create-caspian-app 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,89 +1,89 @@
1
- """Shared pytest setup for the app-level test suite.
2
-
3
- Ensures the project root is importable so `import main` and `from src...`
4
- resolve the same way they do when Caspian boots the app.
5
- """
6
-
7
- import asyncio
8
- import os
9
- import sys
10
- from collections.abc import Coroutine
11
- from pathlib import Path
12
- from types import SimpleNamespace
13
- from typing import Optional, TypeVar
14
-
15
- import pytest
16
-
17
- PROJECT_ROOT = Path(__file__).resolve().parents[1]
18
- if str(PROJECT_ROOT) not in sys.path:
19
- sys.path.insert(0, str(PROJECT_ROOT))
20
-
21
- _T = TypeVar("_T")
22
-
23
-
24
- def run_async(coro: Coroutine[object, object, _T]) -> _T:
25
- """Run a coroutine to completion and return its result.
26
-
27
- Shared helper so synchronous tests can exercise async runtime code
28
- (component compilation, streaming) without a full async test harness.
29
- """
30
- return asyncio.run(coro)
31
-
32
-
33
- @pytest.fixture
34
- def fake_request():
35
- """Factory for a minimal Starlette-like request stand-in.
36
-
37
- Casp's ``Auth`` reads ``request.session`` while ``StateManager`` reads
38
- ``request.state.session`` and ``request.headers``. The factory shares a
39
- single session dict between ``request.session`` and ``request.state.session``
40
- so both runtimes observe the same store, and exposes ``url``/``method``/
41
- ``query_params`` defaults that the auth request-flow helpers touch.
42
- """
43
-
44
- def _make(
45
- session: Optional[dict] = None,
46
- headers: Optional[dict] = None,
47
- method: str = "GET",
48
- path: str = "/",
49
- query_params: Optional[dict] = None,
50
- ) -> SimpleNamespace:
51
- store: dict = session if session is not None else {}
52
- return SimpleNamespace(
53
- session=store,
54
- state=SimpleNamespace(session=store),
55
- headers=headers if headers is not None else {},
56
- method=method,
57
- url=SimpleNamespace(path=path),
58
- query_params=query_params if query_params is not None else {},
59
- )
60
-
61
- return _make
62
-
63
-
64
- @pytest.fixture
65
- def component_fixture_dir():
66
- """A throwaway directory for writing component modules a test then imports.
67
-
68
- Tests write ``.py`` component files here and pass the path as ``base_dir``
69
- to ``transform_components`` so relative ``@import`` paths resolve against a
70
- clean, per-test directory. The compiler only loads components from inside a
71
- configured ``componentScanDirs`` root (``src`` here), so the directory must
72
- live under ``src`` rather than an arbitrary ``tmp_path``.
73
- """
74
- import shutil
75
- import uuid
76
-
77
- scan_root = PROJECT_ROOT / "src" / "__test_components__"
78
- fixture_dir = scan_root / uuid.uuid4().hex
79
- fixture_dir.mkdir(parents=True, exist_ok=True)
80
- try:
81
- yield fixture_dir
82
- finally:
83
- shutil.rmtree(scan_root, ignore_errors=True)
84
-
85
- # Keep the app in its non-production code paths during tests (dev security
86
- # headers, readable error messages, no HTTPS-only cookies).
87
- os.environ.setdefault("APP_ENV", "development")
88
- # Deterministic session secret so importing main.py never raises in dev.
89
- os.environ.setdefault("AUTH_SECRET", "test-secret-not-for-production")
1
+ """Shared pytest setup for the app-level test suite.
2
+
3
+ Ensures the project root is importable so `import main` and `from src...`
4
+ resolve the same way they do when Caspian boots the app.
5
+ """
6
+
7
+ import asyncio
8
+ import os
9
+ import sys
10
+ from collections.abc import Coroutine
11
+ from pathlib import Path
12
+ from types import SimpleNamespace
13
+ from typing import Optional, TypeVar
14
+
15
+ import pytest
16
+
17
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
18
+ if str(PROJECT_ROOT) not in sys.path:
19
+ sys.path.insert(0, str(PROJECT_ROOT))
20
+
21
+ _T = TypeVar("_T")
22
+
23
+
24
+ def run_async(coro: Coroutine[object, object, _T]) -> _T:
25
+ """Run a coroutine to completion and return its result.
26
+
27
+ Shared helper so synchronous tests can exercise async runtime code
28
+ (component compilation, streaming) without a full async test harness.
29
+ """
30
+ return asyncio.run(coro)
31
+
32
+
33
+ @pytest.fixture
34
+ def fake_request():
35
+ """Factory for a minimal Starlette-like request stand-in.
36
+
37
+ Casp's ``Auth`` reads ``request.session`` while ``StateManager`` reads
38
+ ``request.state.session`` and ``request.headers``. The factory shares a
39
+ single session dict between ``request.session`` and ``request.state.session``
40
+ so both runtimes observe the same store, and exposes ``url``/``method``/
41
+ ``query_params`` defaults that the auth request-flow helpers touch.
42
+ """
43
+
44
+ def _make(
45
+ session: Optional[dict] = None,
46
+ headers: Optional[dict] = None,
47
+ method: str = "GET",
48
+ path: str = "/",
49
+ query_params: Optional[dict] = None,
50
+ ) -> SimpleNamespace:
51
+ store: dict = session if session is not None else {}
52
+ return SimpleNamespace(
53
+ session=store,
54
+ state=SimpleNamespace(session=store),
55
+ headers=headers if headers is not None else {},
56
+ method=method,
57
+ url=SimpleNamespace(path=path),
58
+ query_params=query_params if query_params is not None else {},
59
+ )
60
+
61
+ return _make
62
+
63
+
64
+ @pytest.fixture
65
+ def component_fixture_dir():
66
+ """A throwaway directory for writing component modules a test then imports.
67
+
68
+ Tests write ``.py`` component files here and pass the path as ``base_dir``
69
+ to ``transform_components`` so relative ``@import`` paths resolve against a
70
+ clean, per-test directory. The compiler only loads components from inside a
71
+ configured ``componentScanDirs`` root (``src`` here), so the directory must
72
+ live under ``src`` rather than an arbitrary ``tmp_path``.
73
+ """
74
+ import shutil
75
+ import uuid
76
+
77
+ scan_root = PROJECT_ROOT / "src" / "__test_components__"
78
+ fixture_dir = scan_root / uuid.uuid4().hex
79
+ fixture_dir.mkdir(parents=True, exist_ok=True)
80
+ try:
81
+ yield fixture_dir
82
+ finally:
83
+ shutil.rmtree(scan_root, ignore_errors=True)
84
+
85
+ # Keep the app in its non-production code paths during tests (dev security
86
+ # headers, readable error messages, no HTTPS-only cookies).
87
+ os.environ.setdefault("APP_ENV", "development")
88
+ # Deterministic session secret so importing main.py never raises in dev.
89
+ os.environ.setdefault("AUTH_SECRET", "test-secret-not-for-production")
@@ -1,44 +1,44 @@
1
- """Integration test for the app's HTTP surface via httpx2's ASGI transport.
2
-
3
- `/health` is the simplest always-on route and exercises the full middleware
4
- stack (session, CSRF, security headers, auth) end to end. We drive the ASGI
5
- app directly with httpx2's AsyncClient over its ASGITransport rather than
6
- Starlette's TestClient.
7
- """
8
-
9
- import asyncio
10
-
11
- import httpx2
12
-
13
- import main
14
-
15
-
16
- async def _get(path: str) -> httpx2.Response:
17
- # ASGITransport speaks to the app in-process; no server/socket required.
18
- transport = httpx2.ASGITransport(app=main.app)
19
- async with httpx2.AsyncClient(
20
- transport=transport, base_url="http://testserver"
21
- ) as client:
22
- return await client.get(path)
23
-
24
-
25
- def _get_sync(path: str) -> httpx2.Response:
26
- # Run the async request from a plain sync test (no pytest-asyncio needed).
27
- return asyncio.run(_get(path))
28
-
29
-
30
- def test_health_returns_ok():
31
- response = _get_sync("/health")
32
- assert response.status_code == 200
33
- assert response.json() == {"status": "ok"}
34
-
35
-
36
- def test_health_carries_security_headers():
37
- response = _get_sync("/health")
38
- # SecurityHeadersMiddleware attaches baseline browser headers.
39
- assert response.headers.get("x-content-type-options") == "nosniff"
40
-
41
-
42
- def test_unknown_route_is_404():
43
- response = _get_sync("/definitely-not-a-real-route")
44
- assert response.status_code == 404
1
+ """Integration test for the app's HTTP surface via httpx2's ASGI transport.
2
+
3
+ `/health` is the simplest always-on route and exercises the full middleware
4
+ stack (session, CSRF, security headers, auth) end to end. We drive the ASGI
5
+ app directly with httpx2's AsyncClient over its ASGITransport rather than
6
+ Starlette's TestClient.
7
+ """
8
+
9
+ import asyncio
10
+
11
+ import httpx2
12
+
13
+ import main
14
+
15
+
16
+ async def _get(path: str) -> httpx2.Response:
17
+ # ASGITransport speaks to the app in-process; no server/socket required.
18
+ transport = httpx2.ASGITransport(app=main.app)
19
+ async with httpx2.AsyncClient(
20
+ transport=transport, base_url="http://testserver"
21
+ ) as client:
22
+ return await client.get(path)
23
+
24
+
25
+ def _get_sync(path: str) -> httpx2.Response:
26
+ # Run the async request from a plain sync test (no pytest-asyncio needed).
27
+ return asyncio.run(_get(path))
28
+
29
+
30
+ def test_health_returns_ok():
31
+ response = _get_sync("/health")
32
+ assert response.status_code == 200
33
+ assert response.json() == {"status": "ok"}
34
+
35
+
36
+ def test_health_carries_security_headers():
37
+ response = _get_sync("/health")
38
+ # SecurityHeadersMiddleware attaches baseline browser headers.
39
+ assert response.headers.get("x-content-type-options") == "nosniff"
40
+
41
+
42
+ def test_unknown_route_is_404():
43
+ response = _get_sync("/definitely-not-a-real-route")
44
+ assert response.status_code == 404
@@ -1,112 +1,112 @@
1
- """Unit tests for the pure helper functions in `main.py`.
2
-
3
- These cover the app-owned logic that has no external dependencies: env
4
- parsing, query-param coercion, and the component-deferral HTML transform.
5
- """
6
-
7
- import inspect
8
- from typing import Optional
9
-
10
- import main
11
-
12
-
13
- class TestEnvParsing:
14
- def test_csv_env_splits_and_strips(self, monkeypatch):
15
- monkeypatch.setenv("SOME_LIST", " a , b ,, c ")
16
- assert main._csv_env("SOME_LIST") == ["a", "b", "c"]
17
-
18
- def test_csv_env_missing_is_empty(self, monkeypatch):
19
- monkeypatch.delenv("SOME_LIST", raising=False)
20
- assert main._csv_env("SOME_LIST") == []
21
-
22
- def test_bool_env_truthy_values(self, monkeypatch):
23
- for raw in ("1", "true", "TRUE", "yes", "on"):
24
- monkeypatch.setenv("FLAG", raw)
25
- assert main._bool_env("FLAG") is True
26
-
27
- def test_bool_env_falsy_and_default(self, monkeypatch):
28
- monkeypatch.setenv("FLAG", "nope")
29
- assert main._bool_env("FLAG") is False
30
- monkeypatch.delenv("FLAG", raising=False)
31
- assert main._bool_env("FLAG", default=True) is True
32
-
33
-
34
- class TestScalarCoercion:
35
- def test_int_and_float(self):
36
- assert main._coerce_scalar("42", int) == 42
37
- assert main._coerce_scalar("3.5", float) == 3.5
38
-
39
- def test_bool_variants(self):
40
- assert main._coerce_scalar("yes", bool) is True
41
- assert main._coerce_scalar("off", bool) is False
42
-
43
- def test_none_passthrough(self):
44
- assert main._coerce_scalar(None, int) is None
45
-
46
- def test_bad_int_falls_back_to_string(self):
47
- # Best-effort coercion: unparseable input returns the raw string.
48
- assert main._coerce_scalar("not-a-number", int) == "not-a-number"
49
-
50
- def test_optional_is_unwrapped(self):
51
- assert main._coerce_scalar("7", Optional[int]) == 7
52
-
53
-
54
- class TestUnwrapOptional:
55
- def test_unwraps_optional(self):
56
- assert main._unwrap_optional(Optional[int]) is int
57
-
58
- def test_leaves_plain_type(self):
59
- assert main._unwrap_optional(str) is str
60
-
61
-
62
- class TestQueryParamCoercion:
63
- def _param(self, annotation):
64
- return inspect.Parameter(
65
- "x", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=annotation
66
- )
67
-
68
- def test_list_param(self):
69
- from starlette.datastructures import QueryParams
70
- from starlette.requests import Request
71
-
72
- scope = {
73
- "type": "http",
74
- "query_string": b"x=1&x=2&x=3",
75
- "headers": [],
76
- }
77
- request = Request(scope)
78
- # sanity: the request exposes the multi-value list
79
- assert isinstance(request.query_params, QueryParams)
80
- result = main._coerce_query_param(request, "x", self._param(list[int]))
81
- assert result == [1, 2, 3]
82
-
83
- def test_scalar_param(self):
84
- from starlette.requests import Request
85
-
86
- scope = {"type": "http", "query_string": b"x=10", "headers": []}
87
- request = Request(scope)
88
- result = main._coerce_query_param(request, "x", self._param(int))
89
- assert result == 10
90
-
91
-
92
- class TestDeferComponentRoots:
93
- def test_no_component_is_untouched(self):
94
- html = "<div>hello</div>"
95
- assert main.defer_component_roots(html) == html
96
-
97
- def test_component_root_is_wrapped_in_template(self):
98
- # The transform operates on a full document body, as in production.
99
- html = "<body><div pp-component=\"abc\"><span>x</span></div></body>"
100
- out = main.defer_component_roots(html)
101
- assert "<template" in out
102
- assert 'pp-component="abc"' in out
103
-
104
- def test_finalize_html_defers_and_tags_scripts(self):
105
- html = (
106
- "<body><div pp-component=\"abc\">"
107
- "<script>console.log(1)</script></div></body>"
108
- )
109
- out = main.finalize_html(html)
110
- # transform_scripts retags author scripts before deferral.
111
- assert 'type="text/pp"' in out
112
- assert "<template" in out
1
+ """Unit tests for the pure helper functions in `main.py`.
2
+
3
+ These cover the app-owned logic that has no external dependencies: env
4
+ parsing, query-param coercion, and the component-deferral HTML transform.
5
+ """
6
+
7
+ import inspect
8
+ from typing import Optional
9
+
10
+ import main
11
+
12
+
13
+ class TestEnvParsing:
14
+ def test_csv_env_splits_and_strips(self, monkeypatch):
15
+ monkeypatch.setenv("SOME_LIST", " a , b ,, c ")
16
+ assert main._csv_env("SOME_LIST") == ["a", "b", "c"]
17
+
18
+ def test_csv_env_missing_is_empty(self, monkeypatch):
19
+ monkeypatch.delenv("SOME_LIST", raising=False)
20
+ assert main._csv_env("SOME_LIST") == []
21
+
22
+ def test_bool_env_truthy_values(self, monkeypatch):
23
+ for raw in ("1", "true", "TRUE", "yes", "on"):
24
+ monkeypatch.setenv("FLAG", raw)
25
+ assert main._bool_env("FLAG") is True
26
+
27
+ def test_bool_env_falsy_and_default(self, monkeypatch):
28
+ monkeypatch.setenv("FLAG", "nope")
29
+ assert main._bool_env("FLAG") is False
30
+ monkeypatch.delenv("FLAG", raising=False)
31
+ assert main._bool_env("FLAG", default=True) is True
32
+
33
+
34
+ class TestScalarCoercion:
35
+ def test_int_and_float(self):
36
+ assert main._coerce_scalar("42", int) == 42
37
+ assert main._coerce_scalar("3.5", float) == 3.5
38
+
39
+ def test_bool_variants(self):
40
+ assert main._coerce_scalar("yes", bool) is True
41
+ assert main._coerce_scalar("off", bool) is False
42
+
43
+ def test_none_passthrough(self):
44
+ assert main._coerce_scalar(None, int) is None
45
+
46
+ def test_bad_int_falls_back_to_string(self):
47
+ # Best-effort coercion: unparseable input returns the raw string.
48
+ assert main._coerce_scalar("not-a-number", int) == "not-a-number"
49
+
50
+ def test_optional_is_unwrapped(self):
51
+ assert main._coerce_scalar("7", Optional[int]) == 7
52
+
53
+
54
+ class TestUnwrapOptional:
55
+ def test_unwraps_optional(self):
56
+ assert main._unwrap_optional(Optional[int]) is int
57
+
58
+ def test_leaves_plain_type(self):
59
+ assert main._unwrap_optional(str) is str
60
+
61
+
62
+ class TestQueryParamCoercion:
63
+ def _param(self, annotation):
64
+ return inspect.Parameter(
65
+ "x", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=annotation
66
+ )
67
+
68
+ def test_list_param(self):
69
+ from starlette.datastructures import QueryParams
70
+ from starlette.requests import Request
71
+
72
+ scope = {
73
+ "type": "http",
74
+ "query_string": b"x=1&x=2&x=3",
75
+ "headers": [],
76
+ }
77
+ request = Request(scope)
78
+ # sanity: the request exposes the multi-value list
79
+ assert isinstance(request.query_params, QueryParams)
80
+ result = main._coerce_query_param(request, "x", self._param(list[int]))
81
+ assert result == [1, 2, 3]
82
+
83
+ def test_scalar_param(self):
84
+ from starlette.requests import Request
85
+
86
+ scope = {"type": "http", "query_string": b"x=10", "headers": []}
87
+ request = Request(scope)
88
+ result = main._coerce_query_param(request, "x", self._param(int))
89
+ assert result == 10
90
+
91
+
92
+ class TestDeferComponentRoots:
93
+ def test_no_component_is_untouched(self):
94
+ html = "<div>hello</div>"
95
+ assert main.defer_component_roots(html) == html
96
+
97
+ def test_component_root_is_wrapped_in_template(self):
98
+ # The transform operates on a full document body, as in production.
99
+ html = "<body><div pp-component=\"abc\"><span>x</span></div></body>"
100
+ out = main.defer_component_roots(html)
101
+ assert "<template" in out
102
+ assert 'pp-component="abc"' in out
103
+
104
+ def test_finalize_html_defers_and_tags_scripts(self):
105
+ html = (
106
+ "<body><div pp-component=\"abc\">"
107
+ "<script>console.log(1)</script></div></body>"
108
+ )
109
+ out = main.finalize_html(html)
110
+ # transform_scripts retags author scripts before deferral.
111
+ assert 'type="text/pp"' in out
112
+ assert "<template" in out
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-caspian-app",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Scaffold a new Caspian project (FastAPI-powered reactive Python framework).",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",