store-api-py 0.2.0__tar.gz → 0.4.0__tar.gz

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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: store-api-py
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: FastAPI adapter that auto-generates RESTful APIs for py-store schemas (schema-driven CRUD over GQL)
5
5
  License: MIT
6
6
  Keywords: py-store,storepy,fastapi,rest,crud,gql
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "store-api-py"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "FastAPI adapter that auto-generates RESTful APIs for py-store schemas (schema-driven CRUD over GQL)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -16,10 +16,14 @@ from fastapi.responses import JSONResponse, Response
16
16
  from .errors import (
17
17
  StoreApiError,
18
18
  context_error,
19
+ empty_body,
19
20
  error_payload,
21
+ file_not_configured,
20
22
  invalid_body,
21
23
  map_error,
22
24
  not_found,
25
+ too_large,
26
+ upload_not_configured,
23
27
  )
24
28
  from .params import parse_query_params
25
29
 
@@ -51,6 +55,8 @@ def create_app(
51
55
  permission_error: type[BaseException] | None = None,
52
56
  file_field: str = "file",
53
57
  file_resolver: Callable[[Request, dict, str], Any] | None = None,
58
+ upload_resolver: Callable[[Request, dict, str], Any] | None = None,
59
+ upload_limit: int = 32 * 1024 * 1024,
54
60
  ) -> FastAPI:
55
61
  """为 store(py-store 的 store 实例,需已 init + register)生成 RESTful FastAPI 应用。
56
62
 
@@ -59,11 +65,25 @@ def create_app(
59
65
  - context_provider: 每请求上下文钩子(spec/04-context.md);非权限类抛错 ⇒ 401 CONTEXT_ERROR
60
66
  - resources: 显式资源名;缺省取 store.list() 并过滤归档表
61
67
  - permission_error: store 权限错误类;缺省取 store.PermissionError
68
+ - file_resolver: 下载字节接缝 (request, rec, rid) => {body, contentType, fileName};缺省时下载 501
69
+ - upload_resolver: 上传字节接缝 (request, rec, rid) => {ref};缺省时上传 501
70
+ - upload_limit: 上传字节体上限(超限 413),默认 32MB
62
71
  """
63
72
  app = FastAPI(title="store-api")
64
73
  if permission_error is None:
65
74
  permission_error = getattr(store, "PermissionError", None)
66
75
 
76
+ # fail-secure 装配守卫:宿主已开启上下文强制却未配 context_provider 时,装配期即
77
+ # 拒绝(fail-fast)。否则服务能启动、每个请求却在运行期以 ERR_NO_CONTEXT 失败——
78
+ # 配置错误被推迟成运行时事故(no-error-masking:不允许静默带错运行)。
79
+ require_context = getattr(store, "require_context", None)
80
+ if callable(require_context) and require_context() and context_provider is None:
81
+ raise RuntimeError(
82
+ "ERR_SECURE_CONFIG: 宿主已开启上下文强制(fail-secure)但未配置 context_provider;"
83
+ "请注入从请求解析身份的 context_provider(spec/04-context.md),"
84
+ "无需鉴权的内部服务请显式 store.set_require_context(False) 后再装配"
85
+ )
86
+
67
87
  def _respond(mapped: StoreApiError) -> JSONResponse:
68
88
  return JSONResponse(status_code=mapped.status_code, content=error_payload(mapped.code, mapped.message))
69
89
 
