From 9423295559f8475a1d281813f29ade1fe22b6080 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sun, 25 Oct 2026 01:29:06 -0700 Subject: [PATCH 1/3] Fix storage copy/binary get and request-scope Vite nonce and Inertia root view/version LocalDriver.copy resolves both paths through get_path. Local and S3 get read bytes and fall back to raw bytes for non-UTF-8 content; get_bytes is added to the drivers, StorageManager and the Storage facade. Vite use_*_tag_attributes accept dicts (the wrapping lambda referenced itself), and the CSP nonce and preloaded assets live in per-instance ContextVars. Inertia set_root_view/version write to the request state inside a request. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SZhGWNwFRzgkFffoHce4Ht --- ...ry-copy-and-request-scoped-vite-inertia.md | 47 ++++++++++++++++ docs/adr/AGENTS.md | 1 + .../src/fastapi_startkit/inertia/inertia.py | 13 ++++- .../fastapi_startkit/storage/drivers/local.py | 14 ++--- .../fastapi_startkit/storage/drivers/s3.py | 12 +++-- .../src/fastapi_startkit/storage/helper.py | 7 +++ .../src/fastapi_startkit/storage/storage.py | 7 +++ .../src/fastapi_startkit/vite/vite.py | 54 +++++++++++-------- .../tests/inertia/test_inertia_context.py | 37 +++++++++++++ .../tests/storage/test_local_driver.py | 24 +++++++++ .../tests/storage/test_s3_driver.py | 23 ++++++++ .../tests/storage/test_storage.py | 12 +++++ fastapi_startkit/tests/vite/test_vite.py | 39 ++++++++++++++ 13 files changed, 254 insertions(+), 36 deletions(-) create mode 100644 docs/adr/037-storage-binary-copy-and-request-scoped-vite-inertia.md diff --git a/docs/adr/037-storage-binary-copy-and-request-scoped-vite-inertia.md b/docs/adr/037-storage-binary-copy-and-request-scoped-vite-inertia.md new file mode 100644 index 00000000..046d4a84 --- /dev/null +++ b/docs/adr/037-storage-binary-copy-and-request-scoped-vite-inertia.md @@ -0,0 +1,47 @@ +# 037. Storage binary reads and relative copy; request-scoped Vite and Inertia state + +Date: 2026-10-25 + +## Problem + +Storage (`storage/drivers/local.py`, `s3.py`): + +- `LocalDriver.copy` passed its arguments straight to `shutil.copyfile`, so `Storage.copy("a.txt", "b.txt")` resolved against the process working directory instead of the disk root and raised `FileNotFoundError`. +- `LocalDriver.get` opened files in text mode and `S3Driver.get` decoded the body as UTF-8, so reading a binary file such as a `.png` raised `UnicodeDecodeError`. No driver offered a bytes-returning read. + +Vite (`vite/vite.py`): + +- `use_script_tag_attributes`, `use_style_tag_attributes` and `use_preload_tag_attributes` wrapped a dict as `attributes = lambda *_: attributes`. The lambda closes over the rebound name, so it returned itself and `dict.update` raised `TypeError`. Passing a dict, as documented, never worked. +- The CSP nonce and preloaded-asset map were instance attributes on the single `vite` container binding. A nonce set while handling one request was used by every concurrent request. + +Inertia (`inertia/inertia.py`): + +- `Inertia.set_root_view` and `Inertia.version` always wrote to the process-wide `ResponseFactory`. Called inside a request, they changed the root view and version for every concurrent and later request. ADR 007 already gives each request an `InertiaRequestState`; these two setters ignored it. + +## Alternatives + +Binary reads: + +1. Change `get` to always return `bytes`. Breaks every caller and test that expects text. +2. Add only a `get_bytes` method. Keeps `get` crashing on binary files. +3. `get` reads bytes and returns `str` when the content is valid UTF-8, otherwise the raw `bytes`; plus a `get_bytes` that always returns `bytes`. + +Per-request Vite state: + +1. Create a `Vite` instance per request. Every template global and facade would need a request-aware lookup. +2. Keep the nonce on a `ContextVar`. Each asyncio task (each request under Starlette) sees its own value without any change to callers. + +## Decision + +- `LocalDriver.copy` resolves both paths through `get_path`, like `move`. Absolute paths still work because `os.path.join` keeps them. +- Binary reads take option 3 on both the local and S3 drivers. `get_bytes` returns `None` for a missing file, the same as `get`. `decode_text` in `storage/helper.py` is shared. `StorageManager` and the `Storage` facade proxy `get_bytes`. Reading in binary mode means `get` no longer converts `\r\n` to `\n`. +- Vite turns non-callable attributes into a resolver through `_as_resolver`, which binds the value instead of the name. Dicts and `False` (to skip preloads) now work. +- Vite stores the nonce and the preloaded-asset map in `ContextVar`s owned by the instance. `use_csp_nonce`, `csp_nonce`, `preloaded_assets` and `flush` keep their signatures. The manifest cache stays shared, because it is keyed by path and does not depend on the request. +- `ResponseFactory.set_root_view` and `set_version` write to the current `InertiaRequestState` when one exists, and to the global defaults otherwise, the same as `share` and `set_encrypt_history`. A version set during a request is resolved immediately and wins over the middleware's version for that request. + +## Validation + +- Local driver: relative `copy`, a UTF-8 `get`, a binary `get` and `get_bytes`, and a missing file through `get_bytes`. The S3 driver gets matching tests, and a `Storage.fake` test covers the facade. +- Vite: dict attributes for script, style and preload tags, `False` for preloads, and two concurrent tasks that each set a different nonce and render tags. The test checks that each task sees only its own nonce and that the outer context stays unset. +- Inertia: two concurrent requests through `InertiaMiddleware` each set a root view and version, then wait on a shared barrier. Each response returns its own values, and the global defaults are unchanged. Outside a request, the setters still configure the defaults. +- All new tests fail on the old code. diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md index c13f7f1b..a21ca18d 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -35,3 +35,4 @@ Read this index first, then open the records relevant to the change. Before impl | [029](029-cache-module.md) | 2026-10-21 | Cache module with remember, memo, locks and tags | New `fastapi_startkit.cache`: async `Cache.get/put/has/forget/flush`, `get` with a callable default, and `remember(key, ttl, factory)` that runs a sync or async factory only on a miss; `remember(key, ttl)` without a factory is a decorator keyed by `key` plus a hash of the call arguments. Stores implement an async contract with a `MISSING` sentinel (cached `None` is a hit): array, file (pickled per-key files), database (masoniteorm table) and redis (named connection from the existing `RedisManager`, `SET PX` TTL, flush by `SCAN` of prefixed keys, never `FLUSHDB`). TTL in seconds or `timedelta`, `None` forever, <=0 forgets. `CacheConfig` via `resolve_config`; `CacheProvider` binds `cache`; facade `remember` resolves lazily. `Cache.memo()` memoizes reads per asyncio task through a `ContextVar` owned by the task, so requests never share memo state. `Cache.lock(name, seconds, owner)` gives atomic, owner-checked, non-re-entrant locks with `get`, `block` (250 ms polling, `LockTimeoutError`), `release`, `force_release` and `async with`, per store: event-loop dict, `fcntl.flock` file, `lock:` row with update-if-expired, Redis `SET NX PX` plus a Lua owner-checked delete. `Cache.tags([...])` uses Laravel's namespaced keys: random tag ids at `:tag::key`, entries at `:sha1(ids):`, flush rewrites the ids; tags are sorted and deduplicated. Shipped in slices into `feature/caches`; the former memo, lock and tags ADRs (030, 031, 034) are folded in. | | [032](032-migrate-seed-option.md) | 2026-10-21 | db:migrate:fresh and db:migrate:refresh --seed run seeders in the same event loop | `--seed` called the unregistered `seed:run` through Cleo `call` (which also drops the first argument) inside a running event loop, where `db:seed` would call `asyncio.run` again. The seeder selection moves into async `DBSeedCommand.run_seeders`, which `db:seed`, `db:migrate:fresh` and `db:migrate:refresh` all await. `db:migrate:refresh` is now registered. | | [033](033-async-seeder-stub.md) | 2026-10-21 | Generated seeders have an async run | `create_seed.stub` generated a synchronous `run` with docstrings, but `Seeder` awaits `run()`, so every generated table seeder raised `TypeError`. The stub now generates `async def run(self) -> None` with no docstrings; `Seeder` is unchanged. | +| [037](037-storage-binary-copy-and-request-scoped-vite-inertia.md) | 2026-10-25 | Storage binary reads and relative copy; request-scoped Vite and Inertia state | `LocalDriver.copy` resolves both paths through `get_path`. Local and S3 `get` read bytes and return `str` for UTF-8 content or raw `bytes` otherwise, and a new `get_bytes` (also on `StorageManager`/`Storage`) always returns bytes. Vite `use_*_tag_attributes` accept dicts and `False`; the self-referencing lambda is gone. The Vite CSP nonce and preloaded-asset map live in per-instance `ContextVar`s, so concurrent requests never share them. `Inertia.set_root_view`/`Inertia.version` write to the current `InertiaRequestState` inside a request and to global defaults otherwise. | diff --git a/fastapi_startkit/src/fastapi_startkit/inertia/inertia.py b/fastapi_startkit/src/fastapi_startkit/inertia/inertia.py index a259dd1f..8cd1c768 100644 --- a/fastapi_startkit/src/fastapi_startkit/inertia/inertia.py +++ b/fastapi_startkit/src/fastapi_startkit/inertia/inertia.py @@ -42,7 +42,11 @@ def __init__(self): self.preserve_big_integers_enabled: bool = False def set_root_view(self, view: str): - self.root_view = view + state = current_state.get() + if state is not None: + state.root_view = view + else: + self.root_view = view def share(self, key: str | dict[str, Any], value: Any = None): state = current_state.get() @@ -58,7 +62,12 @@ def shared(self) -> dict[str, Any]: return {**self.shared_props, **request_props} def set_version(self, version): - self.version = version + state = current_state.get() + if state is not None: + state.resolved_version = self.resolve_version(version) + state.version_resolved = True + else: + self.version = version def get_version(self) -> str | None: state = current_state.get() diff --git a/fastapi_startkit/src/fastapi_startkit/storage/drivers/local.py b/fastapi_startkit/src/fastapi_startkit/storage/drivers/local.py index 199a0dc4..0ce42a45 100644 --- a/fastapi_startkit/src/fastapi_startkit/storage/drivers/local.py +++ b/fastapi_startkit/src/fastapi_startkit/storage/drivers/local.py @@ -5,7 +5,7 @@ from ..filestream import FileStream from ..file import File -from ..helper import get_extension +from ..helper import decode_text, get_extension class LocalDriver: @@ -55,11 +55,13 @@ def put_file(self, file_path, content, name=None): return os.path.join(file_path, file_name) def get(self, file_path): - try: - with open(self.get_path(file_path), "r") as f: - content = f.read() + content = self.get_bytes(file_path) + return None if content is None else decode_text(content) - return content + def get_bytes(self, file_path): + try: + with open(self.get_path(file_path), "rb") as f: + return f.read() except FileNotFoundError: return None @@ -75,7 +77,7 @@ def stream(self, file_path): return FileStream(content) def copy(self, from_file_path, to_file_path): - return copyfile(from_file_path, to_file_path) + return copyfile(self.get_path(from_file_path), self.get_path(to_file_path)) def move(self, from_file_path, to_file_path): return move(self.get_path(from_file_path), self.get_path(to_file_path)) diff --git a/fastapi_startkit/src/fastapi_startkit/storage/drivers/s3.py b/fastapi_startkit/src/fastapi_startkit/storage/drivers/s3.py index b9d17f5e..629f269d 100644 --- a/fastapi_startkit/src/fastapi_startkit/storage/drivers/s3.py +++ b/fastapi_startkit/src/fastapi_startkit/storage/drivers/s3.py @@ -2,7 +2,7 @@ import uuid from ..file import File -from ..helper import get_extension +from ..helper import decode_text, get_extension class S3Driver: @@ -67,12 +67,14 @@ def put_file(self, file_path, content, name=None): return os.path.join(file_path, file_name) def get(self, file_path): + content = self.get_bytes(file_path) + return None if content is None else decode_text(content) + + def get_bytes(self, file_path): try: - return ( - self.get_resource().Bucket(self.get_bucket()).Object(file_path).get().get("Body").read().decode("utf-8") - ) + return self.get_resource().Bucket(self.get_bucket()).Object(file_path).get().get("Body").read() except self.missing_file_exceptions(): - pass + return None def missing_file_exceptions(self): import botocore # pyright: ignore[reportMissingImports] # boto3 is an optional extra, imported lazily diff --git a/fastapi_startkit/src/fastapi_startkit/storage/helper.py b/fastapi_startkit/src/fastapi_startkit/storage/helper.py index 08c62a49..5d4f59ec 100644 --- a/fastapi_startkit/src/fastapi_startkit/storage/helper.py +++ b/fastapi_startkit/src/fastapi_startkit/storage/helper.py @@ -31,3 +31,10 @@ def get_extension(filepath: str, without_dot=False) -> str: if without_dot: extension = extension[1:] return extension + + +def decode_text(content: bytes) -> str | bytes: + try: + return content.decode("utf-8") + except UnicodeDecodeError: + return content diff --git a/fastapi_startkit/src/fastapi_startkit/storage/storage.py b/fastapi_startkit/src/fastapi_startkit/storage/storage.py index 81e9b8c1..b458dc70 100644 --- a/fastapi_startkit/src/fastapi_startkit/storage/storage.py +++ b/fastapi_startkit/src/fastapi_startkit/storage/storage.py @@ -74,6 +74,9 @@ def put(self, *args, **kwargs): def get(self, *args, **kwargs): return self.disk().get(*args, **kwargs) + def get_bytes(self, *args, **kwargs): + return self.disk().get_bytes(*args, **kwargs) + def exists(self, *args, **kwargs): return self.disk().exists(*args, **kwargs) @@ -140,6 +143,10 @@ def put(cls, *args, **kwargs): def get(cls, *args, **kwargs): return cls.init().get(*args, **kwargs) + @classmethod + def get_bytes(cls, *args, **kwargs): + return cls.init().get_bytes(*args, **kwargs) + @classmethod def exists(cls, *args, **kwargs): return cls.init().exists(*args, **kwargs) diff --git a/fastapi_startkit/src/fastapi_startkit/vite/vite.py b/fastapi_startkit/src/fastapi_startkit/vite/vite.py index 179f22bc..8329bc75 100644 --- a/fastapi_startkit/src/fastapi_startkit/vite/vite.py +++ b/fastapi_startkit/src/fastapi_startkit/vite/vite.py @@ -4,6 +4,7 @@ import re import secrets from collections.abc import Callable +from contextvars import ContextVar from typing import Any, Literal @@ -44,7 +45,7 @@ def __init__( # Optional base URL prefix for all generated asset URLs. self._asset_url = asset_url.rstrip("/") - self._nonce: str | None = None + self._nonce: ContextVar[str | None] = ContextVar(f"vite_nonce_{id(self)}", default=None) self._integrity_key: str | bool = "integrity" self._entry_points: list[str] = [] self._asset_path_resolver: Callable[[str], str] | None = None @@ -54,7 +55,9 @@ def __init__( self._style_tag_attributes_resolvers: list[Callable[..., Any]] = [] self._preload_tag_attributes_resolvers: list[Callable[..., Any]] = [] - self._preloaded_assets: dict[str, list[str]] = {} + self._preloaded: ContextVar[dict[str, list[str]] | None] = ContextVar( + f"vite_preloaded_assets_{id(self)}", default=None + ) # ------------------------------------------------------------------ # Public configuration API @@ -62,11 +65,12 @@ def __init__( def use_csp_nonce(self, nonce: str | None = None) -> str: """Generate (or set) a CSP nonce applied to all generated tags.""" - self._nonce = nonce or secrets.token_hex(20) - return self._nonce + value = nonce or secrets.token_hex(20) + self._nonce.set(value) + return value def csp_nonce(self) -> str | None: - return self._nonce + return self._nonce.get() def use_integrity_key(self, key: str | bool) -> "Vite": """Set the manifest key used to read SRI integrity hashes.""" @@ -84,23 +88,23 @@ def create_asset_paths_using(self, resolver: Callable[[str], str] | None) -> "Vi return self def use_script_tag_attributes(self, attributes: dict[str, Any] | Callable[..., Any]) -> "Vite": - if not callable(attributes): - attributes = lambda *_: attributes # noqa: E731 - self._script_tag_attributes_resolvers.append(attributes) + self._script_tag_attributes_resolvers.append(self._as_resolver(attributes)) return self def use_style_tag_attributes(self, attributes: dict[str, Any] | Callable[..., Any]) -> "Vite": - if not callable(attributes): - attributes = lambda *_: attributes # noqa: E731 - self._style_tag_attributes_resolvers.append(attributes) + self._style_tag_attributes_resolvers.append(self._as_resolver(attributes)) return self def use_preload_tag_attributes(self, attributes: dict[str, Any] | bool | Callable[..., Any]) -> "Vite": - if not callable(attributes): - attributes = lambda *_: attributes # noqa: E731 - self._preload_tag_attributes_resolvers.append(attributes) + self._preload_tag_attributes_resolvers.append(self._as_resolver(attributes)) return self + @staticmethod + def _as_resolver(attributes: Any) -> Callable[..., Any]: + if callable(attributes): + return attributes + return lambda *_: attributes + # ------------------------------------------------------------------ # Core HTML generation # ------------------------------------------------------------------ @@ -215,7 +219,8 @@ def react_refresh(self) -> str: if not self.is_running_hot(): return "" - nonce_attr = f' nonce="{self._nonce}"' if self._nonce else "" + nonce = self.csp_nonce() + nonce_attr = f' nonce="{nonce}"' if nonce else "" hot_url = f"{self._read_hot_origin()}/@react-refresh" return ( @@ -247,11 +252,14 @@ def is_running_hot(self) -> bool: return os.path.isfile(self._hot_file) def preloaded_assets(self) -> dict[str, list[str]]: - return self._preloaded_assets + assets = self._preloaded.get() + if assets is None: + assets = {} + self._preloaded.set(assets) + return assets def flush(self) -> None: - """Reset per-request state (call this after each response in long-running servers).""" - self._preloaded_assets = {} + self._preloaded.set(None) def to_html(self) -> str: """Render pre-configured entry points as HTML (used as Htmlable).""" @@ -343,7 +351,7 @@ def _make_preload_tag_for_chunk( if attributes is False: return "" - self._preloaded_assets[url] = self._parse_attributes({k: v for k, v in attributes.items() if k != "href"}) + self.preloaded_assets()[url] = self._parse_attributes({k: v for k, v in attributes.items() if k != "href"}) return f"" def _resolve_script_tag_attributes(self, src, url, chunk, manifest) -> dict[str, Any]: @@ -368,7 +376,7 @@ def _resolve_preload_tag_attributes(self, src, url, chunk, manifest) -> dict[str "rel": "preload", "as": "style", "href": url, - "nonce": self._nonce or False, + "nonce": self.csp_nonce() or False, "crossorigin": self._resolve_stylesheet_tag_attributes(src, url, chunk, manifest).get( "crossorigin", False ), @@ -377,7 +385,7 @@ def _resolve_preload_tag_attributes(self, src, url, chunk, manifest) -> dict[str attrs = { "rel": "modulepreload", "href": url, - "nonce": self._nonce or False, + "nonce": self.csp_nonce() or False, "crossorigin": self._resolve_script_tag_attributes(src, url, chunk, manifest).get("crossorigin", False), } @@ -397,7 +405,7 @@ def _make_script_tag_with_attributes(self, url: str, attributes: dict[str, Any]) { "type": "module", "src": url, - "nonce": self._nonce or False, + "nonce": self.csp_nonce() or False, **attributes, } ) @@ -408,7 +416,7 @@ def _make_stylesheet_tag_with_attributes(self, url: str, attributes: dict[str, A { "rel": "stylesheet", "href": url, - "nonce": self._nonce or False, + "nonce": self.csp_nonce() or False, **attributes, } ) diff --git a/fastapi_startkit/tests/inertia/test_inertia_context.py b/fastapi_startkit/tests/inertia/test_inertia_context.py index acbafa57..b0d35339 100644 --- a/fastapi_startkit/tests/inertia/test_inertia_context.py +++ b/fastapi_startkit/tests/inertia/test_inertia_context.py @@ -4,9 +4,12 @@ non-Inertia passthrough, InertiaResponse.__call__ without middleware. """ +import asyncio import unittest from unittest.mock import MagicMock +import httpx + from fastapi import FastAPI, Request from fastapi.testclient import TestClient @@ -83,3 +86,37 @@ def test_x_inertia_version_header_name_constant(self): def test_x_inertia_location_header_name_constant(self): assert Header.INERTIA_LOCATION == "X-Inertia-Location" + + +class TestPerRequestRootViewAndVersion(unittest.IsolatedAsyncioTestCase): + def setUp(self): + Inertia._instance = None + + def tearDown(self): + Inertia._instance = None + + async def test_concurrent_requests_do_not_share_root_view_or_version(self): + app = FastAPI() + app.add_middleware(InertiaMiddleware) + both_set = asyncio.Barrier(2) + + @app.get("/{name}") + async def page(name: str): + Inertia.set_root_view(f"{name}.html") + Inertia.version(f"v-{name}") + await both_set.wait() + return {"root_view": Inertia.instance().get_root_view(), "version": Inertia.get_version()} + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + first, second = await asyncio.gather(client.get("/first"), client.get("/second")) + + assert first.json() == {"root_view": "first.html", "version": "v-first"} + assert second.json() == {"root_view": "second.html", "version": "v-second"} + assert Inertia.instance().root_view == "index.html" + assert Inertia.instance().version is None + + def test_set_root_view_and_version_outside_request_configure_defaults(self): + Inertia.set_root_view("app.html") + Inertia.version("v1") + assert Inertia.instance().get_root_view() == "app.html" + assert Inertia.get_version() == "v1" diff --git a/fastapi_startkit/tests/storage/test_local_driver.py b/fastapi_startkit/tests/storage/test_local_driver.py index b472d0f7..84de7449 100644 --- a/fastapi_startkit/tests/storage/test_local_driver.py +++ b/fastapi_startkit/tests/storage/test_local_driver.py @@ -95,6 +95,30 @@ def test_copy_duplicates_file(self, driver, tmp_path): assert src.exists() assert (storage / "copy.txt").read_text() == "copy me" + def test_copy_resolves_relative_paths_against_root(self, driver, tmp_path): + driver.put("orig.txt", "copy me") + driver.copy("orig.txt", "nested/copy.txt") + assert (tmp_path / "storage" / "orig.txt").read_text() == "copy me" + assert (tmp_path / "storage" / "nested" / "copy.txt").read_text() == "copy me" + + def test_get_returns_text_for_utf8_files(self, driver): + driver.put("note.txt", "héllo") + assert driver.get("note.txt") == "héllo" + + def test_get_returns_bytes_for_binary_files(self, driver): + png = b"\x89PNG\r\n\x1a\n\x00\xff" + driver.put("logo.png", png) + assert driver.get("logo.png") == png + + def test_get_bytes_returns_raw_content(self, driver): + driver.put("note.txt", "hello") + driver.put("logo.png", b"\x89PNG\xff") + assert driver.get_bytes("note.txt") == b"hello" + assert driver.get_bytes("logo.png") == b"\x89PNG\xff" + + def test_get_bytes_returns_none_when_missing(self, driver): + assert driver.get_bytes("missing.bin") is None + def test_get_files_lists_files_in_directory(self, driver, tmp_path): storage = tmp_path / "storage" storage.mkdir(parents=True, exist_ok=True) diff --git a/fastapi_startkit/tests/storage/test_s3_driver.py b/fastapi_startkit/tests/storage/test_s3_driver.py index ca5a3285..88c15080 100644 --- a/fastapi_startkit/tests/storage/test_s3_driver.py +++ b/fastapi_startkit/tests/storage/test_s3_driver.py @@ -77,6 +77,29 @@ class FakeClientError(Exception): result = driver.get("missing.txt") assert result is None + def test_get_returns_bytes_for_binary_content(self, driver, mock_resource): + body = MagicMock() + body.read.return_value = b"\x89PNG\xff" + mock_resource.Bucket().Object().get.return_value = {"Body": body} + + assert driver.get("logo.png") == b"\x89PNG\xff" + + def test_get_bytes_returns_raw_content(self, driver, mock_resource): + body = MagicMock() + body.read.return_value = b"file content" + mock_resource.Bucket().Object().get.return_value = {"Body": body} + + assert driver.get_bytes("file.txt") == b"file content" + + def test_get_bytes_returns_none_on_client_error(self, driver, mock_resource): + class FakeClientError(Exception): + pass + + mock_resource.Bucket().Object().get.side_effect = FakeClientError("not found") + + with patch.object(driver, "missing_file_exceptions", return_value=(FakeClientError,)): + assert driver.get_bytes("missing.txt") is None + # --------------------------------------------------------------------------- # exists diff --git a/fastapi_startkit/tests/storage/test_storage.py b/fastapi_startkit/tests/storage/test_storage.py index 95329178..d226a288 100644 --- a/fastapi_startkit/tests/storage/test_storage.py +++ b/fastapi_startkit/tests/storage/test_storage.py @@ -109,3 +109,15 @@ def test_storage_fake(self): # Verify it uses a temporary directory self.assertIn("storage_fake_s3_", driver.options["root"]) + + def test_storage_copy_and_binary_get_through_facade(self): + with patch("fastapi_startkit.application.app") as mock_app_getter: + mock_app_getter.return_value = self.mock_app + self.mock_app.make.return_value = self.storage_manager + + with Storage.fake("local"): + Storage.put("logo.png", b"\x89PNG\xff") + Storage.copy("logo.png", "copies/logo.png") + + self.assertEqual(Storage.get("copies/logo.png"), b"\x89PNG\xff") + self.assertEqual(Storage.get_bytes("copies/logo.png"), b"\x89PNG\xff") diff --git a/fastapi_startkit/tests/vite/test_vite.py b/fastapi_startkit/tests/vite/test_vite.py index c57bbfb4..9e45f167 100644 --- a/fastapi_startkit/tests/vite/test_vite.py +++ b/fastapi_startkit/tests/vite/test_vite.py @@ -1,3 +1,4 @@ +import asyncio import hashlib import json import os @@ -236,6 +237,44 @@ def test_preload_resolver_returning_dict_merges_attributes(self, public_path): html = vite("resources/js/app.js") assert 'data-preload="1"' in html + def test_script_tag_attributes_accept_dict(self, public_path): + write_manifest(public_path) + vite = Vite(public_path).use_script_tag_attributes({"defer": True, "data-track": "reload"}) + html = vite("resources/js/app.js") + assert '