feat(packs-lab): chunked resumable upload (offset-based) 추가
기존 single-shot POST /upload는 그대로 유지하고, 5GB+ 안정성을 위한
chunk upload 5-endpoint를 추가했다.
- POST /upload/init — mint-token jti consume + 세션 디렉토리 생성
- PUT /upload/{sid}/chunk?offset=N — offset 매칭 후 .part 파일 append
· 불일치 시 409 + X-Current-Offset 헤더로 재개 지점 통보
- GET /upload/{sid}/status — 현재 written / expected_size 조회
- POST /upload/{sid}/complete — atomic rename + Supabase INSERT
- DELETE /upload/{sid} — 세션 중단 + 부분파일 정리
auth.py: verify_upload_token_no_consume() 추가 — chunk/complete/abort/status
는 동일 mint-token을 재사용해야 하므로 jti consume 없이 시그니처+만료만 검증.
models.py: InitUploadResponse, ChunkUploadResponse 추가.
세션 state: PACK_BASE_DIR/.uploads/{jti}/meta.json + data.part (파일시스템
영속, 단일 컨테이너 가정).
chunk 크기 상한: PACK_CHUNK_MAX_SIZE env (기본 64MB).
tests: chunk upload 시나리오 8종 — full-flow / offset mismatch / status /
abort / wrong token / incomplete complete / filename collision / host path
저장. 전체 37 테스트 pass.
CLAUDE.md: packs-lab API 표에 chunk 5-endpoint + 사용 패턴 보강.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -248,6 +248,241 @@ def test_list_filters_deleted():
|
||||
fake_supabase.table.return_value.select.return_value.is_.assert_called_with("deleted_at", "null")
|
||||
|
||||
|
||||
def _mint(filename: str, size: int, jti: str = None) -> str:
|
||||
return auth.mint_upload_token({
|
||||
"tier": "pro",
|
||||
"label": "샘플",
|
||||
"filename": filename,
|
||||
"size_bytes": size,
|
||||
"jti": jti or str(uuid.uuid4()),
|
||||
"expires_at": int(time.time()) + 1800,
|
||||
})
|
||||
|
||||
|
||||
def test_chunk_upload_full_flow(tmp_path, monkeypatch):
|
||||
"""init → chunk(0) → chunk(N) → complete 정상 흐름."""
|
||||
monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path)
|
||||
from pathlib import Path
|
||||
monkeypatch.setattr("app.routes.PACK_HOST_DIR", Path("/volume1/host"))
|
||||
|
||||
fake_supabase = MagicMock()
|
||||
fake_supabase.table.return_value.insert.return_value.execute.return_value = MagicMock(
|
||||
data=[{"uploaded_at": "2026-05-12T00:00:00+00:00"}]
|
||||
)
|
||||
|
||||
payload = b"a" * 100 + b"b" * 50 # 150 bytes total
|
||||
chunk1 = payload[:100]
|
||||
chunk2 = payload[100:]
|
||||
|
||||
jti = str(uuid.uuid4())
|
||||
token = _mint("chunk_full.zip", len(payload), jti=jti)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
with patch("app.routes._supabase", return_value=fake_supabase):
|
||||
test_client = TestClient(app)
|
||||
|
||||
# init
|
||||
r = test_client.post("/api/packs/upload/init", headers=headers)
|
||||
assert r.status_code == 200, r.text
|
||||
sid = r.json()["session_id"]
|
||||
assert sid == jti
|
||||
assert r.json()["expected_size"] == 150
|
||||
|
||||
# chunk 1 (offset=0)
|
||||
r = test_client.put(
|
||||
f"/api/packs/upload/{sid}/chunk?offset=0",
|
||||
content=chunk1,
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["written"] == 100
|
||||
|
||||
# chunk 2 (offset=100)
|
||||
r = test_client.put(
|
||||
f"/api/packs/upload/{sid}/chunk?offset=100",
|
||||
content=chunk2,
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["written"] == 150
|
||||
|
||||
# complete
|
||||
r = test_client.post(f"/api/packs/upload/{sid}/complete", headers=headers)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["filename"] == "chunk_full.zip"
|
||||
assert body["size_bytes"] == 150
|
||||
assert body["file_path"] == "/volume1/host/chunk_full.zip" or body["file_path"].endswith("chunk_full.zip")
|
||||
|
||||
# 파일이 최종 위치로 이동했고 session은 정리됨
|
||||
assert (tmp_path / "chunk_full.zip").read_bytes() == payload
|
||||
assert not (tmp_path / ".uploads" / sid).exists()
|
||||
|
||||
|
||||
def test_chunk_upload_offset_mismatch(tmp_path, monkeypatch):
|
||||
"""잘못된 offset → 409 + X-Current-Offset 헤더."""
|
||||
monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path)
|
||||
|
||||
jti = str(uuid.uuid4())
|
||||
token = _mint("offset_mismatch.zip", 100, jti=jti)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
test_client = TestClient(app)
|
||||
r = test_client.post("/api/packs/upload/init", headers=headers)
|
||||
assert r.status_code == 200
|
||||
sid = r.json()["session_id"]
|
||||
|
||||
# 잘못된 offset (10인데 0이어야 함)
|
||||
r = test_client.put(
|
||||
f"/api/packs/upload/{sid}/chunk?offset=10",
|
||||
content=b"x" * 10,
|
||||
headers=headers,
|
||||
)
|
||||
assert r.status_code == 409
|
||||
assert r.headers.get("X-Current-Offset") == "0"
|
||||
|
||||
|
||||
def test_chunk_upload_status(tmp_path, monkeypatch):
|
||||
"""status로 현재 written 조회."""
|
||||
monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path)
|
||||
|
||||
jti = str(uuid.uuid4())
|
||||
token = _mint("status_check.zip", 50, jti=jti)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
test_client = TestClient(app)
|
||||
r = test_client.post("/api/packs/upload/init", headers=headers)
|
||||
sid = r.json()["session_id"]
|
||||
|
||||
# 빈 상태
|
||||
r = test_client.get(f"/api/packs/upload/{sid}/status", headers=headers)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["written"] == 0
|
||||
assert r.json()["expected_size"] == 50
|
||||
|
||||
# 일부 업로드 후
|
||||
test_client.put(
|
||||
f"/api/packs/upload/{sid}/chunk?offset=0",
|
||||
content=b"x" * 20,
|
||||
headers=headers,
|
||||
)
|
||||
r = test_client.get(f"/api/packs/upload/{sid}/status", headers=headers)
|
||||
assert r.json()["written"] == 20
|
||||
|
||||
|
||||
def test_chunk_upload_abort(tmp_path, monkeypatch):
|
||||
"""DELETE → session 디렉토리 정리."""
|
||||
monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path)
|
||||
|
||||
jti = str(uuid.uuid4())
|
||||
token = _mint("abort_test.zip", 30, jti=jti)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
test_client = TestClient(app)
|
||||
test_client.post("/api/packs/upload/init", headers=headers)
|
||||
test_client.put(
|
||||
f"/api/packs/upload/{jti}/chunk?offset=0",
|
||||
content=b"y" * 10,
|
||||
headers=headers,
|
||||
)
|
||||
assert (tmp_path / ".uploads" / jti).exists()
|
||||
|
||||
r = test_client.delete(f"/api/packs/upload/{jti}", headers=headers)
|
||||
assert r.status_code == 200
|
||||
assert not (tmp_path / ".uploads" / jti).exists()
|
||||
|
||||
|
||||
def test_chunk_upload_wrong_token(tmp_path, monkeypatch):
|
||||
"""다른 jti의 token으로 chunk 호출 → 403."""
|
||||
monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path)
|
||||
|
||||
# session A 시작
|
||||
jti_a = str(uuid.uuid4())
|
||||
token_a = _mint("wrong_token_a.zip", 30, jti=jti_a)
|
||||
headers_a = {"Authorization": f"Bearer {token_a}"}
|
||||
test_client = TestClient(app)
|
||||
test_client.post("/api/packs/upload/init", headers=headers_a)
|
||||
|
||||
# session B의 token으로 session A의 chunk 호출
|
||||
jti_b = str(uuid.uuid4())
|
||||
token_b = _mint("wrong_token_b.zip", 30, jti=jti_b)
|
||||
headers_b = {"Authorization": f"Bearer {token_b}"}
|
||||
|
||||
r = test_client.put(
|
||||
f"/api/packs/upload/{jti_a}/chunk?offset=0",
|
||||
content=b"z" * 10,
|
||||
headers=headers_b,
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_chunk_upload_complete_incomplete(tmp_path, monkeypatch):
|
||||
"""expected_size 미달 상태에서 complete 호출 → 400."""
|
||||
monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path)
|
||||
|
||||
jti = str(uuid.uuid4())
|
||||
token = _mint("incomplete.zip", 100, jti=jti)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
test_client = TestClient(app)
|
||||
test_client.post("/api/packs/upload/init", headers=headers)
|
||||
test_client.put(
|
||||
f"/api/packs/upload/{jti}/chunk?offset=0",
|
||||
content=b"q" * 50,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
r = test_client.post(f"/api/packs/upload/{jti}/complete", headers=headers)
|
||||
assert r.status_code == 400
|
||||
assert "미완료" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_chunk_init_filename_collision(tmp_path, monkeypatch):
|
||||
"""init 시 동일 파일명이 PACK_BASE_DIR에 이미 있으면 409."""
|
||||
monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path)
|
||||
(tmp_path / "existing.zip").write_bytes(b"already here")
|
||||
|
||||
token = _mint("existing.zip", 100)
|
||||
r = TestClient(app).post(
|
||||
"/api/packs/upload/init",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_chunk_upload_stores_host_path(tmp_path, monkeypatch):
|
||||
"""complete 시 Supabase에 저장되는 file_path는 PACK_HOST_DIR 기준."""
|
||||
from pathlib import Path
|
||||
container_base = tmp_path / "container"
|
||||
host_base = Path("/volume1/host/packs")
|
||||
monkeypatch.setattr("app.routes.PACK_BASE_DIR", container_base)
|
||||
monkeypatch.setattr("app.routes.PACK_HOST_DIR", host_base)
|
||||
|
||||
captured = {}
|
||||
fake_supabase = MagicMock()
|
||||
|
||||
def capture_insert(payload):
|
||||
captured.update(payload)
|
||||
m = MagicMock()
|
||||
m.execute.return_value = MagicMock(data=[{"uploaded_at": "2026-05-12T00:00:00+00:00"}])
|
||||
return m
|
||||
|
||||
fake_supabase.table.return_value.insert.side_effect = capture_insert
|
||||
|
||||
jti = str(uuid.uuid4())
|
||||
token = _mint("hostpath_chunk.zip", 5, jti=jti)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
with patch("app.routes._supabase", return_value=fake_supabase):
|
||||
c = TestClient(app)
|
||||
c.post("/api/packs/upload/init", headers=headers)
|
||||
c.put(f"/api/packs/upload/{jti}/chunk?offset=0", content=b"hello", headers=headers)
|
||||
r = c.post(f"/api/packs/upload/{jti}/complete", headers=headers)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert captured["file_path"] == str(host_base / "hostpath_chunk.zip")
|
||||
|
||||
|
||||
def test_upload_stores_host_path_not_container_path(tmp_path, monkeypatch):
|
||||
"""upload 시 Supabase에 저장되는 file_path는 PACK_BASE_DIR(컨테이너) 가 아닌 PACK_HOST_DIR(NAS 호스트) 절대경로여야 한다.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user