@@ -99,7 +119,10 @@ def create_app(
99
119
  # 显式投影(spec/01+02):GQL 省略投影段 = 只返回 _id(三端 core 契约)。
100
120
  # 列表路由仅在 q 缺失时用它;q 存在时投影完全由 q 决定,适配层不追加。
101
121
  proj = _schema_projection(store, name)
102
- _register_resource(app, store, name, prefix, id_field, permission_error, proj, file_field, file_resolver)
122
+ _register_resource(
123
+ app, store, name, prefix, id_field, permission_error, proj,
124
+ file_field, file_resolver, upload_resolver, upload_limit,
125
+ )
103
126
 
104
127
  return app
105
128
 
@@ -147,6 +170,42 @@ def _guard(permission_error: type[BaseException] | None) -> Callable:
147
170
  return deco
148
171
 
149
172
 
173
+ def _pick_field(rec: dict | None, field_path: str) -> Any:
174
+ """点路径取字段(决策 C):'images.full' → rec['images']['full'];任一段缺失 → None。"""
175
+ if rec is None:
176
+ return None
177
+ cur: Any = rec
178
+ for seg in field_path.split("."):
179
+ if not isinstance(cur, dict):
180
+ return None
181
+ cur = cur.get(seg)
182
+ if cur is None:
183
+ return None
184
+ return cur
185
+
186
+
187
+ async def _read_upload_body(request: Request, upload_limit: int) -> bytes:
188
+ """上传字节体(决策 B'):空体 → 400 EMPTY_BODY;超限 → 413 UPLOAD_TOO_LARGE。
189
+
190
+ FastAPI/starlette 无 Fastify 式 parser/bodyLimit,故 upload_limit 是 py 端唯一防线。
191
+ """
192
+ body = await request.body()
193
+ if not body:
194
+ raise empty_body(f"上传请求体为空: {request.method} {request.url.path}")
195
+ if len(body) > upload_limit:
196
+ raise too_large(f"上传字节体 {len(body)} 字节超过上限 {upload_limit}")
197
+ return body
198
+
199
+
200
+ def _set_by_path(field_path: str, value: Any) -> dict:
201
+ """上传回写的 set 体:字面键,交由 store 寻址。
202
+
203
+ store 对非 `$` 前缀键自动包 `$set`(py-store/src/py_store/crud/write.py),
204
+ 点路径键 `$set {'a.b': v}` 即嵌套写入 —— 与 node 端同策略(parity)。
205
+ """
206
+ return {field_path: value}
207
+
208
+
150
209
  def _register_resource(
151
210
  app: FastAPI,
152
211
  store: Any,
@@ -157,11 +216,18 @@ def _register_resource(
157
216
  proj: str,
158
217
  file_field: str,
159
218
  file_resolver: Callable[[Request, dict, str], Any] | None,
219
+ upload_resolver: Callable[[Request, dict, str], Any] | None,
220
+ upload_limit: int,
160
221
  ) -> None:
161
222
  # 工厂函数隔离闭包:handler 签名只含 Request 与路径参数,
162
223
  # 否则 FastAPI 会把捕获变量解析成查询参数(缺参即 422)
163
224
  base = f"{prefix}/{name}"
164
225
 
226
+ def _field_path(request: Request) -> str:
227
+ # 请求级字段路径:?field= 优先,缺省回落插件级 file_field(spec/02-params.md)
228
+ f = request.query_params.get("field")
229
+ return f if f else file_field
230
+
165
231
  @app.get(base)
166
232
  @_guard(permission_error)
167
233
  async def list_resource(request: Request):
@@ -181,24 +247,49 @@ def _register_resource(
181
247
  @app.get(base + "/{rid}/file")
182
248
  @_guard(permission_error)
183
249
  async def get_file(rid: str, request: Request):
250
+ # spec/03 判定顺序第 1 层:未注入 file_resolver → 501(先于 query_one 判定)
251
+ if file_resolver is None:
252
+ raise file_not_configured(f"GET {base}/{{rid}}/file 未注入 file_resolver")
253
+ fp = _field_path(request)
184
254
  rec = await store.query_one(f"{name}($condition: @c0){proj}", {"c0": {id_field: rid}})
185
255
  if rec is None:
186
256
  raise not_found(f"记录不存在: {id_field}={rid}")
187
- raw = rec.get(file_field)
257
+ raw = _pick_field(rec, fp)
188
258
  if raw is None:
189
- raise not_found(f"文件不存在: {file_field}={rid}")
190
- if file_resolver is not None:
191
- out = file_resolver(request, rec, rid)
192
- if inspect.isawaitable(out):
193
- out = await out
194
- else:
195
- out = {"body": str(raw), "contentType": "text/plain; charset=utf-8", "fileName": f"{name}-{rid}"}
259
+ raise not_found(f"文件不存在: {fp}={rid}")
260
+ out = file_resolver(request, rec, rid)
261
+ if inspect.isawaitable(out):
262
+ out = await out
196
263
  return Response(
197
264
  content=out["body"],
198
265
  media_type=out.get("contentType", "application/octet-stream"),
199
266
  headers={"content-disposition": f'attachment; filename="{out.get("fileName", "file")}"'},
200
267
  )
201
268
 
269
+ @app.post(base + "/{rid}/file")
270
+ @_guard(permission_error)
271
+ async def upload_file(rid: str, request: Request):
272
+ # spec/03 判定顺序第 1 层:未注入 upload_resolver → 501
273
+ if upload_resolver is None:
274
+ raise upload_not_configured(f"POST {base}/{{rid}}/file 未注入 upload_resolver")
275
+ await _read_upload_body(request, upload_limit) # 空体 400 / 超限 413
276
+ fp = _field_path(request)
277
+ rec = await store.query_one(f"{name}($condition: @c0){proj}", {"c0": {id_field: rid}})
278
+ if rec is None:
279
+ raise not_found(f"记录不存在: {id_field}={rid}")
280
+ # 接缝:resolver 负责字节落库并返回引用(决策 B:与 file_resolver 对称)
281
+ out = upload_resolver(request, rec, rid)
282
+ if inspect.isawaitable(out):
283
+ out = await out
284
+ ref = out.get("ref") if isinstance(out, dict) else None
285
+ if ref is None:
286
+ raise RuntimeError( # → map_error 末层 500 原样透传(禁静默)
287
+ f"upload_resolver 未返回 ref: {name}({id_field}={rid})"
288
+ )
289
+ # 回写:走 update 语义 → RBAC 写权限自动生效(决策 A)
290
+ data = await store.update(name, {id_field: rid}, _set_by_path(fp, ref))
291
+ return {"data": data}
292
+
202
293
  @app.post(base, status_code=201)
203
294
  @_guard(permission_error)
204
295
  async def create_resource(request: Request):
@@ -38,13 +38,31 @@ def not_found(message: str) -> StoreApiError:
38
38
  return StoreApiError(404, "NOT_FOUND", message)
39
39
 
40
40
 
41
+ # ── 资源字节上传/下载守卫工厂(spec/03-errors.md) ──
42
+ def empty_body(message: str | None = None) -> StoreApiError:
43
+ return StoreApiError(400, "EMPTY_BODY", message or "上传请求体为空")
44
+
45
+
46
+ def too_large(message: str | None = None) -> StoreApiError:
47
+ return StoreApiError(413, "UPLOAD_TOO_LARGE", message or "上传字节体超过上限")
48
+
49
+
50
+ def upload_not_configured(message: str | None = None) -> StoreApiError:
51
+ return StoreApiError(501, "UPLOAD_NOT_CONFIGURED", message or "未注入 upload_resolver")
52
+
53
+
54
+ def file_not_configured(message: str | None = None) -> StoreApiError:
55
+ return StoreApiError(501, "FILE_NOT_CONFIGURED", message or "未注入 file_resolver")
56
+
57
+
41
58
  def context_error(message: str | None) -> StoreApiError:
42
59
  return StoreApiError(401, "CONTEXT_ERROR", message)
43
60
 
44
61
 
45
62
  def map_error(err: BaseException, permission_error: type[BaseException] | None) -> StoreApiError:
46
63
  """判定顺序(spec/03-errors.md):
47
- 适配层守卫(400) → PermissionError(403) → queryOne 空结果(404) → 其余 500 透传
64
+ 未注入 resolver / 适配层守卫(400/413/501,由 StoreApiError 自带 status) → 权限类(403)
65
+ → GQL 解析失败(400) → 其余 500 透传。
48
66
  """
49
67
  if isinstance(err, StoreApiError):
50
68
  return err
@@ -53,10 +71,20 @@ def map_error(err: BaseException, permission_error: type[BaseException] | None)
53
71
  if permission_error is not None and isinstance(err, permission_error):
54
72
  # 按类型判定,禁按 message 匹配
55
73
  return StoreApiError(403, store_code(err), str(err) or None)
74
+ if getattr(err, "code", None) == "no_context":
75
+ # spec/03 判定顺序第 3 层:权限类同档 —— py-store NoContextError(machine code
76
+ # `no_context`,requireContext 开启且 ctx 缺失);按 code 判定(宿主已剥前缀),
77
+ # 禁按 message 匹配 → 403。
78
+ return StoreApiError(403, store_code(err), str(err) or None)
56
79
  # spec/03 判定顺序第 3 层:GQL 解析失败(core 稳定前缀 ERR_GQL_PARSE:,与
57
80
  # ERR_PERM_PREFIX 同构的类型级契约——前缀判定非文案脆弱匹配)→ 400 GQL_PARSE,
58
81
  # message 剥前缀取原文(与 go/rust/node 同语义)。
59
82
  text = str(err)
60
83
  if text.startswith("ERR_GQL_PARSE:"):
61
84
  return StoreApiError(400, "GQL_PARSE", text[len("ERR_GQL_PARSE:"):])
85
+ # spec/03 判定顺序第 4 层(core 稳定前缀类):资源无任何可读副本
86
+ # (py-store resource open 零副本行 → ERR_RESOURCE_NOT_FOUND:)→ 404 NOT_FOUND,
87
+ # message 剥前缀取原文(与 GQL_PARSE 同构;禁按文案匹配)。
88
+ if text.startswith("ERR_RESOURCE_NOT_FOUND:"):
89
+ return StoreApiError(404, "NOT_FOUND", text[len("ERR_RESOURCE_NOT_FOUND:"):] or None)
62
90
  return StoreApiError(500, store_code(err), text or None)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: store-api-py
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: FastAPI adapter that auto-generates RESTful APIs for py-store schemas (schema-driven CRUD over GQL)
5
5
  License: MIT
6
6
  Keywords: py-store,storepy,fastapi,rest,crud,gql
@@ -9,4 +9,5 @@ src/store_api_py.egg-info/SOURCES.txt
9
9
  src/store_api_py.egg-info/dependency_links.txt
10
10
  src/store_api_py.egg-info/requires.txt
11
11
  src/store_api_py.egg-info/top_level.txt
12
+ tests/test_error_matrix.py
12
13
  tests/test_smoke.py
@@ -0,0 +1,52 @@
1
+ """映射矩阵:每档错误语义 → REST 状态码(A3「可程序化区分」验收)。
2
+
3
+ 直接调纯函数 map_error(无需真实 store/库),用例与 node/test/error-matrix.test.js 同构。
4
+ 规范依据:spec/03-errors.md 判定顺序 + core machine code(`no_context` / `permission_denied`)。
5
+ """
6
+
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
11
+
12
+ from store_api_py.errors import map_error # noqa: E402
13
+
14
+
15
+ class PermissionError(Exception):
16
+ """store 权限错误类(按类型判定,禁按 message 匹配)。"""
17
+
18
+
19
+ def _err(message: str, code: str) -> Exception:
20
+ e = Exception(message)
21
+ e.code = code
22
+ return e
23
+
24
+
25
+ def test_matrix_permission_to_403():
26
+ err = PermissionError("无访问权限")
27
+ err.code = "permission_denied"
28
+ r = map_error(err, PermissionError)
29
+ assert r.status_code == 403
30
+ assert r.code == "permission_denied"
31
+ assert r.message == "无访问权限"
32
+
33
+
34
+ def test_matrix_no_context_to_403():
35
+ r = map_error(_err("上下文缺失", "no_context"), PermissionError)
36
+ assert r.status_code == 403
37
+ assert r.code == "no_context"
38
+ assert r.message == "上下文缺失"
39
+
40
+
41
+ def test_matrix_gql_parse_to_400():
42
+ r = map_error(Exception("ERR_GQL_PARSE:意外的 token"), PermissionError)
43
+ assert r.status_code == 400
44
+ assert r.code == "GQL_PARSE"
45
+ assert r.message == "意外的 token"
46
+
47
+
48
+ def test_matrix_other_to_500():
49
+ r = map_error(_err("连接超时", "CONN_TIMEOUT"), PermissionError)
50
+ assert r.status_code == 500
51
+ assert r.code == "CONN_TIMEOUT"
52
+ assert r.message == "连接超时"
@@ -20,6 +20,19 @@ class MockPermissionError(Exception):
20
20
  pass
21
21
 
22
22
 
23
+ def _apply_set(row: dict, path: str, value):
24
+ """模拟 store $set 的点路径写入语义:'images.full' → row['images']['full'](Mongo $set {'a.b': v} 即嵌套写)。"""
25
+ if path.startswith("$"):
26
+ return # 原生操作符($inc/$unset 等)本 mock 不模拟
27
+ segs = path.split(".")
28
+ cur = row
29
+ for s in segs[:-1]:
30
+ if not isinstance(cur.get(s), dict):
31
+ cur[s] = {}
32
+ cur = cur[s]
33
+ cur[segs[-1]] = value
34
+
35
+
23
36
  class MockStore:
24
37
  """mock store:只实现适配器调用面(query/query_one/insert/update/remove/list/set_context)。"""
25
38
 
@@ -59,7 +72,10 @@ class MockStore:
59
72
  async def update(self, name, cond, data):
60
73
  row = self.rows.get(cond.get("_id"))
61
74
  if row:
62
- row.update(data)
75
+ # 模拟 store 的 $set 语义:非 `$` 前缀键自动包 $set(py-store/src/py_store/crud/write.py),
76
+ # 键内点路径由后端寻址(Mongo $set {'a.b': v} 即嵌套写入)。
77
+ for k, v in data.items():
78
+ _apply_set(row, k, v)
63
79
  return row
64
80
 
65
81
  async def remove(self, name, cond):
@@ -70,6 +86,22 @@ def build_app(store, **extra):
70
86
  return create_app(store, prefix="/api", **extra)
71
87
 
72
88
 
89
+ def test_secure_config_guard():
90
+ """fail-secure 装配守卫:require_context=True 且无 context_provider ⇒ 装配期 ERR_SECURE_CONFIG"""
91
+ store = MockStore()
92
+ store.require_context = lambda: True
93
+ # 无 context_provider:装配期拒绝(fail-fast,错误不推迟到运行期)
94
+ with pytest.raises(RuntimeError, match="ERR_SECURE_CONFIG"):
95
+ build_app(store)
96
+ # 补配 context_provider:正常装配
97
+ app = build_app(store, context_provider=lambda request: {"uid": "u1"})
98
+ assert app is not None
99
+ # require_context=False(默认姿态)时无守卫,照常装配
100
+ opened = MockStore()
101
+ opened.require_context = lambda: False
102
+ assert build_app(opened) is not None
103
+
104
+
73
105
  def test_filter_archived():
74
106
  assert filter_archived(["user", "userDeleted", "post", "logDeleted"]) == ["user", "post", "logDeleted"]
75
107
 
@@ -226,14 +258,22 @@ def test_x_cache_header():
226
258
 
227
259
 
228
260
  def test_file_download_route():
229
- """文件下载路由:200 + 头;记录/字段缺失 → 404(spec/01+03+05)"""
261
+ """文件下载路由:注入 resolver → 200 + 头;未注入 → 501;缺失 → 404(spec/01+03+05)"""
230
262
  store = MockStore()
231
- client = TestClient(build_app(store))
232
263
 
233
- created = client.post("/api/user", json={"name": "a", "age": 1})
234
- rid = created.json()["data"]["_id"]
264
+ # 未注入 file_resolver → 501 FILE_NOT_CONFIGURED(决策 D,原 text/plain 兜底已废止)
265
+ bare = TestClient(build_app(store))
266
+ rid = bare.post("/api/user", json={"name": "a", "age": 1}).json()["data"]["_id"]
235
267
  store.rows[rid]["file"] = "hello"
268
+ not_configured = bare.get(f"/api/user/{rid}/file")
269
+ assert not_configured.status_code == 501
270
+ assert not_configured.json()["error"]["code"] == "FILE_NOT_CONFIGURED"
236
271
 
272
+ # 注入 resolver → 200
273
+ async def _fr(request, rec, i):
274
+ return {"body": b"hello", "contentType": "text/plain; charset=utf-8", "fileName": f"user-{i}"}
275
+
276
+ client = TestClient(build_app(store, file_resolver=_fr))
237
277
  file = client.get(f"/api/user/{rid}/file")
238
278
  assert file.status_code == 200
239
279
  assert file.headers["content-type"] == "text/plain; charset=utf-8"
@@ -243,3 +283,76 @@ def test_file_download_route():
243
283
  missing = client.get("/api/user/nope/file")
244
284
  assert missing.status_code == 404
245
285
  assert missing.json()["error"]["code"] == "NOT_FOUND"
286
+
287
+
288
+ def test_file_download_resource_not_found_maps_404():
289
+ """下载路由:资源无任何可读副本(core 前缀 ERR_RESOURCE_NOT_FOUND:)→ 404 NOT_FOUND(spec/03)。"""
290
+ store = MockStore()
291
+ rid = TestClient(build_app(store)).post("/api/user", json={"name": "a", "age": 1}).json()["data"]["_id"]
292
+ store.rows[rid]["file"] = "sha1x" # 字段非空才会走到 resolver
293
+
294
+ async def _fr(request, rec, i):
295
+ raise FileNotFoundError("ERR_RESOURCE_NOT_FOUND:资源不存在或无可读副本: sha1x")
296
+
297
+ res = TestClient(build_app(store, file_resolver=_fr)).get(f"/api/user/{rid}/file")
298
+ assert res.status_code == 404
299
+ assert res.json()["error"]["code"] == "NOT_FOUND"
300
+ assert res.json()["error"]["message"] == "资源不存在或无可读副本: sha1x" # 前缀已剥离
301
+
302
+
303
+ def test_file_download_provider_failure_stays_500():
304
+ """下载路由:provider 读取失败(非前缀错误)→ 500 透传,不得伪装成 404。"""
305
+ store = MockStore()
306
+ rid = TestClient(build_app(store)).post("/api/user", json={"name": "a", "age": 1}).json()["data"]["_id"]
307
+ store.rows[rid]["file"] = "sha1x"
308
+
309
+ async def _fr(request, rec, i):
310
+ raise RuntimeError("io down")
311
+
312
+ res = TestClient(build_app(store, file_resolver=_fr)).get(f"/api/user/{rid}/file")
313
+ assert res.status_code == 500
314
+ assert res.json()["error"]["message"] == "io down"
315
+
316
+
317
+ def test_upload_route_and_field():
318
+ """上传路由:未注入 → 501;注入 → 200 回写;空体 → 400;超限 → 413;field 点路径(spec/01+02+03)"""
319
+ store = MockStore()
320
+ bare = TestClient(build_app(store))
321
+ rid = bare.post("/api/user", json={"name": "a", "age": 1}).json()["data"]["_id"]
322
+
323
+ nc = bare.post(f"/api/user/{rid}/file", content=b"bytes",
324
+ headers={"content-type": "application/octet-stream"})
325
+ assert nc.status_code == 501
326
+ assert nc.json()["error"]["code"] == "UPLOAD_NOT_CONFIGURED"
327
+
328
+ async def _ur(request, rec, i):
329
+ return {"ref": "sha1abc"}
330
+
331
+ client = TestClient(build_app(store, upload_resolver=_ur))
332
+ ok = client.post(f"/api/user/{rid}/file", content=b"bytes",
333
+ headers={"content-type": "application/octet-stream"})
334
+ assert ok.status_code == 200
335
+ assert ok.json()["data"]["file"] == "sha1abc"
336
+
337
+ empty = client.post(f"/api/user/{rid}/file", content=b"",
338
+ headers={"content-type": "application/octet-stream"})
339
+ assert empty.status_code == 400
340
+ assert empty.json()["error"]["code"] == "EMPTY_BODY"
341
+
342
+ # 超限 → 413 UPLOAD_TOO_LARGE(A4 parity;用小 upload_limit 触发,避免构造 32MB 大包)
343
+ small = TestClient(build_app(store, upload_resolver=_ur, upload_limit=4))
344
+ too_large = small.post(f"/api/user/{rid}/file", content=b"bytes",
345
+ headers={"content-type": "application/octet-stream"})
346
+ assert too_large.status_code == 413
347
+ assert too_large.json()["error"]["code"] == "UPLOAD_TOO_LARGE"
348
+
349
+ store.rows[rid]["images"] = {"full": "old-ref"}
350
+ ok2 = client.post(f"/api/user/{rid}/file?field=images.full", content=b"bytes",
351
+ headers={"content-type": "application/octet-stream"})
352
+ assert ok2.status_code == 200
353
+ assert ok2.json()["data"]["images"]["full"] == "sha1abc"
354
+
355
+ missing = client.post("/api/user/nope/file", content=b"bytes",
356
+ headers={"content-type": "application/octet-stream"})
357
+ assert missing.status_code == 404
358
+ assert missing.json()["error"]["code"] == "NOT_FOUND"
File without changes
File without changes