swadhinbiswas πŸš€ release: 2.0.8 β€” write-path fix (ADR-080), fork-safe GIL pool (ADR-081), HTTP/3 (ADR-082), Security DI fix, docs honesty pass Β· 8/6/2026
731fffe
488 lines Β· 15.2 KB
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
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
"""JustAPI testing utilities β€” async fixtures, DB helpers, and assertions.

.. warning::
   Test functions **must not** have the same name as imported helpers.
   E.g. ``from justapi.testing import test_db`` then ``def test_db():``
   will cause a name collision β€” rename your test to ``test_my_db:`` instead.



Usage:
    import pytest
    import pytest_asyncio
    from justapi import JustAPIApp
    from justapi.testing import AsyncTestClient, assert_ok, assert_json

    @pytest_asyncio.fixture
    async def app():
        app = JustAPIApp()
        app.get("/ping", lambda r: {"pong": True})
        return app

    @pytest_asyncio.fixture
    async def client(app):
        async with AsyncTestClient(app) as c:
            yield c

    @pytest.mark.asyncio
    async def test_ping(client):
        resp = await client.get("/ping")
        assert_ok(resp)
        assert_json(resp, {"pong": True})

    # With database:
    @pytest_asyncio.fixture
    async def db_client(app):
        async with AsyncTestClient(app, database="sqlite::memory:") as c:
            yield c
"""

import asyncio
import inspect
import json
import os
import subprocess
import sys
import tempfile
from functools import partial
from typing import Any, Awaitable, Callable, Optional


class AsyncTestClient:
    """Async wrapper around *JustAPITestClient* for use in ``pytest-asyncio`` fixtures.

    Use as an async context manager:

        async with AsyncTestClient(app) as client:
            resp = await client.get("/hello")

    To test database-backed routes, pass the *database* URL:

        async with AsyncTestClient(app, database="sqlite::memory:") as client:
            resp = await client.post("/users", b'{"name":"Alice"}')
    """

    def __init__(self, app: "Any", database: Optional[str] = None) -> None:
        self._app = app
        self._database = database
        self._client: Optional["Any"] = None
        self._setup_hooks: list[Callable[["AsyncTestClient"], Awaitable[None]]] = []
        self._teardown_hooks: list[Callable[["AsyncTestClient"], Awaitable[None]]] = []

    async def _build_client(self) -> None:
        from justapi import JustAPITestClient
        
        inner_app = getattr(self._app, "_inner", self._app)
        self._client = JustAPITestClient(inner_app, database=self._database)

    async def _destroy_client(self) -> None:
        self._client = None

    async def __aenter__(self) -> "AsyncTestClient":
        await self._build_client()
        for hook in self._setup_hooks:
            res = hook(self)
            if res is not None:
                await res
        return self

    async def __aexit__(self, *exc_info: Any) -> None:
        for hook in reversed(self._teardown_hooks):
            res = hook(self)
            if res is not None:
                await res
        await self._destroy_client()

    def on_setup(self, hook: Callable[["AsyncTestClient"], Awaitable[None]]) -> "AsyncTestClient":
        self._setup_hooks.append(hook)
        return self

    def on_teardown(self, hook: Callable[["AsyncTestClient"], Awaitable[None]]) -> "AsyncTestClient":
        self._teardown_hooks.append(hook)
        return self

    async def _run(self, method: str, path: str, body: bytes = b"") -> dict:
        client = self._client
        if client is None:
            raise RuntimeError("AsyncTestClient not initialized")

        loop = asyncio.get_running_loop()

        if method in ("GET", "DELETE"):
            fn = partial(getattr(client, method.lower()), path)
        else:
            fn = partial(getattr(client, method.lower()), path, body)

        return await loop.run_in_executor(None, fn)

    async def get(self, path: str) -> dict:
        return await self._run("GET", path)

    async def post(self, path: str, body: bytes = b"") -> dict:
        return await self._run("POST", path, body)

    async def put(self, path: str, body: bytes = b"") -> dict:
        return await self._run("PUT", path, body)

    async def patch(self, path: str, body: bytes = b"") -> dict:
        return await self._run("PATCH", path, body)

    async def delete(self, path: str) -> dict:
        return await self._run("DELETE", path)

    async def query(self, path: str, body: bytes = b"") -> dict:
        client = self._client
        if client is None:
            raise RuntimeError("AsyncTestClient not initialized")
        loop = asyncio.get_running_loop()
        # RFC 10008 requires a Content-Type on QUERY requests.
        fn = partial(client.query_with, path, body, [("Content-Type", "application/json")])
        return await loop.run_in_executor(None, fn)


# ---------------------------------------------------------------------------
# Database test helpers
# ---------------------------------------------------------------------------


