1072 lines
41 KiB
Python
1072 lines
41 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import json
|
|
import threading
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import httpx
|
|
import pytest
|
|
from imagefind import storage as storage_module
|
|
from imagefind import uploads as uploads_module
|
|
from imagefind.config import Settings
|
|
from imagefind.database import DatabaseTransientError, utcnow
|
|
from imagefind.jobs import JobRetry
|
|
from imagefind.main import create_app
|
|
from imagefind.remote import AlistClient, openlist_endpoint_from_webdav_url
|
|
from imagefind.storage import StorageService, TransferCancelled
|
|
from imagefind.uploads import UploadService
|
|
|
|
|
|
def _app(tmp_path: Path):
|
|
settings = Settings(
|
|
data_dir=tmp_path / "data",
|
|
embedding_backend="hash",
|
|
scan_interval_seconds=86400,
|
|
upload_chunk_mb=1,
|
|
upload_staging_gb=1,
|
|
upload_reserve_gb=0,
|
|
)
|
|
settings.prepare()
|
|
app = create_app(settings)
|
|
media = tmp_path / "media"
|
|
media.mkdir()
|
|
source_id = app.state.services.sources.add_local("私有影片", str(media))
|
|
app.state.services.storage.set_writable(source_id, True)
|
|
_, token = app.state.services.auth.create_api_token("test")
|
|
return app, media, source_id, {"Authorization": f"Bearer {token}"}
|
|
|
|
|
|
def test_chunked_upload_transfer_refresh_and_download(tmp_path: Path):
|
|
app, media, source_id, headers = _app(tmp_path)
|
|
payload = b"private-video-data" * 4096
|
|
|
|
async def scenario():
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
created = await client.post(
|
|
"/api/v1/uploads",
|
|
headers=headers,
|
|
json={
|
|
"source_id": source_id,
|
|
"relative_path": "imports",
|
|
"filename": "ABC-123.mp4",
|
|
"title": " 我的自定义标题 ",
|
|
"size_bytes": len(payload),
|
|
},
|
|
)
|
|
assert created.status_code == 201
|
|
upload = created.json()
|
|
assert upload["title"] == "我的自定义标题"
|
|
assert upload["total_chunks"] == 1
|
|
digest = hashlib.sha256(payload).hexdigest()
|
|
chunk = await client.put(
|
|
f"/api/v1/uploads/{upload['id']}/chunks/0",
|
|
headers={**headers, "X-Chunk-SHA256": digest},
|
|
content=payload,
|
|
)
|
|
assert chunk.status_code == 200
|
|
completed = await client.post(f"/api/v1/uploads/{upload['id']}/complete", headers=headers)
|
|
assert completed.status_code == 200
|
|
job_id = completed.json()["job_id"]
|
|
|
|
app.state.services.uploads.transfer(job_id, upload["id"])
|
|
assert (media / "imports" / "ABC-123.mp4").read_bytes() == payload
|
|
app.state.services.scanner.refresh_path("refresh-test", source_id, "imports/ABC-123.mp4", upload["id"])
|
|
|
|
uploads = (await client.get("/api/v1/uploads", headers=headers)).json()
|
|
assert uploads[0]["status"] == "completed"
|
|
assert uploads[0]["title"] == "我的自定义标题"
|
|
videos = (await client.get("/api/v1/videos", headers=headers)).json()
|
|
assert videos[0]["catalog_code"] == "ABC-123"
|
|
assert videos[0]["title"] == "我的自定义标题"
|
|
download = await client.get(videos[0]["download_url"], headers=headers)
|
|
assert download.content == payload
|
|
assert "ABC-123.mp4" in download.headers["content-disposition"]
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_transient_target_failure_requeues_without_losing_local_staging(tmp_path: Path, monkeypatch):
|
|
app, _, source_id, _ = _app(tmp_path)
|
|
payload = b"retryable-upload"
|
|
upload = app.state.services.uploads.create(source_id, "imports", "retry.mp4", len(payload))
|
|
app.state.services.uploads.receive_chunk(upload["id"], 0, payload)
|
|
completed = app.state.services.uploads.complete(upload["id"])
|
|
temporary = Path(app.state.services.uploads._get(upload["id"])["temp_path"])
|
|
monkeypatch.setattr(
|
|
app.state.services.storage,
|
|
"write_file",
|
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(ConnectionError("connection reset")),
|
|
)
|
|
|
|
with pytest.raises(JobRetry) as retry:
|
|
app.state.services.uploads.transfer(completed["job_id"], upload["id"])
|
|
|
|
current = app.state.services.uploads._get(upload["id"])
|
|
assert retry.value.delay_seconds == 2
|
|
assert current["status"] == "queued"
|
|
assert current["retry_count"] == 1
|
|
assert current["next_retry_at"]
|
|
assert current["resume_mode"] == "restart"
|
|
assert temporary.read_bytes() == payload
|
|
|
|
|
|
def test_upload_title_can_be_updated_before_completion_and_is_validated(tmp_path: Path):
|
|
app, _, source_id, headers = _app(tmp_path)
|
|
|
|
async def scenario():
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
created = await client.post(
|
|
"/api/v1/uploads",
|
|
headers=headers,
|
|
json={"source_id": source_id, "filename": "movie.mp4", "title": "初始标题", "size_bytes": 4},
|
|
)
|
|
assert created.status_code == 201
|
|
upload_id = created.json()["id"]
|
|
updated = await client.patch(
|
|
f"/api/v1/uploads/{upload_id}", headers=headers, json={"title": " 更新标题 "}
|
|
)
|
|
assert updated.status_code == 200
|
|
assert updated.json()["title"] == "更新标题"
|
|
cleared = await client.patch(f"/api/v1/uploads/{upload_id}", headers=headers, json={"title": " "})
|
|
assert cleared.status_code == 200
|
|
assert cleared.json()["title"] is None
|
|
too_long = await client.patch(f"/api/v1/uploads/{upload_id}", headers=headers, json={"title": "x" * 501})
|
|
assert too_long.status_code == 422
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_upload_history_pagination_is_stable_and_keeps_global_status(tmp_path: Path):
|
|
app, _, source_id, headers = _app(tmp_path)
|
|
uploads = app.state.services.uploads
|
|
created = [uploads.create(source_id, "", f"movie-{index:02d}.mp4", 1) for index in range(23)]
|
|
upload_ids = [item["id"] for item in created]
|
|
same_created_at = "2026-08-04T00:00:00+00:00"
|
|
with app.state.services.db.transaction() as conn:
|
|
conn.execute("UPDATE uploads SET status='completed',created_at=?", (same_created_at,))
|
|
conn.execute("UPDATE uploads SET status='receiving' WHERE id=?", (upload_ids[0],))
|
|
conn.execute("UPDATE uploads SET status='queued' WHERE id=?", (upload_ids[1],))
|
|
conn.execute("UPDATE uploads SET status='failed',error='network error' WHERE id=?", (upload_ids[2],))
|
|
|
|
async def scenario():
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
first = (await client.get("/api/v1/uploads?page=1&page_size=10", headers=headers)).json()
|
|
second = (await client.get("/api/v1/uploads?page=2&page_size=10", headers=headers)).json()
|
|
clamped = (await client.get("/api/v1/uploads?page=99&page_size=10", headers=headers)).json()
|
|
legacy = (await client.get("/api/v1/uploads?limit=4", headers=headers)).json()
|
|
|
|
expected = sorted(upload_ids, reverse=True)
|
|
assert (first["page"], first["pages"], first["total"], len(first["items"])) == (1, 3, 23, 10)
|
|
assert [item["id"] for item in first["items"]] == expected[:10]
|
|
assert [item["id"] for item in second["items"]] == expected[10:20]
|
|
assert not ({item["id"] for item in first["items"]} & {item["id"] for item in second["items"]})
|
|
assert (clamped["page"], len(clamped["items"])) == (3, 3)
|
|
assert first["active_count"] == 2
|
|
assert first["failed_count"] == 1
|
|
assert {item["id"] for item in first["status_items"]} == set(upload_ids[:3])
|
|
assert isinstance(legacy, list) and len(legacy) == 4
|
|
assert uploads.cached_paginate(1, 10) == first
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_upload_missing_chunks_and_hash_rejection(tmp_path: Path):
|
|
app, _, source_id, headers = _app(tmp_path)
|
|
payload = b"x" * (1024 * 1024 + 3)
|
|
|
|
async def scenario():
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
upload = (
|
|
await client.post(
|
|
"/api/v1/uploads",
|
|
headers=headers,
|
|
json={"source_id": source_id, "filename": "movie.mp4", "size_bytes": len(payload)},
|
|
)
|
|
).json()
|
|
bad = await client.put(
|
|
f"/api/v1/uploads/{upload['id']}/chunks/0",
|
|
headers={**headers, "X-Chunk-SHA256": "0" * 64},
|
|
content=payload[: 1024 * 1024],
|
|
)
|
|
assert bad.status_code == 400
|
|
good = await client.put(
|
|
f"/api/v1/uploads/{upload['id']}/chunks/0",
|
|
headers=headers,
|
|
content=payload[: 1024 * 1024],
|
|
)
|
|
assert good.status_code == 200
|
|
incomplete = await client.post(f"/api/v1/uploads/{upload['id']}/complete", headers=headers)
|
|
assert incomplete.json() == {"completed": False, "missing_chunks": [1]}
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_upload_cancel_is_consistent_and_commit_window_returns_conflict(tmp_path: Path):
|
|
app, _, source_id, headers = _app(tmp_path)
|
|
upload = app.state.services.uploads.create(source_id, "", "movie.mp4", 4)
|
|
app.state.services.uploads.receive_chunk(upload["id"], 0, b"data")
|
|
job_id = app.state.services.uploads.complete(upload["id"])["job_id"]
|
|
app.state.services.uploads.cancel(upload["id"])
|
|
|
|
with app.state.services.db.read() as conn:
|
|
upload_state = conn.execute("SELECT status FROM uploads WHERE id=?", (upload["id"],)).fetchone()
|
|
job_state = conn.execute("SELECT status FROM jobs WHERE id=?", (job_id,)).fetchone()
|
|
assert upload_state["status"] == "cancelled"
|
|
assert job_state["status"] == "cancelled"
|
|
with app.state.services.db.read() as conn:
|
|
retained_path = Path(
|
|
conn.execute("SELECT temp_path FROM uploads WHERE id=?", (upload["id"],)).fetchone()[0]
|
|
)
|
|
assert retained_path.read_bytes() == b"data"
|
|
|
|
committed = app.state.services.uploads.create(source_id, "", "committed.mp4", 4)
|
|
with app.state.services.db.transaction() as conn:
|
|
conn.execute(
|
|
"UPDATE uploads SET status='transferring',failure_stage='commit',target_key='committed.mp4' WHERE id=?",
|
|
(committed["id"],),
|
|
)
|
|
|
|
async def scenario():
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.delete(f"/api/v1/uploads/{committed['id']}", headers=headers)
|
|
assert response.status_code == 409
|
|
assert "已经写入" in response.json()["detail"]
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_cancelled_upload_requires_explicit_discard_to_delete_recovery_copy(tmp_path: Path):
|
|
app, _, source_id, headers = _app(tmp_path)
|
|
uploads = app.state.services.uploads
|
|
upload = uploads.create(source_id, "", "recoverable.mp4", 4)
|
|
uploads.receive_chunk(upload["id"], 0, b"data")
|
|
with uploads.db.read() as conn:
|
|
staging = Path(conn.execute("SELECT temp_path FROM uploads WHERE id=?", (upload["id"],)).fetchone()[0])
|
|
uploads.cancel(upload["id"])
|
|
assert staging.is_file()
|
|
|
|
async def scenario():
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.delete(
|
|
f"/api/v1/uploads/{upload['id']}/recovery", headers=headers
|
|
)
|
|
assert response.status_code == 204
|
|
|
|
asyncio.run(scenario())
|
|
assert not staging.exists()
|
|
with uploads.db.read() as conn:
|
|
assert conn.execute("SELECT 1 FROM uploads WHERE id=?", (upload["id"],)).fetchone() is None
|
|
|
|
|
|
def test_upload_catalog_failure_retries_without_retransmitting(tmp_path: Path, monkeypatch):
|
|
app, media, source_id, _ = _app(tmp_path)
|
|
uploads = app.state.services.uploads
|
|
upload = uploads.create(source_id, "imports", "movie.mp4", 4)
|
|
uploads.receive_chunk(upload["id"], 0, b"data")
|
|
transfer_job_id = uploads.complete(upload["id"])["job_id"]
|
|
enqueue = uploads.jobs.enqueue
|
|
|
|
def fail_refresh(kind, payload, **kwargs):
|
|
if kind == "refresh_path":
|
|
raise RuntimeError("catalog transaction unavailable")
|
|
return enqueue(kind, payload, **kwargs)
|
|
|
|
monkeypatch.setattr(uploads.jobs, "enqueue", fail_refresh)
|
|
with pytest.raises(RuntimeError, match="catalog transaction"):
|
|
uploads.transfer(transfer_job_id, upload["id"])
|
|
|
|
with uploads.db.read() as conn:
|
|
failed = dict(
|
|
conn.execute("SELECT status,failure_stage,target_key FROM uploads WHERE id=?", (upload["id"],)).fetchone()
|
|
)
|
|
assert failed == {
|
|
"status": "failed",
|
|
"failure_stage": "catalog",
|
|
"target_key": "imports/movie.mp4",
|
|
}
|
|
assert (media / "imports" / "movie.mp4").read_bytes() == b"data"
|
|
|
|
monkeypatch.setattr(uploads.jobs, "enqueue", enqueue)
|
|
refresh_job_id = uploads.retry(upload["id"])
|
|
with uploads.db.read() as conn:
|
|
job = dict(conn.execute("SELECT kind,payload_json FROM jobs WHERE id=?", (refresh_job_id,)).fetchone())
|
|
state = conn.execute("SELECT status FROM uploads WHERE id=?", (upload["id"],)).fetchone()[0]
|
|
assert job["kind"] == "refresh_path"
|
|
assert json.loads(job["payload_json"])["key"] == "imports/movie.mp4"
|
|
assert state == "indexing"
|
|
|
|
|
|
def test_upload_recovery_retires_stale_transfer_after_destination_commit(tmp_path: Path):
|
|
app, media, source_id, _ = _app(tmp_path)
|
|
uploads = app.state.services.uploads
|
|
upload = uploads.create(source_id, "imports", "movie.mp4", 4)
|
|
uploads.receive_chunk(upload["id"], 0, b"data")
|
|
transfer_job_id = uploads.complete(upload["id"])["job_id"]
|
|
with uploads.db.read() as conn:
|
|
staging = Path(conn.execute("SELECT temp_path FROM uploads WHERE id=?", (upload["id"],)).fetchone()[0])
|
|
target = media / "imports" / "movie.mp4"
|
|
target.parent.mkdir(parents=True)
|
|
staging.replace(target)
|
|
with uploads.db.transaction() as conn:
|
|
conn.execute(
|
|
"UPDATE uploads SET status='transferring',failure_stage='commit',target_key='imports/movie.mp4' WHERE id=?",
|
|
(upload["id"],),
|
|
)
|
|
conn.execute(
|
|
"UPDATE jobs SET status='running',lease_owner='old-process:transfer',heartbeat_at=? WHERE id=?",
|
|
(utcnow(), transfer_job_id),
|
|
)
|
|
|
|
assert uploads.jobs.recover_stale() == 1
|
|
assert uploads.recover_pending() == 1
|
|
|
|
with uploads.db.read() as conn:
|
|
stale = conn.execute("SELECT status FROM jobs WHERE id=?", (transfer_job_id,)).fetchone()[0]
|
|
current = dict(
|
|
conn.execute("SELECT status,job_id,target_key FROM uploads WHERE id=?", (upload["id"],)).fetchone()
|
|
)
|
|
refresh = conn.execute("SELECT kind,status FROM jobs WHERE id=?", (current["job_id"],)).fetchone()
|
|
assert stale == "cancelled"
|
|
assert current["status"] == "indexing"
|
|
assert current["target_key"] == "imports/movie.mp4"
|
|
assert dict(refresh) == {"kind": "refresh_path", "status": "queued"}
|
|
|
|
|
|
def test_upload_recovery_ignores_active_transfer_and_reconnects_failed_job(tmp_path: Path):
|
|
app, _, source_id, _ = _app(tmp_path)
|
|
uploads = app.state.services.uploads
|
|
upload = uploads.create(source_id, "imports", "movie.mp4", 4)
|
|
uploads.receive_chunk(upload["id"], 0, b"data")
|
|
transfer_job_id = uploads.complete(upload["id"])["job_id"]
|
|
|
|
assert uploads.recover_pending() == 0
|
|
with uploads.db.read() as conn:
|
|
active = dict(conn.execute("SELECT status,job_id FROM uploads WHERE id=?", (upload["id"],)).fetchone())
|
|
assert active == {"status": "queued", "job_id": transfer_job_id}
|
|
|
|
with uploads.db.transaction() as conn:
|
|
conn.execute(
|
|
"UPDATE jobs SET status='failed',error='deadlock detected',finished_at=? WHERE id=?",
|
|
(utcnow(), transfer_job_id),
|
|
)
|
|
conn.execute(
|
|
"UPDATE uploads SET status='transferring',progress=.86,message='传输到目标库 86%' WHERE id=?",
|
|
(upload["id"],),
|
|
)
|
|
|
|
assert uploads.recover_pending() == 1
|
|
with uploads.db.read() as conn:
|
|
recovered = dict(
|
|
conn.execute("SELECT status,job_id,message FROM uploads WHERE id=?", (upload["id"],)).fetchone()
|
|
)
|
|
replacement = dict(conn.execute("SELECT kind,status FROM jobs WHERE id=?", (recovered["job_id"],)).fetchone())
|
|
assert recovered["status"] == "queued"
|
|
assert recovered["job_id"] != transfer_job_id
|
|
assert "任务中断" in recovered["message"]
|
|
assert replacement == {"kind": "transfer_upload", "status": "queued"}
|
|
|
|
|
|
def test_locked_transfer_failure_does_not_leave_upload_transferring(tmp_path: Path, monkeypatch):
|
|
app, _, source_id, _ = _app(tmp_path)
|
|
uploads = app.state.services.uploads
|
|
upload = uploads.create(source_id, "imports", "movie.mp4", 4)
|
|
uploads.receive_chunk(upload["id"], 0, b"data")
|
|
transfer_job_id = uploads.complete(upload["id"])["job_id"]
|
|
original_get = uploads._get
|
|
reads = 0
|
|
|
|
def read_once(upload_id: str):
|
|
nonlocal reads
|
|
reads += 1
|
|
if reads > 1:
|
|
raise DatabaseTransientError("deadlock detected")
|
|
return original_get(upload_id)
|
|
|
|
monkeypatch.setattr(uploads, "_get", read_once)
|
|
monkeypatch.setattr(
|
|
uploads.storage,
|
|
"write_file",
|
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(DatabaseTransientError("deadlock detected")),
|
|
)
|
|
|
|
with pytest.raises(JobRetry, match="传输中断"):
|
|
uploads.transfer(transfer_job_id, upload["id"])
|
|
|
|
with uploads.db.read() as conn:
|
|
state = dict(
|
|
conn.execute("SELECT status,retry_count,failure_stage FROM uploads WHERE id=?", (upload["id"],)).fetchone()
|
|
)
|
|
assert reads == 1
|
|
assert state == {"status": "queued", "retry_count": 1, "failure_stage": "transfer"}
|
|
|
|
|
|
def test_webdav_upload_uses_temporary_put_atomic_move_and_safe_cleanup(tmp_path: Path, monkeypatch):
|
|
app, _, _, _ = _app(tmp_path)
|
|
storage = app.state.services.storage
|
|
source = {
|
|
"id": "remote",
|
|
"kind": "webdav",
|
|
"config": {"writable": True, "driver": "webdav"},
|
|
}
|
|
monkeypatch.setattr(storage.sources, "get", lambda _source_id: source)
|
|
|
|
class Client:
|
|
def __init__(self, move_status=201):
|
|
self.move_status = move_status
|
|
self.requests: list[tuple[str, str, dict]] = []
|
|
self.puts: list[tuple[str, bytes]] = []
|
|
self.deletes: list[str] = []
|
|
|
|
def request(self, method, url, headers=None, content=None, timeout=None):
|
|
self.requests.append((method, url, headers or {}))
|
|
if method == "PROPFIND":
|
|
return SimpleNamespace(status_code=404)
|
|
if method == "MOVE":
|
|
return SimpleNamespace(status_code=self.move_status)
|
|
return SimpleNamespace(status_code=201)
|
|
|
|
def put(self, url, headers=None, content=None, timeout=None):
|
|
self.puts.append((url, b"".join(content)))
|
|
return SimpleNamespace(status_code=201)
|
|
|
|
def delete(self, url):
|
|
self.deletes.append(url)
|
|
return SimpleNamespace(status_code=204)
|
|
|
|
class Connector:
|
|
base_url = "https://dav.example/media/"
|
|
|
|
def __init__(self, client):
|
|
self.client = client
|
|
self.closed = False
|
|
|
|
def url_for(self, key):
|
|
return self.base_url + key
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
payload = tmp_path / "remote-upload.part"
|
|
payload.write_bytes(b"remote-data")
|
|
client = Client()
|
|
connector = Connector(client)
|
|
monkeypatch.setattr(storage.sources, "connector", lambda _source_id: connector)
|
|
before: list[str] = []
|
|
key = storage.write_file(
|
|
"remote",
|
|
"imports",
|
|
"movie.mp4",
|
|
payload,
|
|
conflict="replace",
|
|
operation_id="upload-1",
|
|
before_commit=before.append,
|
|
)
|
|
temporary_url = "https://dav.example/media/imports/.movie.mp4.imagefind-upload-1.part"
|
|
target_url = "https://dav.example/media/imports/movie.mp4"
|
|
assert key == "imports/movie.mp4"
|
|
assert client.puts == [(temporary_url, b"remote-data")]
|
|
move = next(item for item in client.requests if item[0] == "MOVE")
|
|
assert move[1] == temporary_url
|
|
assert move[2]["Destination"] == target_url
|
|
assert before == ["imports/movie.mp4"]
|
|
assert connector.closed is True
|
|
|
|
failing_client = Client(move_status=405)
|
|
failing_connector = Connector(failing_client)
|
|
monkeypatch.setattr(storage, "_REMOTE_MOVE_RETRY_DELAYS", (0, 0, 0))
|
|
monkeypatch.setattr(storage.sources, "connector", lambda _source_id: failing_connector)
|
|
with pytest.raises(RuntimeError, match="不支持安全原子写入"):
|
|
storage.write_file(
|
|
"remote",
|
|
"imports",
|
|
"other.mp4",
|
|
payload,
|
|
conflict="replace",
|
|
operation_id="upload-2",
|
|
)
|
|
failed_temp = "https://dav.example/media/imports/.other.mp4.imagefind-upload-2.part"
|
|
assert failing_client.puts == [(failed_temp, b"remote-data")]
|
|
assert failing_client.deletes[-1] == failed_temp
|
|
assert all(url != "https://dav.example/media/imports/other.mp4" for url, _ in failing_client.puts)
|
|
|
|
|
|
def test_remote_move_reconciles_false_failure_retries_and_true_failure(monkeypatch):
|
|
storage = object.__new__(StorageService)
|
|
monkeypatch.setattr(storage, "_REMOTE_MOVE_RETRY_DELAYS", (0, 0, 0))
|
|
|
|
class Client:
|
|
def __init__(self, outcomes):
|
|
self.outcomes = iter(outcomes)
|
|
self.files = {"source.mp4"}
|
|
self.moves = 0
|
|
|
|
def request(self, method, url, headers=None, content=None, timeout=None):
|
|
key = url.removeprefix("https://dav.example/media/")
|
|
if method == "PROPFIND":
|
|
return SimpleNamespace(status_code=207 if key in self.files else 404)
|
|
assert method == "MOVE"
|
|
assert timeout == storage._REMOTE_MOVE_TIMEOUT_SECONDS
|
|
self.moves += 1
|
|
outcome = next(self.outcomes)
|
|
if outcome in {"success", "moved-500"}:
|
|
self.files.discard(key)
|
|
destination = headers["Destination"].removeprefix("https://dav.example/media/")
|
|
self.files.add(destination)
|
|
return SimpleNamespace(status_code=201 if outcome == "success" else 500)
|
|
|
|
class Connector:
|
|
base_url = "https://dav.example/media/"
|
|
|
|
def __init__(self, client):
|
|
self.client = client
|
|
|
|
def url_for(self, key):
|
|
return self.base_url + key
|
|
|
|
false_failure = Client(["moved-500"])
|
|
storage._remote_move(
|
|
Connector(false_failure),
|
|
"source.mp4",
|
|
"trash/source.mp4",
|
|
overwrite=False,
|
|
failure_label="远程移动失败",
|
|
)
|
|
assert false_failure.moves == 1
|
|
assert false_failure.files == {"trash/source.mp4"}
|
|
|
|
retried = Client(["failed", "success"])
|
|
storage._remote_move(
|
|
Connector(retried),
|
|
"source.mp4",
|
|
"trash/source.mp4",
|
|
overwrite=False,
|
|
failure_label="远程移动失败",
|
|
)
|
|
assert retried.moves == 2
|
|
assert retried.files == {"trash/source.mp4"}
|
|
|
|
failed = Client(["failed"] * storage._REMOTE_MOVE_ATTEMPTS)
|
|
with pytest.raises(RuntimeError, match="源文件仍存在,目标文件不存在"):
|
|
storage._remote_move(
|
|
Connector(failed),
|
|
"source.mp4",
|
|
"trash/source.mp4",
|
|
overwrite=False,
|
|
failure_label="远程移动失败",
|
|
)
|
|
assert failed.moves == storage._REMOTE_MOVE_ATTEMPTS
|
|
assert failed.files == {"source.mp4"}
|
|
|
|
|
|
def test_remote_move_timeout_reconciles_completed_destination(monkeypatch):
|
|
storage = object.__new__(StorageService)
|
|
monkeypatch.setattr(storage, "_REMOTE_MOVE_RETRY_DELAYS", (0, 0, 0))
|
|
|
|
class Client:
|
|
def __init__(self):
|
|
self.files = {"source.mp4"}
|
|
|
|
def request(self, method, url, headers=None, content=None, timeout=None):
|
|
key = url.removeprefix("https://dav.example/media/")
|
|
if method == "PROPFIND":
|
|
return SimpleNamespace(status_code=207 if key in self.files else 404)
|
|
assert method == "MOVE"
|
|
assert timeout == storage._REMOTE_MOVE_TIMEOUT_SECONDS
|
|
self.files.remove(key)
|
|
self.files.add(headers["Destination"].removeprefix("https://dav.example/media/"))
|
|
raise TimeoutError("response timeout")
|
|
|
|
class Connector:
|
|
base_url = "https://dav.example/media/"
|
|
|
|
def __init__(self):
|
|
self.client = Client()
|
|
|
|
def url_for(self, key):
|
|
return self.base_url + key
|
|
|
|
connector = Connector()
|
|
storage._remote_move(
|
|
connector,
|
|
"source.mp4",
|
|
"trash/source.mp4",
|
|
overwrite=False,
|
|
failure_label="远程移动失败",
|
|
)
|
|
assert connector.client.files == {"trash/source.mp4"}
|
|
|
|
|
|
def test_remote_request_wait_can_be_cancelled_promptly(monkeypatch):
|
|
storage = object.__new__(StorageService)
|
|
monkeypatch.setattr(storage, "_REMOTE_CANCEL_POLL_SECONDS", 0.001)
|
|
started = threading.Event()
|
|
closed = threading.Event()
|
|
|
|
def request():
|
|
started.set()
|
|
closed.wait(2)
|
|
return SimpleNamespace(status_code=201)
|
|
|
|
with pytest.raises(TransferCancelled, match="已取消"):
|
|
storage._run_remote_request(request, closed.set, started.is_set)
|
|
assert closed.is_set()
|
|
|
|
|
|
def test_alist_stream_upload_forwards_existing_sha256_without_rehashing():
|
|
captured: dict[str, object] = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["method"] = request.method
|
|
captured["path"] = request.url.path
|
|
captured["headers"] = dict(request.headers)
|
|
captured["body"] = request.read()
|
|
return httpx.Response(200, json={"code": 200, "message": "success", "data": {}})
|
|
|
|
client = AlistClient(
|
|
"https://openlist.example",
|
|
"admin",
|
|
"secret",
|
|
root_path="云盘/测试",
|
|
)
|
|
client.client.close()
|
|
client.client = httpx.Client(transport=httpx.MockTransport(handler))
|
|
client._token = "private-token"
|
|
try:
|
|
client.put_stream(
|
|
"临时/movie.part",
|
|
iter((b"video-", b"payload")),
|
|
size_bytes=13,
|
|
content_sha256="a" * 64,
|
|
timeout=30,
|
|
)
|
|
finally:
|
|
client.close()
|
|
|
|
headers = captured["headers"]
|
|
assert captured["method"] == "PUT"
|
|
assert captured["path"] == "/api/fs/put"
|
|
assert captured["body"] == b"video-payload"
|
|
assert headers["authorization"] == "private-token"
|
|
assert headers["file-path"] == "/%E4%BA%91%E7%9B%98/%E6%B5%8B%E8%AF%95/%E4%B8%B4%E6%97%B6/movie.part"
|
|
assert headers["x-file-sha256"] == "a" * 64
|
|
assert headers["as-task"] == "false"
|
|
|
|
|
|
def test_direct_alist_storage_uses_hash_aware_api_then_atomic_move(tmp_path: Path, monkeypatch):
|
|
source = {
|
|
"id": "alist",
|
|
"kind": "webdav",
|
|
"config": {
|
|
"driver": "alist",
|
|
"mode": "direct",
|
|
"base_url": "https://openlist.example",
|
|
"root_path": "media",
|
|
"username": "admin",
|
|
"verify_tls": True,
|
|
"writable": True,
|
|
},
|
|
"secrets": {"password": "secret"},
|
|
}
|
|
|
|
class WebClient:
|
|
def __init__(self):
|
|
self.moves = []
|
|
|
|
def request(self, method, url, headers=None, content=None, timeout=None):
|
|
if method == "PROPFIND":
|
|
return SimpleNamespace(status_code=404)
|
|
if method == "MKCOL":
|
|
return SimpleNamespace(status_code=201)
|
|
assert method == "MOVE"
|
|
self.moves.append((url, headers, timeout))
|
|
return SimpleNamespace(status_code=201)
|
|
|
|
def delete(self, _url):
|
|
return SimpleNamespace(status_code=204)
|
|
|
|
class Connector:
|
|
base_url = "https://openlist.example/dav/media/"
|
|
|
|
def __init__(self):
|
|
self.client = WebClient()
|
|
self.closed = False
|
|
|
|
def url_for(self, key):
|
|
return self.base_url + key
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
connector = Connector()
|
|
|
|
class Sources:
|
|
def get(self, _source_id):
|
|
return source
|
|
|
|
def connector(self, _source_id):
|
|
return connector
|
|
|
|
controls = []
|
|
|
|
class Control:
|
|
def __init__(self, *_args, **kwargs):
|
|
self.kwargs = kwargs
|
|
self.uploads = []
|
|
self.closed = False
|
|
controls.append(self)
|
|
|
|
def put_stream(self, path, content, **kwargs):
|
|
self.uploads.append((path, b"".join(content), kwargs))
|
|
return {}
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
monkeypatch.setattr(storage_module, "AlistClient", Control)
|
|
storage = StorageService(None, SimpleNamespace(remote_timeout_seconds=30), Sources())
|
|
payload = tmp_path / "payload.part"
|
|
payload.write_bytes(b"remote-data")
|
|
key = storage.write_file(
|
|
"alist",
|
|
"imports",
|
|
"movie.mp4",
|
|
payload,
|
|
conflict="replace",
|
|
operation_id="upload-id",
|
|
content_sha256="b" * 64,
|
|
)
|
|
|
|
assert key == "imports/movie.mp4"
|
|
assert len(controls) == 1
|
|
temporary = "imports/.movie.mp4.imagefind-upload-id.part"
|
|
assert controls[0].uploads[0][0] == temporary
|
|
assert controls[0].uploads[0][1] == b"remote-data"
|
|
assert controls[0].uploads[0][2]["content_sha256"] == "b" * 64
|
|
assert connector.client.moves[0][0].endswith(temporary)
|
|
assert connector.client.moves[0][1]["Destination"].endswith("imports/movie.mp4")
|
|
assert controls[0].closed is True
|
|
assert connector.closed is True
|
|
|
|
|
|
def test_generic_openlist_webdav_source_is_probed_before_hash_aware_upload(monkeypatch):
|
|
source = {
|
|
"id": "generic-openlist",
|
|
"kind": "webdav",
|
|
"config": {
|
|
"base_url": "http://openlist.example:5244/dav/yidongpan/test%20space/",
|
|
"username": "admin",
|
|
"verify_tls": False,
|
|
"writable": True,
|
|
},
|
|
"secrets": {"password": "secret"},
|
|
}
|
|
controls = []
|
|
|
|
class Control:
|
|
def __init__(self, base_url, username, password, **kwargs):
|
|
self.base_url = base_url
|
|
self.username = username
|
|
self.password = password
|
|
self.kwargs = kwargs
|
|
self.probes = 0
|
|
self.closed = False
|
|
controls.append(self)
|
|
|
|
def probe(self):
|
|
self.probes += 1
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
monkeypatch.setattr(storage_module, "AlistClient", Control)
|
|
storage = StorageService(
|
|
None,
|
|
SimpleNamespace(remote_timeout_seconds=30),
|
|
SimpleNamespace(),
|
|
)
|
|
first = storage._openlist_upload_client(source["id"], source)
|
|
second = storage._openlist_upload_client(source["id"], source)
|
|
|
|
assert first is controls[0]
|
|
assert second is controls[1]
|
|
assert controls[0].base_url == "http://openlist.example:5244"
|
|
assert controls[0].kwargs["root_path"] == "yidongpan/test space"
|
|
assert controls[0].probes == 1
|
|
assert controls[1].probes == 0
|
|
|
|
|
|
def test_non_openlist_or_failed_probe_keeps_standard_webdav(monkeypatch):
|
|
storage = StorageService(
|
|
None,
|
|
SimpleNamespace(remote_timeout_seconds=30),
|
|
SimpleNamespace(),
|
|
)
|
|
assert openlist_endpoint_from_webdav_url("https://dav.example/remote/files") is None
|
|
|
|
source = {
|
|
"config": {
|
|
"base_url": "https://dav.example/dav/media/",
|
|
"username": "user",
|
|
"verify_tls": True,
|
|
},
|
|
"secrets": {"password": "secret"},
|
|
}
|
|
|
|
class Control:
|
|
def __init__(self, *_args, **_kwargs):
|
|
self.closed = False
|
|
|
|
def probe(self):
|
|
raise RuntimeError("not OpenList")
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
monkeypatch.setattr(storage_module, "AlistClient", Control)
|
|
assert storage._openlist_upload_client("ordinary", source) is None
|
|
|
|
|
|
def test_upload_transfer_progress_uses_bounded_database_checkpoints(monkeypatch):
|
|
class Connection:
|
|
def __init__(self):
|
|
self.executions = []
|
|
|
|
def execute(self, sql, args):
|
|
self.executions.append((sql, args))
|
|
|
|
class Database:
|
|
def __init__(self):
|
|
self.transactions = []
|
|
|
|
@contextmanager
|
|
def transaction(self):
|
|
connection = Connection()
|
|
self.transactions.append(connection)
|
|
yield connection
|
|
|
|
def write_with_retry(self, operation, *, timeout_seconds):
|
|
assert timeout_seconds == 2
|
|
with self.transaction() as connection:
|
|
return operation(connection)
|
|
|
|
class Jobs:
|
|
def __init__(self):
|
|
self.checkpoints = []
|
|
|
|
def checkpoint(self, job_id, *, persist):
|
|
self.checkpoints.append((job_id, persist))
|
|
|
|
service = object.__new__(UploadService)
|
|
service.db = Database()
|
|
service.jobs = Jobs()
|
|
service._progress_guard = threading.Lock()
|
|
service._progress_state = {}
|
|
clock = iter((100.0, 105.0, 106.0, 107.0))
|
|
monkeypatch.setattr(uploads_module.time, "monotonic", lambda: next(clock))
|
|
total = 512 * 1024**2
|
|
|
|
service._progress("upload", "job", 1, total)
|
|
service._progress("upload", "job", 100 * 1024**2, total)
|
|
service._progress("upload", "job", 300 * 1024**2, total)
|
|
service._progress("upload", "job", total, total)
|
|
|
|
assert len(service.db.transactions) == 3
|
|
assert service.jobs.checkpoints == [("job", False)] * 3
|
|
final_upload_args = service.db.transactions[-1].executions[0][1]
|
|
assert final_upload_args[0] == 0.99
|
|
assert final_upload_args[2] == "数据已发送,等待目标库确认"
|
|
|
|
|
|
def test_trash_layout_preserves_basename_and_remote_restore_avoids_rename():
|
|
storage = object.__new__(StorageService)
|
|
trash_id = "5dc19861-4aa4-4dc7-9149-73395b52ebea"
|
|
trash_key = storage._trash_key(trash_id, "课程/第一节.mp4")
|
|
assert trash_key == f".imagefind-trash/{trash_id}/第一节.mp4"
|
|
assert storage._trash_container_key(trash_key) == f".imagefind-trash/{trash_id}"
|
|
assert storage._trash_container_key(f".imagefind-trash/{trash_id}-第一节.mp4") is None
|
|
|
|
class Client:
|
|
def __init__(self, files):
|
|
self.files = set(files)
|
|
|
|
def request(self, method, url, headers=None, content=None):
|
|
assert method == "PROPFIND"
|
|
key = url.removeprefix("https://dav.example/media/")
|
|
return SimpleNamespace(status_code=207 if key in self.files else 404)
|
|
|
|
class Connector:
|
|
base_url = "https://dav.example/media/"
|
|
|
|
def __init__(self, files):
|
|
self.client = Client(files)
|
|
|
|
def url_for(self, key):
|
|
return self.base_url + key
|
|
|
|
item = {"id": trash_id, "original_key": "课程/第一节.mp4"}
|
|
assert storage._remote_restore_target(Connector(set()), item) == "课程/第一节.mp4"
|
|
assert storage._remote_restore_target(
|
|
Connector({"课程/第一节.mp4", f"课程/已恢复-{trash_id}/第一节.mp4"}),
|
|
item,
|
|
) == f"课程/已恢复-{trash_id}-2/第一节.mp4"
|
|
|
|
|
|
def test_local_trash_restore_and_path_protection(tmp_path: Path):
|
|
app, media, source_id, _ = _app(tmp_path)
|
|
target = media / "folder" / "movie.mp4"
|
|
target.parent.mkdir()
|
|
target.write_bytes(b"movie")
|
|
trash_id = app.state.services.storage.trash(source_id, "folder/movie.mp4")
|
|
assert not target.exists()
|
|
restored = app.state.services.storage.restore(trash_id)
|
|
assert restored == "folder/movie.mp4"
|
|
assert target.read_bytes() == b"movie"
|
|
try:
|
|
app.state.services.storage.list_dir(source_id, "../")
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
raise AssertionError("directory traversal must be rejected")
|
|
|
|
|
|
def test_alist_direct_stream_returns_redirect(tmp_path: Path):
|
|
app, _, _, headers = _app(tmp_path)
|
|
service = app.state.services
|
|
now = utcnow()
|
|
with service.db.transaction() as conn:
|
|
conn.execute(
|
|
"INSERT INTO sources(id,kind,name,config_json,secret_blob,created_at,updated_at) VALUES(?,?,?,?,?,?,?)",
|
|
(
|
|
"alist-source",
|
|
"webdav",
|
|
"AList 直连",
|
|
json.dumps(
|
|
{
|
|
"driver": "alist",
|
|
"mode": "direct",
|
|
"base_url": "https://alist.example",
|
|
"root_path": "private",
|
|
"username": "user",
|
|
}
|
|
),
|
|
service.secrets.encrypt_json({"password": "password"}),
|
|
now,
|
|
now,
|
|
),
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO videos(id,source_id,source_key,display_name,location,fingerprint,status,available,"
|
|
"created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)",
|
|
(
|
|
"alist-video",
|
|
"alist-source",
|
|
"movie.mp4",
|
|
"movie.mp4",
|
|
"https://alist.example/dav/private/movie.mp4",
|
|
"fingerprint",
|
|
"indexed",
|
|
1,
|
|
now,
|
|
now,
|
|
),
|
|
)
|
|
service.storage.direct_url = lambda source_id, key: "https://provider.example/signed/movie.mp4"
|
|
|
|
async def scenario():
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test", follow_redirects=False) as client:
|
|
response = await client.get("/api/v1/videos/alist-video/stream", headers=headers)
|
|
assert response.status_code == 302
|
|
assert response.headers["location"] == "https://provider.example/signed/movie.mp4"
|
|
assert response.headers["cache-control"] == "no-store"
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_alist_recovery_import_api(tmp_path: Path):
|
|
app, _, _, headers = _app(tmp_path)
|
|
captured = {}
|
|
|
|
def restore_alist(name, base_url, root_path, username, password, crypt_password, crypt_salt, *, verify_tls):
|
|
captured.update(
|
|
name=name,
|
|
base_url=base_url,
|
|
root_path=root_path,
|
|
username=username,
|
|
password=password,
|
|
crypt_password=crypt_password,
|
|
crypt_salt=crypt_salt,
|
|
verify_tls=verify_tls,
|
|
)
|
|
return "restored-source"
|
|
|
|
app.state.services.sources.restore_alist = restore_alist
|
|
|
|
async def scenario():
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.post(
|
|
"/api/v1/sources/alist/restore",
|
|
headers=headers,
|
|
json={
|
|
"name": "恢复的保险库",
|
|
"password": "new-alist-password",
|
|
"verify_tls": False,
|
|
"recovery": {
|
|
"format": "imagefind-rclone-crypt-v1",
|
|
"base_url": "https://alist.example",
|
|
"root_path": "private/videos",
|
|
"username": "admin",
|
|
"crypt_password": "crypt-secret",
|
|
"crypt_salt": "crypt-salt",
|
|
},
|
|
},
|
|
)
|
|
assert response.status_code == 201
|
|
assert response.json()["id"] == "restored-source"
|
|
assert captured == {
|
|
"name": "恢复的保险库",
|
|
"base_url": "https://alist.example/",
|
|
"root_path": "private/videos",
|
|
"username": "admin",
|
|
"password": "new-alist-password",
|
|
"crypt_password": "crypt-secret",
|
|
"crypt_salt": "crypt-salt",
|
|
"verify_tls": False,
|
|
}
|
|
|
|
invalid = await client.post(
|
|
"/api/v1/sources/alist/restore",
|
|
headers=headers,
|
|
json={"password": "x", "recovery": {"format": "unknown"}},
|
|
)
|
|
assert invalid.status_code == 422
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_single_worker_claims_heavy_jobs_in_queue_order(tmp_path: Path):
|
|
app, _, source_id, _ = _app(tmp_path)
|
|
jobs = app.state.services.jobs
|
|
transfer_id = jobs.enqueue("transfer_upload", {"upload_id": "one"})
|
|
scan_id = jobs.enqueue("scan_source", {"source_id": source_id})
|
|
|
|
# Claim selection is tested independently of host resource pressure; the
|
|
# production worker still consults the governor before every claim.
|
|
jobs.governor = None
|
|
transfer = jobs._claim()
|
|
general = jobs._claim()
|
|
|
|
assert transfer and transfer[0] == transfer_id
|
|
assert general and general[0] == scan_id
|