325 lines
11 KiB
Python
325 lines
11 KiB
Python
"""Tests for U6: attachment & image field upload, download, and cleanup.
|
|
|
|
Requires PostgreSQL — marked ``postgres``. Uses ``httpx.AsyncClient`` with
|
|
``ASGITransport`` (same pattern as test_routes.py).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from httpx import ASGITransport
|
|
|
|
from agentkit.bitable.service import BitableService
|
|
from agentkit.server.routes import bitable as bitable_routes
|
|
from agentkit.server.routes.bitable import require_bitable_auth
|
|
|
|
pytestmark = pytest.mark.postgres
|
|
|
|
TEST_USER_ID = "test-user-id"
|
|
|
|
|
|
def _make_test_user() -> dict[str, Any]:
|
|
return {"user_id": TEST_USER_ID, "username": "testuser", "role": "member"}
|
|
|
|
|
|
@pytest.fixture
|
|
def app(
|
|
bitable_service: BitableService, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> FastAPI:
|
|
"""Test app with upload dir redirected to tmp_path."""
|
|
upload_dir = tmp_path / "bitable_uploads"
|
|
# Patch both the routes module variable AND the env var (service reads env var)
|
|
monkeypatch.setattr(bitable_routes, "BITABLE_UPLOAD_DIR", upload_dir)
|
|
monkeypatch.setenv("AGENTKIT_BITABLE_UPLOAD_DIR", str(upload_dir))
|
|
|
|
app = FastAPI()
|
|
app.state.bitable_service = bitable_service
|
|
app.include_router(bitable_routes.router, prefix="/api/v1")
|
|
app.dependency_overrides[require_bitable_auth] = lambda: _make_test_user()
|
|
|
|
return app
|
|
|
|
|
|
@pytest.fixture
|
|
async def client(app: FastAPI) -> httpx.AsyncClient:
|
|
transport = ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
|
yield c
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _create_table_with_field(
|
|
client: httpx.AsyncClient,
|
|
field_type: str,
|
|
field_name: str = "files",
|
|
) -> tuple[str, str]:
|
|
"""Create a table + a field, return (table_id, field_id)."""
|
|
table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
|
|
"id"
|
|
]
|
|
field_id = (
|
|
await client.post(
|
|
f"/api/v1/bitable/tables/{table_id}/fields",
|
|
json={"name": field_name, "field_type": field_type, "owner": "agent"},
|
|
)
|
|
).json()["field"]["id"]
|
|
return table_id, field_id
|
|
|
|
|
|
def _make_image_bytes(name: str = "test.png", size: int = 100) -> tuple[bytes, str]:
|
|
"""Minimal valid PNG header + padding."""
|
|
png_header = b"\x89PNG\r\n\x1a\n"
|
|
body = b"\x00" * size
|
|
return png_header + body, name
|
|
|
|
|
|
def _make_pdf_bytes(name: str = "doc.pdf", size: int = 50) -> tuple[bytes, str]:
|
|
return b"%PDF-1.4\n" + b"\x00" * size, name
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Upload tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_upload_image_success(client: httpx.AsyncClient, tmp_path: Path) -> None:
|
|
table_id, field_id = await _create_table_with_field(client, "image")
|
|
img_bytes, img_name = _make_image_bytes()
|
|
resp = await client.post(
|
|
f"/api/v1/bitable/tables/{table_id}/upload",
|
|
params={"field_id": field_id},
|
|
files={"file": (img_name, io.BytesIO(img_bytes), "image/png")},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["filename"] == img_name
|
|
assert data["mime_type"] == "image/png"
|
|
assert data["size"] == len(img_bytes)
|
|
assert data["stored_name"].endswith(".png")
|
|
assert data["url"].startswith("/api/v1/bitable/files/")
|
|
# File exists on disk
|
|
file_path = bitable_routes.BITABLE_UPLOAD_DIR / data["stored_name"]
|
|
assert file_path.exists()
|
|
assert file_path.read_bytes() == img_bytes
|
|
|
|
|
|
async def test_upload_attachment_pdf(client: httpx.AsyncClient) -> None:
|
|
table_id, field_id = await _create_table_with_field(client, "attachment")
|
|
pdf_bytes, pdf_name = _make_pdf_bytes()
|
|
resp = await client.post(
|
|
f"/api/v1/bitable/tables/{table_id}/upload",
|
|
params={"field_id": field_id},
|
|
files={"file": (pdf_name, io.BytesIO(pdf_bytes), "application/pdf")},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["filename"] == pdf_name
|
|
assert data["mime_type"] == "application/pdf"
|
|
|
|
|
|
async def test_upload_image_rejects_non_image(client: httpx.AsyncClient) -> None:
|
|
table_id, field_id = await _create_table_with_field(client, "image")
|
|
pdf_bytes, _ = _make_pdf_bytes()
|
|
resp = await client.post(
|
|
f"/api/v1/bitable/tables/{table_id}/upload",
|
|
params={"field_id": field_id},
|
|
files={"file": ("doc.pdf", io.BytesIO(pdf_bytes), "application/pdf")},
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "image" in resp.json()["detail"].lower()
|
|
|
|
|
|
async def test_upload_rejects_non_attachment_field(client: httpx.AsyncClient) -> None:
|
|
table_id, field_id = await _create_table_with_field(client, "text")
|
|
img_bytes, _ = _make_image_bytes()
|
|
resp = await client.post(
|
|
f"/api/v1/bitable/tables/{table_id}/upload",
|
|
params={"field_id": field_id},
|
|
files={"file": ("test.png", io.BytesIO(img_bytes), "image/png")},
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
async def test_upload_404_unknown_field(client: httpx.AsyncClient) -> None:
|
|
table_id = (await client.post("/api/v1/bitable/tables", json={"name": "T"})).json()["table"][
|
|
"id"
|
|
]
|
|
img_bytes, _ = _make_image_bytes()
|
|
resp = await client.post(
|
|
f"/api/v1/bitable/tables/{table_id}/upload",
|
|
params={"field_id": "nonexistent"},
|
|
files={"file": ("test.png", io.BytesIO(img_bytes), "image/png")},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
|
|
async def test_upload_requires_auth(bitable_service: BitableService) -> None:
|
|
"""No auth override → 401."""
|
|
app = FastAPI()
|
|
app.state.bitable_service = bitable_service
|
|
app.include_router(bitable_routes.router, prefix="/api/v1")
|
|
transport = ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
|
resp = await c.post(
|
|
"/api/v1/bitable/tables/x/upload",
|
|
params={"field_id": "y"},
|
|
files={"file": ("t.png", io.BytesIO(b"x"), "image/png")},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Download tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_download_file_success(client: httpx.AsyncClient) -> None:
|
|
table_id, field_id = await _create_table_with_field(client, "image")
|
|
img_bytes, img_name = _make_image_bytes()
|
|
upload_resp = await client.post(
|
|
f"/api/v1/bitable/tables/{table_id}/upload",
|
|
params={"field_id": field_id},
|
|
files={"file": (img_name, io.BytesIO(img_bytes), "image/png")},
|
|
)
|
|
stored_name = upload_resp.json()["stored_name"]
|
|
resp = await client.get(f"/api/v1/bitable/files/{stored_name}")
|
|
assert resp.status_code == 200
|
|
assert resp.content == img_bytes
|
|
|
|
|
|
async def test_download_404_missing_file(client: httpx.AsyncClient) -> None:
|
|
resp = await client.get("/api/v1/bitable/files/nonexistent.png")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Attachment cleanup on record deletion
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_delete_record_cleans_up_files(
|
|
client: httpx.AsyncClient,
|
|
bitable_service: BitableService,
|
|
) -> None:
|
|
table_id, field_id = await _create_table_with_field(client, "image")
|
|
img_bytes, _ = _make_image_bytes()
|
|
upload_resp = await client.post(
|
|
f"/api/v1/bitable/tables/{table_id}/upload",
|
|
params={"field_id": field_id},
|
|
files={"file": ("pic.png", io.BytesIO(img_bytes), "image/png")},
|
|
)
|
|
file_meta = upload_resp.json()
|
|
stored_name = file_meta["stored_name"]
|
|
|
|
# Create a record with the image metadata
|
|
create_resp = await client.post(
|
|
f"/api/v1/bitable/tables/{table_id}/records",
|
|
json={"records": [{field_id: [file_meta]}]},
|
|
)
|
|
record_id = create_resp.json()["records"][0]["id"]
|
|
|
|
# Verify file exists
|
|
file_path = bitable_routes.BITABLE_UPLOAD_DIR / stored_name
|
|
assert file_path.exists()
|
|
|
|
# Delete the record
|
|
del_resp = await client.delete(f"/api/v1/bitable/records/{record_id}")
|
|
assert del_resp.status_code == 200
|
|
|
|
# File should be gone
|
|
assert not file_path.exists()
|
|
|
|
|
|
async def test_delete_records_by_table_cleans_up_files(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
table_id, field_id = await _create_table_with_field(client, "attachment")
|
|
pdf_bytes, _ = _make_pdf_bytes()
|
|
upload_resp = await client.post(
|
|
f"/api/v1/bitable/tables/{table_id}/upload",
|
|
params={"field_id": field_id},
|
|
files={"file": ("doc.pdf", io.BytesIO(pdf_bytes), "application/pdf")},
|
|
)
|
|
file_meta = upload_resp.json()
|
|
stored_name = file_meta["stored_name"]
|
|
|
|
await client.post(
|
|
f"/api/v1/bitable/tables/{table_id}/records",
|
|
json={"records": [{field_id: [file_meta]}]},
|
|
)
|
|
|
|
file_path = bitable_routes.BITABLE_UPLOAD_DIR / stored_name
|
|
assert file_path.exists()
|
|
|
|
# Delete all records
|
|
resp = await client.delete(f"/api/v1/bitable/tables/{table_id}/records")
|
|
assert resp.status_code == 200
|
|
assert not file_path.exists()
|
|
|
|
|
|
async def test_delete_record_when_file_already_missing(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""Record deletion should succeed even if the physical file is gone."""
|
|
table_id, field_id = await _create_table_with_field(client, "image")
|
|
img_bytes, _ = _make_image_bytes()
|
|
upload_resp = await client.post(
|
|
f"/api/v1/bitable/tables/{table_id}/upload",
|
|
params={"field_id": field_id},
|
|
files={"file": ("pic.png", io.BytesIO(img_bytes), "image/png")},
|
|
)
|
|
file_meta = upload_resp.json()
|
|
stored_name = file_meta["stored_name"]
|
|
|
|
create_resp = await client.post(
|
|
f"/api/v1/bitable/tables/{table_id}/records",
|
|
json={"records": [{field_id: [file_meta]}]},
|
|
)
|
|
record_id = create_resp.json()["records"][0]["id"]
|
|
|
|
# Manually delete the file before deleting the record
|
|
file_path = bitable_routes.BITABLE_UPLOAD_DIR / stored_name
|
|
file_path.unlink()
|
|
assert not file_path.exists()
|
|
|
|
# Record deletion should still succeed
|
|
del_resp = await client.delete(f"/api/v1/bitable/records/{record_id}")
|
|
assert del_resp.status_code == 200
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Multiple files in one field
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_multiple_files_in_attachment_field(client: httpx.AsyncClient) -> None:
|
|
table_id, field_id = await _create_table_with_field(client, "attachment")
|
|
metas = []
|
|
for name in ("a.pdf", "b.pdf"):
|
|
pdf_bytes, _ = _make_pdf_bytes(name)
|
|
resp = await client.post(
|
|
f"/api/v1/bitable/tables/{table_id}/upload",
|
|
params={"field_id": field_id},
|
|
files={"file": (name, io.BytesIO(pdf_bytes), "application/pdf")},
|
|
)
|
|
metas.append(resp.json())
|
|
|
|
# Store all files as an array in one record
|
|
create_resp = await client.post(
|
|
f"/api/v1/bitable/tables/{table_id}/records",
|
|
json={"records": [{field_id: metas}]},
|
|
)
|
|
assert create_resp.status_code == 201
|
|
record = create_resp.json()["records"][0]
|
|
assert len(record["values"][field_id]) == 2
|