class ManagedDb:
    """Managed test database with automatic cleanup.

    Use as an async context manager:

        async with ManagedDb(url="sqlite://:memory:") as db:
            app.set_database(db.url)
            # run routes against in-memory SQLite
    """

    def __init__(self, url: str = "sqlite://:memory:", migrations_dir: Optional[str] = None):
        self._url = url
        self._migrations_dir = migrations_dir
        self._cleanup_paths: list[str] = []

    @property
    def url(self) -> str:
        return self._url

    async def run_migrations(self, directory: Optional[str] = None) -> None:
        """Run SQL migrations against the test database using sqlx CLI."""
        migrations = directory or self._migrations_dir
        if not migrations:
            return
        if not os.path.isdir(migrations):
            raise FileNotFoundError(f"Migrations directory not found: {migrations}")

        loop = asyncio.get_running_loop()

        def _run():
            result = subprocess.run(
                [sys.executable, "-m", "sqlx", "migrate", "run",
                 "--database-url", self._url, "--source", migrations],
                capture_output=True, text=True, timeout=30,
            )
            if result.returncode != 0:
                raise RuntimeError(
                    f"Migration failed: {result.stderr.strip() or result.stdout.strip()}"
                )

        await loop.run_in_executor(None, _run)

    async def run_sql(self, sql: str) -> None:
        """Execute raw SQL against the test database.

        For file-based databases (``sqlite:///path/to/db.db``), runs SQL
        against the same file.  For in-memory databases (``sqlite://:memory:``),
        this opens a *separate* in-memory database β€” use only for schema
        metadata that doesn't need to persist across connections.
        """
        import sqlite3

        path = _sqlite_path(self._url)

        loop = asyncio.get_running_loop()

        def _run():
            conn = sqlite3.connect(path)
            try:
                conn.executescript(sql)
                conn.commit()
            finally:
                conn.close()

        await loop.run_in_executor(None, _run)

    async def __aenter__(self) -> "ManagedDb":
        return self

    async def __aexit__(self, *exc_info: Any) -> None:
        for path in self._cleanup_paths:
            if os.path.exists(path):
                os.unlink(path)


def test_db(url: str = "sqlite://:memory:", migrations_dir: Optional[str] = None) -> ManagedDb:
    """Create a *ManagedDb* instance for use in pytest fixtures.

    Usage:
        @pytest_asyncio.fixture
        async def db():
            async with test_db() as db:
                await db.run_sql("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)")
                yield db
    """
    return ManagedDb(url=url, migrations_dir=migrations_dir)


# ---------------------------------------------------------------------------
# DB fixture helper β€” configure app with test database and return AsyncTestClient
# ---------------------------------------------------------------------------


async def db_client(
    app: "Any",
    url: str = "sqlite://:memory:",
    *,
    seed_sql: Optional[str] = None,
) -> AsyncTestClient:
    """Create an *AsyncTestClient* configured with a test database.

    Usage:
        @pytest_asyncio.fixture
        async def client(app):
            async with await db_client(app, seed_sql="CREATE TABLE ...") as c:
                yield c
    """
    if seed_sql:
        import sqlite3

        db_path = _sqlite_path(url)
        conn = sqlite3.connect(db_path)
        try:
            conn.executescript(seed_sql)
            conn.commit()
        finally:
            conn.close()

    c = AsyncTestClient(app, database=url)
    await c._build_client()
    return c


# ---------------------------------------------------------------------------
# Snapshot testing
# ---------------------------------------------------------------------------


