484 lines
19 KiB
Python
484 lines
19 KiB
Python
"""SVFSStaticFiles(FastAPI / Starlette 静态文件插件)的集成测试。
|
||||
|
|
|
|||
|
|
没有安装 fastapi / starlette / httpx 时整个模块会被跳过,
|
|||
|
|
这样"插件是可选依赖"的约束在测试层面也成立 —— 核心包不因为它的测试而需要装框架。
|
|||
|
|
|
|||
|
|
用真实 ASGI 栈(FastAPI + TestClient)跑请求,而不是直接调内部方法,
|
|||
|
|
这样 405 / 重定向 / 条件请求 / Range 这些行为都按 HTTP 语义被验证。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import hashlib
|
|||
|
|
import os
|
|||
|
|
import struct
|
|||
|
|
import unittest
|
|||
|
|
|
|||
|
|
import helpers
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
from fastapi import FastAPI
|
|||
|
|
from fastapi.testclient import TestClient
|
|||
|
|
|
|||
|
|
from sqlite_vfs.contrib.fastapi_static import SVFSStaticFiles
|
|||
|
|
|
|||
|
|
AVAILABLE = True
|
|||
|
|
IMPORT_ERROR = None
|
|||
|
|
except ImportError as exc: # pragma: no cover
|
|||
|
|
AVAILABLE = False
|
|||
|
|
IMPORT_ERROR = exc
|
|||
|
|
|
|||
|
|
|
|||
|
|
#: 资源包内容:覆盖 MIME 覆盖表、可压缩大文件、空文件、前端壳、嵌套目录
|
|||
|
|
ASSETS = {
|
|||
|
|
"index.html": b"<!doctype html><title>app</title><div id=app>shell",
|
|||
|
|
"app.js": b"console.log('hi');",
|
|||
|
|
"app.mjs": b"export default 1;",
|
|||
|
|
"style.css": b"body{margin:0}",
|
|||
|
|
"font.woff2": b"\x77\x4f\x46\x32\x00\x01",
|
|||
|
|
"data.json": b'{"ok":true}',
|
|||
|
|
"empty.txt": b"",
|
|||
|
|
"big.txt": b"0123456789abcdef" * 2000, # 32KB,会被哈夫曼压缩
|
|||
|
|
"sub/page.html": b"<p>sub page</p>",
|
|||
|
|
"sub/index.html": b"<p>sub index</p>",
|
|||
|
|
"assets/logo.png": b"\x89PNG\r\n\x1a\n",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@unittest.skipUnless(AVAILABLE, f"需要 fastapi/starlette/httpx: {IMPORT_ERROR}")
|
|||
|
|
class NeedsFastAPI(helpers.VFSTestCase):
|
|||
|
|
"""缺少框架时整类跳过(子类通过属性继承同样被跳过)。"""
|
|||
|
|
|
|||
|
|
|
|||
|
|
class PluginTestCase(NeedsFastAPI):
|
|||
|
|
"""构造一个真实的 .svfs 资源包,并把它挂在 FastAPI 上当静态目录。"""
|
|||
|
|
|
|||
|
|
compress = True
|
|||
|
|
algorithm = None # None = 用默认算法(zlib)
|
|||
|
|
spa = True
|
|||
|
|
html = True
|
|||
|
|
|
|||
|
|
def setUp(self):
|
|||
|
|
super().setUp()
|
|||
|
|
self.src = helpers.make_tree(self.path("dist"), ASSETS)
|
|||
|
|
self.db = helpers.pack(
|
|||
|
|
self.src,
|
|||
|
|
self.path("static.svfs"),
|
|||
|
|
compress=self.compress,
|
|||
|
|
name="assets",
|
|||
|
|
algorithm=self.algorithm,
|
|||
|
|
)
|
|||
|
|
self.md5_before = helpers.md5_of(self.db)
|
|||
|
|
self.statics = SVFSStaticFiles(self.db, html=self.html, spa=self.spa)
|
|||
|
|
self.addCleanup(self.statics.close)
|
|||
|
|
|
|||
|
|
app = FastAPI()
|
|||
|
|
app.mount("/", self.statics, name="static")
|
|||
|
|
self.client = TestClient(app, follow_redirects=False)
|
|||
|
|
|
|||
|
|
def get(self, path, **kwargs):
|
|||
|
|
return self.client.get(path, headers=kwargs.pop("headers", None), **kwargs)
|
|||
|
|
|
|||
|
|
def assert_unmodified(self):
|
|||
|
|
"""所有请求都必须是只读的,资源包不能被改动。"""
|
|||
|
|
self.assertEqual(self.md5_before, helpers.md5_of(self.db))
|
|||
|
|
|
|||
|
|
|
|||
|
|
class BasicServingTest(PluginTestCase):
|
|||
|
|
def test_serves_top_level_file(self):
|
|||
|
|
response = self.get("/app.js")
|
|||
|
|
self.assertEqual(200, response.status_code)
|
|||
|
|
self.assertEqual(ASSETS["app.js"], response.content)
|
|||
|
|
self.assertEqual(b"console.log('hi');", response.content)
|
|||
|
|
|
|||
|
|
def test_serves_nested_file(self):
|
|||
|
|
response = self.get("/sub/page.html")
|
|||
|
|
self.assertEqual(200, response.status_code)
|
|||
|
|
self.assertEqual(ASSETS["sub/page.html"], response.content)
|
|||
|
|
|
|||
|
|
def test_serves_empty_file(self):
|
|||
|
|
response = self.get("/empty.txt")
|
|||
|
|
self.assertEqual(200, response.status_code)
|
|||
|
|
self.assertEqual(b"", response.content)
|
|||
|
|
self.assertEqual("0", response.headers["content-length"])
|
|||
|
|
|
|||
|
|
def test_serves_compressed_file_correctly(self):
|
|||
|
|
"""大文件在库里是哈夫曼压缩存储的,取出来必须还原成原始字节。"""
|
|||
|
|
response = self.get("/big.txt")
|
|||
|
|
self.assertEqual(200, response.status_code)
|
|||
|
|
self.assertEqual(ASSETS["big.txt"], response.content)
|
|||
|
|
|
|||
|
|
def test_content_length_matches_body(self):
|
|||
|
|
response = self.get("/big.txt")
|
|||
|
|
self.assertEqual(str(len(ASSETS["big.txt"])), response.headers["content-length"])
|
|||
|
|
|
|||
|
|
|
|||
|
|
class ContentTypeTest(PluginTestCase):
|
|||
|
|
def test_web_types_are_not_platform_dependent(self):
|
|||
|
|
"""Windows 注册表会把 .mjs 判成 text/plain(浏览器会拒绝执行模块),
|
|||
|
|
这里断言插件用的是显式覆盖表。"""
|
|||
|
|
expected = {
|
|||
|
|
"/app.js": "text/javascript",
|
|||
|
|
"/app.mjs": "text/javascript",
|
|||
|
|
"/style.css": "text/css",
|
|||
|
|
"/index.html": "text/html",
|
|||
|
|
"/data.json": "application/json",
|
|||
|
|
"/font.woff2": "font/woff2",
|
|||
|
|
"/assets/logo.png": "image/png",
|
|||
|
|
}
|
|||
|
|
for path, content_type in expected.items():
|
|||
|
|
with self.subTest(path=path):
|
|||
|
|
response = self.get(path)
|
|||
|
|
self.assertEqual(200, response.status_code)
|
|||
|
|
self.assertEqual(
|
|||
|
|
content_type, response.headers["content-type"].split(";")[0]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_unknown_extension_falls_back_to_octet_stream(self):
|
|||
|
|
helpers.write_file(os.path.join(self.src, "blob.bin"), b"\x00\x01")
|
|||
|
|
helpers.pack(self.src, self.db, compress=self.compress)
|
|||
|
|
self.assertEqual(
|
|||
|
|
"application/octet-stream",
|
|||
|
|
self.get("/blob.bin").headers["content-type"].split(";")[0],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class IndexAndSpaTest(PluginTestCase):
|
|||
|
|
def test_root_serves_index_html(self):
|
|||
|
|
response = self.get("/")
|
|||
|
|
self.assertEqual(200, response.status_code)
|
|||
|
|
self.assertEqual(ASSETS["index.html"], response.content)
|
|||
|
|
self.assertEqual("text/html", response.headers["content-type"].split(";")[0])
|
|||
|
|
|
|||
|
|
def test_directory_redirects_to_trailing_slash(self):
|
|||
|
|
response = self.get("/sub")
|
|||
|
|
self.assertEqual(307, response.status_code)
|
|||
|
|
# Starlette 生成的是绝对 URL
|
|||
|
|
self.assertTrue(
|
|||
|
|
response.headers["location"].endswith("/sub/"),
|
|||
|
|
response.headers["location"],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_directory_with_slash_serves_its_index(self):
|
|||
|
|
response = self.get("/sub/")
|
|||
|
|
self.assertEqual(200, response.status_code)
|
|||
|
|
self.assertEqual(ASSETS["sub/index.html"], response.content)
|
|||
|
|
|
|||
|
|
def test_spa_fallback_for_frontend_route(self):
|
|||
|
|
"""浏览器导航到前端路由 /settings 时应当拿到 index.html。"""
|
|||
|
|
response = self.get("/settings", headers={"accept": "text/html"})
|
|||
|
|
self.assertEqual(200, response.status_code)
|
|||
|
|
self.assertEqual(ASSETS["index.html"], response.content)
|
|||
|
|
|
|||
|
|
def test_spa_fallback_for_deep_route(self):
|
|||
|
|
response = self.get("/user/42/detail", headers={"accept": "text/html"})
|
|||
|
|
self.assertEqual(200, response.status_code)
|
|||
|
|
self.assertEqual(ASSETS["index.html"], response.content)
|
|||
|
|
|
|||
|
|
def test_no_fallback_for_missing_asset(self):
|
|||
|
|
"""缺静态资源必须是 404,否则前端拿到一份 HTML 会报诡异的解析错误。"""
|
|||
|
|
self.assertEqual(404, self.get("/missing.js").status_code)
|
|||
|
|
self.assertEqual(404, self.get("/missing.css").status_code)
|
|||
|
|
|
|||
|
|
def test_no_fallback_for_json_request(self):
|
|||
|
|
"""XHR/接口请求(Accept 不含 text/html)不该被回退成 index.html。"""
|
|||
|
|
self.assertEqual(
|
|||
|
|
404, self.get("/api/data", headers={"accept": "application/json"}).status_code
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_missing_file_returns_404(self):
|
|||
|
|
self.assertEqual(404, self.get("/nope.txt").status_code)
|
|||
|
|
|
|||
|
|
def test_post_is_rejected(self):
|
|||
|
|
self.assertEqual(405, self.client.post("/app.js").status_code)
|
|||
|
|
|
|||
|
|
def test_path_traversal_cannot_escape_the_pack(self):
|
|||
|
|
"""资源包不是磁盘目录,.. 之类无法越出数据库。"""
|
|||
|
|
for path in ("/../../etc/passwd", "/..%2f..%2fsecret.txt", "/sub/../../app.js"):
|
|||
|
|
with self.subTest(path=path):
|
|||
|
|
self.assertIn(self.get(path).status_code, (200, 404, 307))
|
|||
|
|
self.assert_unmodified()
|
|||
|
|
|
|||
|
|
def test_repeated_requests_do_not_modify_pack(self):
|
|||
|
|
for path in ("/", "/app.js", "/big.txt", "/sub/", "/settings"):
|
|||
|
|
self.get(path)
|
|||
|
|
self.assert_unmodified()
|
|||
|
|
|
|||
|
|
|
|||
|
|
class StrictModeTest(PluginTestCase):
|
|||
|
|
"""html=False, spa=False 时行为与 StaticFiles 严格一致。"""
|
|||
|
|
|
|||
|
|
html = False
|
|||
|
|
spa = False
|
|||
|
|
|
|||
|
|
def test_root_is_404(self):
|
|||
|
|
self.assertEqual(404, self.get("/").status_code)
|
|||
|
|
|
|||
|
|
def test_route_is_404(self):
|
|||
|
|
self.assertEqual(404, self.get("/settings", headers={"accept": "text/html"}).status_code)
|
|||
|
|
|
|||
|
|
def test_plain_file_still_served(self):
|
|||
|
|
self.assertEqual(200, self.get("/app.js").status_code)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class ConditionalRequestTest(PluginTestCase):
|
|||
|
|
def test_etag_and_last_modified_present(self):
|
|||
|
|
response = self.get("/app.js")
|
|||
|
|
self.assertIn("etag", response.headers)
|
|||
|
|
self.assertIn("last-modified", response.headers)
|
|||
|
|
self.assertEqual("bytes", response.headers["accept-ranges"])
|
|||
|
|
|
|||
|
|
def test_if_none_match_returns_304(self):
|
|||
|
|
etag = self.get("/app.js").headers["etag"]
|
|||
|
|
response = self.client.get("/app.js", headers={"if-none-match": etag})
|
|||
|
|
self.assertEqual(304, response.status_code)
|
|||
|
|
self.assertEqual(b"", response.content)
|
|||
|
|
|
|||
|
|
def test_if_none_match_with_weak_prefix(self):
|
|||
|
|
etag = self.get("/app.js").headers["etag"]
|
|||
|
|
response = self.client.get("/app.js", headers={"if-none-match": f"W/{etag}"})
|
|||
|
|
self.assertEqual(304, response.status_code)
|
|||
|
|
|
|||
|
|
def test_if_modified_since_returns_304(self):
|
|||
|
|
last_modified = self.get("/app.js").headers["last-modified"]
|
|||
|
|
response = self.client.get(
|
|||
|
|
"/app.js", headers={"if-modified-since": last_modified}
|
|||
|
|
)
|
|||
|
|
self.assertEqual(304, response.status_code)
|
|||
|
|
|
|||
|
|
def test_stale_if_none_match_returns_full_body(self):
|
|||
|
|
response = self.client.get("/app.js", headers={"if-none-match": '"stale"'})
|
|||
|
|
self.assertEqual(200, response.status_code)
|
|||
|
|
self.assertEqual(ASSETS["app.js"], response.content)
|
|||
|
|
|
|||
|
|
def test_etag_is_stable_across_instances(self):
|
|||
|
|
"""同一个资源包重新打开,ETag 不能变,否则缓存永远失效。"""
|
|||
|
|
other = SVFSStaticFiles(self.db)
|
|||
|
|
self.addCleanup(other.close)
|
|||
|
|
app = FastAPI()
|
|||
|
|
app.mount("/", other, name="static")
|
|||
|
|
client = TestClient(app)
|
|||
|
|
self.assertEqual(
|
|||
|
|
self.get("/app.js").headers["etag"], client.get("/app.js").headers["etag"]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_max_age_sets_cache_control(self):
|
|||
|
|
statics = SVFSStaticFiles(self.db, max_age=3600)
|
|||
|
|
self.addCleanup(statics.close)
|
|||
|
|
app = FastAPI()
|
|||
|
|
app.mount("/", statics, name="static")
|
|||
|
|
response = TestClient(app).get("/app.js")
|
|||
|
|
self.assertEqual("public, max-age=3600", response.headers["cache-control"])
|
|||
|
|
|
|||
|
|
|
|||
|
|
class HeadAndRangeTest(PluginTestCase):
|
|||
|
|
def test_head_has_no_body_but_real_length(self):
|
|||
|
|
response = self.client.head("/big.txt")
|
|||
|
|
self.assertEqual(200, response.status_code)
|
|||
|
|
self.assertEqual(b"", response.content)
|
|||
|
|
self.assertEqual(str(len(ASSETS["big.txt"])), response.headers["content-length"])
|
|||
|
|
|
|||
|
|
def test_range_returns_206_with_slice(self):
|
|||
|
|
response = self.client.get("/big.txt", headers={"range": "bytes=0-9"})
|
|||
|
|
self.assertEqual(206, response.status_code)
|
|||
|
|
self.assertEqual(ASSETS["big.txt"][:10], response.content)
|
|||
|
|
self.assertEqual(
|
|||
|
|
f"bytes 0-9/{len(ASSETS['big.txt'])}", response.headers["content-range"]
|
|||
|
|
)
|
|||
|
|
self.assertEqual("10", response.headers["content-length"])
|
|||
|
|
|
|||
|
|
def test_range_with_open_end(self):
|
|||
|
|
response = self.client.get("/big.txt", headers={"range": "bytes=100-"})
|
|||
|
|
self.assertEqual(206, response.status_code)
|
|||
|
|
self.assertEqual(ASSETS["big.txt"][100:], response.content)
|
|||
|
|
|
|||
|
|
def test_suffix_range(self):
|
|||
|
|
response = self.client.get("/big.txt", headers={"range": "bytes=-16"})
|
|||
|
|
self.assertEqual(206, response.status_code)
|
|||
|
|
self.assertEqual(ASSETS["big.txt"][-16:], response.content)
|
|||
|
|
|
|||
|
|
def test_range_on_compressed_content_is_correct(self):
|
|||
|
|
"""库里存的是压缩数据,Range 必须作用在解压后的内容上。"""
|
|||
|
|
for start, end in ((0, 0), (15, 31), (len(ASSETS["big.txt"]) - 5, 999999)):
|
|||
|
|
with self.subTest(start=start, end=end):
|
|||
|
|
response = self.client.get(
|
|||
|
|
"/big.txt", headers={"range": f"bytes={start}-{end}"}
|
|||
|
|
)
|
|||
|
|
self.assertEqual(206, response.status_code)
|
|||
|
|
self.assertEqual(ASSETS["big.txt"][start : end + 1], response.content)
|
|||
|
|
|
|||
|
|
def test_unsatisfiable_range_returns_416(self):
|
|||
|
|
response = self.client.get(
|
|||
|
|
"/big.txt", headers={"range": f"bytes={len(ASSETS['big.txt'])}-"}
|
|||
|
|
)
|
|||
|
|
self.assertEqual(416, response.status_code)
|
|||
|
|
self.assertEqual(
|
|||
|
|
f"bytes */{len(ASSETS['big.txt'])}", response.headers["content-range"]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_malformed_range_is_ignored(self):
|
|||
|
|
"""语法错误的 Range 按 RFC 应当被忽略,返回整份内容。"""
|
|||
|
|
for header in ("bytes=abc-def", "items=0-10", "", "bytes="):
|
|||
|
|
with self.subTest(header=header):
|
|||
|
|
response = self.client.get("/big.txt", headers={"range": header})
|
|||
|
|
self.assertEqual(200, response.status_code)
|
|||
|
|
self.assertEqual(ASSETS["big.txt"], response.content)
|
|||
|
|
|
|||
|
|
def test_multi_range_falls_back_to_full_body(self):
|
|||
|
|
response = self.client.get("/big.txt", headers={"range": "bytes=0-1,4-5"})
|
|||
|
|
self.assertEqual(200, response.status_code)
|
|||
|
|
self.assertEqual(ASSETS["big.txt"], response.content)
|
|||
|
|
|
|||
|
|
def test_if_range_mismatch_ignores_range(self):
|
|||
|
|
response = self.client.get(
|
|||
|
|
"/big.txt", headers={"range": "bytes=0-9", "if-range": '"stale"'}
|
|||
|
|
)
|
|||
|
|
self.assertEqual(200, response.status_code)
|
|||
|
|
self.assertEqual(ASSETS["big.txt"], response.content)
|
|||
|
|
|
|||
|
|
def test_if_range_match_uses_range(self):
|
|||
|
|
etag = self.get("/big.txt").headers["etag"]
|
|||
|
|
response = self.client.get(
|
|||
|
|
"/big.txt", headers={"range": "bytes=0-9", "if-range": etag}
|
|||
|
|
)
|
|||
|
|
self.assertEqual(206, response.status_code)
|
|||
|
|
|
|||
|
|
def test_head_with_range(self):
|
|||
|
|
response = self.client.head("/big.txt", headers={"range": "bytes=0-9"})
|
|||
|
|
self.assertEqual(206, response.status_code)
|
|||
|
|
self.assertEqual(b"", response.content)
|
|||
|
|
self.assertEqual("10", response.headers["content-length"])
|
|||
|
|
|
|||
|
|
|
|||
|
|
class CompressionAlgorithmServingTest(PluginTestCase):
|
|||
|
|
"""资源包用哪种算法压缩,对使用方必须完全透明。"""
|
|||
|
|
|
|||
|
|
def test_pack_uses_zlib_by_default(self):
|
|||
|
|
import sqlite3
|
|||
|
|
|
|||
|
|
conn = sqlite3.connect(f"file:{self.db}?mode=ro", uri=True)
|
|||
|
|
try:
|
|||
|
|
algorithms = {
|
|||
|
|
row[0]
|
|||
|
|
for row in conn.execute(
|
|||
|
|
"""
|
|||
|
|
SELECT b.compress_algo
|
|||
|
|
FROM files f JOIN blobs b ON b.hash = f.content_hash
|
|||
|
|
WHERE f.is_directory = 0
|
|||
|
|
"""
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
finally:
|
|||
|
|
conn.close()
|
|||
|
|
self.assertIn("zlib", algorithms)
|
|||
|
|
self.assertNotIn("huffman", algorithms)
|
|||
|
|
|
|||
|
|
def test_large_asset_survives_compressed_roundtrip(self):
|
|||
|
|
response = self.get("/big.txt")
|
|||
|
|
self.assertEqual(200, response.status_code)
|
|||
|
|
self.assertEqual(ASSETS["big.txt"], response.content)
|
|||
|
|
self.assertEqual(str(len(ASSETS["big.txt"])), response.headers["content-length"])
|
|||
|
|
|
|||
|
|
|
|||
|
|
class HuffmanPackServingTest(PluginTestCase):
|
|||
|
|
"""早期版本用 huffman 打的资源包也必须能正常服务(向后兼容)。"""
|
|||
|
|
|
|||
|
|
algorithm = "huffman"
|
|||
|
|
|
|||
|
|
def test_serves_huffman_compressed_asset(self):
|
|||
|
|
response = self.get("/big.txt")
|
|||
|
|
self.assertEqual(200, response.status_code)
|
|||
|
|
self.assertEqual(ASSETS["big.txt"], response.content)
|
|||
|
|
|
|||
|
|
def test_range_works_on_huffman_pack(self):
|
|||
|
|
response = self.client.get("/big.txt", headers={"range": "bytes=0-9"})
|
|||
|
|
self.assertEqual(206, response.status_code)
|
|||
|
|
self.assertEqual(ASSETS["big.txt"][:10], response.content)
|
|||
|
|
|
|||
|
|
def test_conditional_request_works_on_huffman_pack(self):
|
|||
|
|
etag = self.get("/big.txt").headers["etag"]
|
|||
|
|
response = self.client.get("/big.txt", headers={"if-none-match": etag})
|
|||
|
|
self.assertEqual(304, response.status_code)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class MountPrefixTest(PluginTestCase):
|
|||
|
|
"""挂在子路径下(app.mount("/static", ...))也要正常工作。"""
|
|||
|
|
|
|||
|
|
def setUp(self):
|
|||
|
|
super().setUp()
|
|||
|
|
app = FastAPI()
|
|||
|
|
app.mount("/static", self.statics, name="static")
|
|||
|
|
self.client = TestClient(app, follow_redirects=False)
|
|||
|
|
|
|||
|
|
def test_serves_under_prefix(self):
|
|||
|
|
response = self.get("/static/app.js")
|
|||
|
|
self.assertEqual(200, response.status_code)
|
|||
|
|
self.assertEqual(ASSETS["app.js"], response.content)
|
|||
|
|
|
|||
|
|
def test_outside_prefix_is_404(self):
|
|||
|
|
self.assertEqual(404, self.get("/app.js").status_code)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class ConstructionTest(NeedsFastAPI):
|
|||
|
|
def test_missing_pack_raises_runtime_error_with_hint(self):
|
|||
|
|
missing = self.path("nope.svfs")
|
|||
|
|
with self.assertRaises(RuntimeError) as ctx:
|
|||
|
|
SVFSStaticFiles(missing)
|
|||
|
|
self.assertIn("vfspack", str(ctx.exception))
|
|||
|
|
|
|||
|
|
def test_old_format_pack_is_rejected_clearly(self):
|
|||
|
|
from sqlite_vfs.core import VFSFormatError
|
|||
|
|
|
|||
|
|
db = helpers.make_old_schema_db(self.path("legacy.svfs"))
|
|||
|
|
with self.assertRaises(VFSFormatError):
|
|||
|
|
SVFSStaticFiles(db)
|
|||
|
|
|
|||
|
|
def test_non_database_file_is_rejected(self):
|
|||
|
|
junk = helpers.write_file(self.path("junk.svfs"), b"not a database")
|
|||
|
|
with self.assertRaises(RuntimeError):
|
|||
|
|
SVFSStaticFiles(junk)
|
|||
|
|
|
|||
|
|
def test_repr_and_close(self):
|
|||
|
|
src = helpers.make_tree(self.path("d"), {"a.txt": b"a"})
|
|||
|
|
db = helpers.pack(src, self.path("s.svfs"))
|
|||
|
|
statics = SVFSStaticFiles(db)
|
|||
|
|
self.assertIn("SVFSStaticFiles", repr(statics))
|
|||
|
|
statics.close()
|
|||
|
|
# 关闭后再访问会报错,而不是静默返回空内容
|
|||
|
|
with self.assertRaises(Exception):
|
|||
|
|
statics.lookup_path("/a.txt")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class InterruptedReadTest(NeedsFastAPI):
|
|||
|
|
"""返回 404/304 时不能读取 BLOB(这也是条件请求省下来的开销)。"""
|
|||
|
|
|
|||
|
|
def test_304_does_not_read_content(self):
|
|||
|
|
src = helpers.make_tree(self.path("d"), {"a.txt": b"aaa"})
|
|||
|
|
db = helpers.pack(src, self.path("s.svfs"))
|
|||
|
|
statics = SVFSStaticFiles(db)
|
|||
|
|
self.addCleanup(statics.close)
|
|||
|
|
|
|||
|
|
calls = []
|
|||
|
|
original = statics._read
|
|||
|
|
|
|||
|
|
def counting_read(*args, **kwargs):
|
|||
|
|
calls.append(args)
|
|||
|
|
return original(*args, **kwargs)
|
|||
|
|
|
|||
|
|
statics._read = counting_read
|
|||
|
|
|
|||
|
|
app = FastAPI()
|
|||
|
|
app.mount("/", statics, name="static")
|
|||
|
|
client = TestClient(app)
|
|||
|
|
|
|||
|
|
first = client.get("/a.txt")
|
|||
|
|
self.assertEqual(200, first.status_code)
|
|||
|
|
self.assertEqual(1, len(calls))
|
|||
|
|
|
|||
|
|
etag = first.headers["etag"]
|
|||
|
|
not_modified = client.get("/a.txt", headers={"if-none-match": etag})
|
|||
|
|
self.assertEqual(304, not_modified.status_code)
|
|||
|
|
self.assertEqual(1, len(calls), "304 响应不应该再去读 BLOB")
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
unittest.main()
|