Files
svfs/tests/test_fastapi_static.py
chakcy ecdfacc620 fix+feat: 修复打包/解包主链路,并把存储层重建为内容寻址 + 快照
起始状态:主链路 100% 不可用,而且失败被静默吞掉——打包后库里 0 个文件、
界面仍打印"文件数量: N";解包导出 0 个文件并在磁盘上生成几百层 sub/sub/sub 垃圾目录。

缺陷修复(4 个 P0 + 12 个 P1,逐条见 BUG_REPORT.md):
- 建表列名 compresse_algo 与 INSERT/read_file 用的 compress_algo 不一致
- list_directory 按 path 而非 parent_path 过滤,导致导出无限自递归
- CLI --ignore-timestamps 把 export_filesystem 赋成引用自身的 lambda
- add_file 缺少 commit(直接调用 API 时静默丢数据)
- LIKE 前缀误伤兄弟目录(删 doc 连带删 docs);mv_dir 二次改名
- 失败路径一律返回 exit code 0;--exclude 是子串匹配而非 glob
- FolderPacker.add_file 反向调用 export_file_safe,会用库内内容覆盖源文件

存储层(schema v3,格式变更,提供 vfsmigrate 迁移):
- 内容移入 blobs(hash PRIMARY KEY):相同内容只存一份,跨路径/跨快照共享
- files 的唯一约束改为 UNIQUE(snapshot_id, path):一个库存多个版本
- 完整性:每文件 sha256 + 快照级摘要 + HMAC-SHA256 签名,只读连接即可验证
- 增量打包:按 mtime+size 或 sha256 只写变化项,未变文件不重写不重压
- get_file_info 不再返回 content(元数据查询不该搬 BLOB)

新增能力:
- 可选 FastAPI 静态文件插件(extras,核心仍零第三方依赖)
- 内容寻址发布到对象存储 + 逐对象校验的取回客户端
- 压缩默认改回 zlib:实测比自研哈夫曼体积小 1/3~1/170、快数百倍

其它:
- P2-4:时间戳显式转字符串,清掉 1240 条 DeprecationWarning(盘上格式不变)
- read_file 负数 offset/size 改为报错(原先会走切片语义从末尾取)
- requires-python 从 >=3.7 改为按实际 API 推导的 >=3.9

测试:0 → 343 个用例,纯标准库 unittest,有/无 FastAPI 两种环境都跑通。
证据见 docs/evidence-*.txt。
2026-09-14 00:11:42 +08:00

484 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()