class Snapshot:
    """Response body snapshot assertions.

    Snapshots are stored as JSON files under ``__snapshots__/`` alongside
    the test file.  The first run creates the snapshot; subsequent runs
    compare the response against it.

    Usage:
        from justapi.testing import Snapshot

        snapshot = Snapshot()

        async def test_get_users(client):
            resp = await client.get("/users")
            snapshot.assert_response(resp, "users_list")

    To update all snapshots to match new output::

        SNAPSHOT_UPDATE=1 python -m pytest ...
    """

    def __init__(self, snap_dir: Optional[str] = None):
        self._snap_dir = snap_dir
        self._update = os.environ.get("SNAPSHOT_UPDATE", "").lower() in (
            "1",
            "true",
            "yes",
        )

    # -- Public API -------------------------------------------------------

    def assert_response(self, resp: dict, name: Optional[str] = None) -> None:
        """Assert that a response dict (status + headers + body) matches its
        stored snapshot."""
        path = self._resolve_path(name)
        current = _serialize_response(resp)
        self._assert_match(path, current)

    def assert_match(self, value: Any, name: Optional[str] = None) -> None:
        """Assert that an arbitrary JSON-serialisable value matches its
        stored snapshot."""
        path = self._resolve_path(name)
        current = json.dumps(value, indent=2, default=str, ensure_ascii=False)
        self._assert_match(path, current)

    def assert_body(self, resp: dict, name: Optional[str] = None) -> None:
        """Assert that the raw response body (as bytes) matches its stored
        snapshot (stored as base64)."""
        path = self._resolve_path(name)
        import base64

        current = base64.b64encode(bytes(resp["body"])).decode("ascii")
        self._assert_match(path, current)

    # -- Internals --------------------------------------------------------

    def _resolve_path(self, name: Optional[str] = None) -> str:
        # Walk up the stack to find the first frame outside testing.py
        frame = inspect.currentframe()
        while frame:
            mod_file = frame.f_globals.get("__file__", "")
            if not mod_file.endswith("testing.py"):
                break
            frame = frame.f_back

        if name is not None:
            snap_name = name
        else:
            snap_name = frame.f_code.co_name if frame else "unnamed"

        if self._snap_dir:
            base = self._snap_dir
        else:
            caller_file = frame.f_globals.get("__file__", ".") if frame else "."
            base = os.path.join(os.path.dirname(caller_file), "__snapshots__")

        os.makedirs(base, exist_ok=True)
        return os.path.join(base, f"{snap_name}.snap")

    def _assert_match(self, path: str, current: str) -> None:
        if self._update:
            self._write(path, current)
            return

        if not os.path.exists(path):
            self._write(path, current)
            return

        stored = self._read(path)
        if stored != current:
            msg_lines = [
                f"Snapshot mismatch: {path}",
                "",
                "--- stored",
                "+++ current",
            ]
            for diff in _diff_lines(stored, current):
                msg_lines.append(diff)
            msg_lines.append("")
            msg_lines.append("Run with SNAPSHOT_UPDATE=1 to update.")
            raise AssertionError("\n".join(msg_lines))

    @staticmethod
    def _write(path: str, content: str) -> None:
        with open(path, "w") as f:
            f.write(content)
            f.write("\n")

    @staticmethod
    def _read(path: str) -> str:
        with open(path) as f:
            return f.read().rstrip("\n")


def _serialize_response(resp: dict) -> str:
    import base64

    body_b64 = base64.b64encode(bytes(resp.get("body", b""))).decode("ascii")
    headers = {}
    for k, v in resp.get("headers", {}).items():
        headers[k] = v
    return json.dumps(
        {
            "status": resp.get("status", 200),
            "headers": headers,
            "body_base64": body_b64,
        },
        indent=2,
        ensure_ascii=False,
    )


def _diff_lines(a: str, b: str) -> list[str]:
    """Simple line-by-line diff between two strings."""
    import difflib

    return list(
        difflib.unified_diff(
            a.splitlines(keepends=True),
            b.splitlines(keepends=True),
            fromfile="stored",
            tofile="current",
            lineterm="",
        )
    )


# ---------------------------------------------------------------------------
# Assertion helpers
# ---------------------------------------------------------------------------


def assert_ok(resp: dict) -> None:
    assert resp["status"] == 200, (
        f"Expected 200, got {resp['status']}: {bytes(resp.get('body', b'')).decode(errors='replace')}"
    )


def assert_status(resp: dict, status: int) -> None:
    assert resp["status"] == status, (
        f"Expected {status}, got {resp['status']}: {bytes(resp.get('body', b'')).decode(errors='replace')}"
    )


def assert_json(resp: dict, expected: Any) -> None:
    body = json.loads(bytes(resp["body"]))
    assert body == expected, f"Expected {expected!r}, got {body!r}"


def assert_header(resp: dict, name: str, expected: Optional[str] = None) -> None:
    headers = {k.lower(): v for k, v in resp.get("headers", {}).items()}
    if expected is not None:
        value = headers.get(name.lower())
        assert value == expected, f"Expected header {name}={expected!r}, got {value!r}"
    else:
        assert name.lower() in headers, f"Expected header {name!r} not present"


def transaction_test_db(url: str = "sqlite://:memory:") -> str:
    """Return a database URL suitable for testing (in-memory SQLite by default).

    Usage:
        app.set_database(transaction_test_db())
    """
    return url


def _sqlite_path(url: str) -> str:
    """Extract the file path from a ``sqlite://`` URL for use with ``sqlite3.connect``."""
    if url == "sqlite://:memory:" or url == "sqlite://?mode=memory":
        return ":memory:"
    if url.startswith("sqlite://"):
        path = url[len("sqlite://"):]
        # Strip query parameters
        if "?" in path:
            path = path.split("?")[0]
        return path
    return url


__all__ = [
    "AsyncTestClient",
    "ManagedDb",
    "Snapshot",
    "test_db",
    "db_client",
    "assert_ok",
    "assert_status",
    "assert_json",
    "assert_header",
    "transaction_test_db",
]