diff --git a/docs/source/store-and-retrieve/object-store.md b/docs/source/store-and-retrieve/object-store.md index 6eb0439f04..b2833c7bd6 100644 --- a/docs/source/store-and-retrieve/object-store.md +++ b/docs/source/store-and-retrieve/object-store.md @@ -38,13 +38,27 @@ class ObjectStoreItem: metadata: dict[str, str] | None # Custom key-value metadata (optional) ``` +### ObjectStoreListItem +The `ObjectStoreListItem` model represents only an object's metadata. +```python +class ObjectStoreListItem: + key: str # The object's unique key + size: int # Size in bytes + content_type: str | None # MIME type (e.g., "video/mp4") + metadata: dict[str, str] | None # Custom metadata + last_modified: datetime | None # Last modification timestamp +``` + +Note: Unlike `ObjectStoreItem`, this model does not include the `data` field, making listings fast and memory-efficient. + ### ObjectStore Interface -The `ObjectStore` abstract interface defines the four standard operations: +The `ObjectStore` abstract interface defines the five standard operations: - **put_object(key, item)**: Store a new object with a unique key. Raises if the key already exists. - **upsert_object(key, item)**: Update (or inserts) an object with the given key. - **get_object(key)**: Retrieve an object by its key. Raises if the key doesn't exist. - **delete_object(key)**: Remove an object from the store. Raises if the key doesn't exist. +- **list_objects(prefix)**: List objects in the store, optionally filtered by key prefix. ```python class ObjectStore(ABC): @@ -63,6 +77,10 @@ class ObjectStore(ABC): @abstractmethod async def delete_object(self, key: str) -> None: ... + + @abstractmethod + async def list_objects(self, prefix: str | None = None) -> list["ObjectStoreListItem"]: + ... ``` ## Included Object Stores @@ -152,10 +170,20 @@ async def my_function(config: MyFunctionConfig, builder: Builder): retrieved_item = await object_store.get_object("greeting.txt") print(retrieved_item.data.decode("utf-8")) + # List objects with optional prefix filtering + all_objects = await object_store.list_objects() + for obj in all_objects: + print(f"{obj.key}: {obj.size} bytes, {obj.content_type}") + + # List only objects with specific prefix + greetings = await object_store.list_objects(prefix="greeting") + # Delete an object await object_store.delete_object("greeting.txt") ``` +The `list_objects()` method returns metadata for stored objects without downloading their content. This is efficient for building file browsers, galleries, or managing large files. The optional `prefix` parameter filters objects by key prefix, similar to listing files in a directory. + ### File Server Integration By adding the `object_store` field in the `general.front_end` block of the configuration, clients can directly download and upload files to the connected object store: @@ -194,10 +222,67 @@ This enables HTTP endpoints for object store operations: $ curl -X DELETE http://localhost:9000/static/folder/data.txt ``` +### Video Upload Integration + +When `object_store` is configured in the FastAPI front end, the UI also exposes video upload endpoints. Uploaded videos are stored with the `videos/` prefix and can be accessed from your workflow functions using the same ObjectStore instance. + +```yaml +general: + front_end: + object_store: my_video_store # Enables video routes + _type: fastapi + +object_stores: + my_video_store: + _type: s3 + endpoint_url: http://localhost:9000 + access_key: minioadmin + secret_key: minioadmin + bucket_name: my-video-bucket + +functions: + my_video_function: + _type: my_video_processor + object_store: my_video_store # Same store as frontend +``` + +This enables HTTP endpoints for object store operations: + +- **GET** `/videos` - List uploaded videos + ```console + $ curl -X GET http://localhost:8000/videos + ``` +- **POST** `/videos` - Upload a new video + ```console + $ curl -X POST -F "file=@my_video.mp4;type=video/mp4" http://localhost:8000/videos + ``` +- **DELETE** `/videos/{video_key}` - Delete a video + ```console + $ curl -X DELETE http://localhost:8000/videos/videos_12345.mp4 + ``` + +Your workflow functions can access uploaded videos using `list_objects(prefix="videos/")` and `get_object(video_key)` as shown in the usage examples above. + ## Examples The following examples demonstrate how to use the object store module in the NeMo Agent toolkit: * `examples/object_store/user_report` - A complete workflow that stores and retrieves user diagnostic reports using different object store backends +## Running Tests + +Run these from the repository root after installing dependencies: + +```bash +# In-memory object store unit tests +pytest tests/nat/object_store/test_in_memory_object_store.py -v + +# FastAPI video upload routes +pytest tests/nat/front_ends/fastapi/test_video_upload_routes.py -v + +# S3 provider integration tests (requires MinIO or S3 running and S3 plugin installed) +# Install S3 plugin first: uv pip install -e packages/nvidia_nat_s3 +pytest packages/nvidia_nat_s3/tests/test_s3_object_store.py --run_integration -v +``` + ## Error Handling Object stores may raise specific exceptions: - **KeyAlreadyExistsError**: When trying to store an object with a key that already exists (for `put_object`) diff --git a/external/nat-ui b/external/nat-ui index 8b91a7e80e..04efa5b129 160000 --- a/external/nat-ui +++ b/external/nat-ui @@ -1 +1 @@ -Subproject commit 8b91a7e80edf3c96971e49246f2fd42b30f2c412 +Subproject commit 04efa5b129b962decf56238d3bde3f66e907d2ab diff --git a/packages/nvidia_nat_s3/src/nat/plugins/s3/s3_object_store.py b/packages/nvidia_nat_s3/src/nat/plugins/s3/s3_object_store.py index 7a869389f0..183b5f2b2e 100644 --- a/packages/nvidia_nat_s3/src/nat/plugins/s3/s3_object_store.py +++ b/packages/nvidia_nat_s3/src/nat/plugins/s3/s3_object_store.py @@ -17,12 +17,14 @@ import aioboto3 from botocore.client import BaseClient +from botocore.client import Config from botocore.exceptions import ClientError from nat.data_models.object_store import KeyAlreadyExistsError from nat.data_models.object_store import NoSuchKeyError from nat.object_store.interfaces import ObjectStore from nat.object_store.models import ObjectStoreItem +from nat.object_store.models import ObjectStoreListItem logger = logging.getLogger(__name__) @@ -55,6 +57,10 @@ def __init__(self, self._client_args["region_name"] = region if endpoint_url: self._client_args["endpoint_url"] = endpoint_url + # Use path-style addressing for non-AWS endpoints (MinIO, etc.) to avoid + # DNS-based virtual host lookups that fail for local endpoints + if 'amazonaws.com' not in endpoint_url.lower(): + self._client_args["config"] = Config(s3={'addressing_style': 'path'}) async def __aenter__(self) -> "S3ObjectStore": @@ -164,3 +170,52 @@ async def delete_object(self, key: str) -> None: if results.get('DeleteMarker', False): raise NoSuchKeyError(key=key, additional_message="Object was a delete marker") + + async def list_objects(self, prefix: str | None = None) -> list[ObjectStoreListItem]: + """ + List objects in the S3 bucket, optionally filtered by key prefix. + """ + if self._client is None: + raise RuntimeError("Connection not established") + + objects = [] + + try: + paginator = self._client.get_paginator('list_objects_v2') + + pagination_args = {"Bucket": self.bucket_name} + if prefix is not None: + pagination_args["Prefix"] = prefix + + async for page in paginator.paginate(**pagination_args): + + if 'Contents' not in page: + continue + + for obj in page['Contents']: + key = obj['Key'] + + if key.endswith('/'): + continue + + try: + head_response = await self._client.head_object(Bucket=self.bucket_name, Key=key) + content_type = head_response.get('ContentType') + metadata = head_response.get('Metadata', {}) + except ClientError as e: + logger.warning(f"Failed to get metadata for {key}: {e}") + content_type = None + metadata = {} + + objects.append( + ObjectStoreListItem(key=key, + size=obj.get('Size', 0), + content_type=content_type, + metadata=metadata if metadata else None, + last_modified=obj.get('LastModified'))) + + except ClientError as e: + logger.error(f"Error listing objects with prefix '{prefix}': {e}", exc_info=True) + raise RuntimeError(f"Failed to list objects: {str(e)}") from e + + return objects diff --git a/packages/nvidia_nat_s3/tests/conftest.py b/packages/nvidia_nat_s3/tests/conftest.py new file mode 100644 index 0000000000..c880aa6646 --- /dev/null +++ b/packages/nvidia_nat_s3/tests/conftest.py @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Pytest fixtures for S3 integration tests.""" + +import socket + +import pytest + + +def is_port_open(host: str, port: int) -> bool: + """Check if a port is open on the given host.""" + try: + with socket.create_connection((host, port), timeout=1): + return True + except (TimeoutError, ConnectionRefusedError, OSError): + return False + + +@pytest.fixture(scope="session") +def minio_server(): + """ + Fixture that checks if MinIO is running on localhost:9000. + + To run S3 integration tests, start MinIO with: + + docker run --rm -p 9000:9000 -p 9001:9001 \\ + -e MINIO_ROOT_USER=minioadmin \\ + -e MINIO_ROOT_PASSWORD=minioadmin \\ + minio/minio server /data --console-address ":9001" + + Then run tests with: + pytest packages/nvidia_nat_s3/tests/test_s3_object_store.py --run_integration -v + """ + if not is_port_open("localhost", 9000): + pytest.skip("MinIO not running on localhost:9000. " + "Start MinIO to run S3 integration tests.") + + return { + "bucket_name": "test-bucket", + "endpoint_url": "http://localhost:9000", + "aws_access_key_id": "minioadmin", + "aws_secret_access_key": "minioadmin", + } diff --git a/packages/nvidia_nat_test/src/nat/test/object_store_tests.py b/packages/nvidia_nat_test/src/nat/test/object_store_tests.py index b81da62935..10154e6be6 100644 --- a/packages/nvidia_nat_test/src/nat/test/object_store_tests.py +++ b/packages/nvidia_nat_test/src/nat/test/object_store_tests.py @@ -24,6 +24,7 @@ from nat.data_models.object_store import NoSuchKeyError from nat.object_store.interfaces import ObjectStore from nat.object_store.models import ObjectStoreItem +from nat.object_store.models import ObjectStoreListItem @pytest.mark.asyncio(loop_scope="class") @@ -115,3 +116,63 @@ async def test_delete_object(self, store: ObjectStore): # Try to delete the object again with pytest.raises(NoSuchKeyError): await store.delete_object(key) + + async def test_list_objects(self, store: ObjectStore): + """Test listing objects with and without prefix filtering""" + + test_id = str(uuid.uuid4())[:8] + + test_objects = { + f"videos/{test_id}/video1.mp4": + ObjectStoreItem(data=b"video1_data", content_type="video/mp4", metadata={"title": "Video 1"}), + f"videos/{test_id}/video2.mp4": + ObjectStoreItem(data=b"video2_data", content_type="video/mp4", metadata={"title": "Video 2"}), + f"images/{test_id}/image1.png": + ObjectStoreItem(data=b"image1_data", content_type="image/png", metadata={"title": "Image 1"}), + f"docs/{test_id}/doc1.txt": + ObjectStoreItem(data=b"doc1_data", content_type="text/plain") + } + + for key, item in test_objects.items(): + await store.put_object(key, item) + + # Test 1: List all objects (no prefix) + all_objects = await store.list_objects() + all_keys = {obj.key for obj in all_objects} + + for key in test_objects.keys(): + assert key in all_keys, f"Expected key {key} not found in all_objects" + + # Test 2: List with videos prefix + video_objects = await store.list_objects(prefix=f"videos/{test_id}/") + assert len(video_objects) == 2, f"Expected 2 video objects, got {len(video_objects)}" + + video_keys = {obj.key for obj in video_objects} + assert f"videos/{test_id}/video1.mp4" in video_keys + assert f"videos/{test_id}/video2.mp4" in video_keys + + for obj in video_objects: + assert isinstance(obj, ObjectStoreListItem) + assert obj.key.startswith(f"videos/{test_id}/") + assert obj.size > 0 + assert obj.content_type == "video/mp4" + assert obj.metadata is not None + assert "title" in obj.metadata + + # Test 3: List with images prefix + image_objects = await store.list_objects(prefix=f"images/{test_id}/") + assert len(image_objects) == 1 + assert image_objects[0].key == f"images/{test_id}/image1.png" + assert image_objects[0].content_type == "image/png" + + # Test 4: List with non-existent prefix + empty_objects = await store.list_objects(prefix=f"nonexistent/{test_id}/") + assert len(empty_objects) == 0 + + # Test 5: List with partial prefix + all_test_objects = await store.list_objects(prefix=f"videos/{test_id}") + assert len(all_test_objects) >= 2 # At least our video objects + + # Cleanup + for key in test_objects.keys(): + await store.delete_object(key) diff --git a/src/nat/front_ends/fastapi/fastapi_front_end_plugin_worker.py b/src/nat/front_ends/fastapi/fastapi_front_end_plugin_worker.py index 778182f848..292e179b28 100644 --- a/src/nat/front_ends/fastapi/fastapi_front_end_plugin_worker.py +++ b/src/nat/front_ends/fastapi/fastapi_front_end_plugin_worker.py @@ -18,6 +18,7 @@ import logging import os import typing +import uuid from abc import ABC from abc import abstractmethod from collections.abc import Awaitable @@ -29,6 +30,8 @@ from authlib.common.errors import AuthlibBaseError as OAuthError from fastapi import Body from fastapi import FastAPI +from fastapi import File +from fastapi import Form from fastapi import HTTPException from fastapi import Request from fastapi import Response @@ -304,6 +307,7 @@ async def add_routes(self, app: FastAPI, builder: WorkflowBuilder): await self.add_evaluate_route(app, SessionManager(await builder.build())) await self.add_evaluate_item_route(app, SessionManager(await builder.build())) await self.add_static_files_route(app, builder) + await self.add_video_upload_route(app, builder) await self.add_authorization_route(app) await self.add_mcp_client_tool_list_route(app, builder) @@ -657,6 +661,121 @@ async def delete_static_file(file_path: str): description="Delete a static file from the object store", ) + async def add_video_upload_route(self, app: FastAPI, builder: WorkflowBuilder): + """ + This endpoint allows uploading video files to the configured object store. + Videos are stored with unique keys in a 'videos/' prefix + """ + + if not self.front_end_config.object_store: + logger.debug("No object store configured, skipping video upload route") + return + + # Get the object store client + object_store_client = await builder.get_object_store_client(self.front_end_config.object_store) + + async def upload_video(file: UploadFile = File(...), metadata: str | None = Form(None)): + if not file.content_type or not file.content_type.startswith('video/'): + raise HTTPException(status_code=400, + detail="File must be a video. Content type must start with 'video/'") + file_data = await file.read() + + video_uuid = str(uuid.uuid4()) + safe_filename = file.filename or "video" + + # Sanitize filename to avoid path traversal issues + safe_filename = os.path.basename(safe_filename) + video_key = f"videos/{video_uuid}/{safe_filename}" + + video_metadata = { + "original_filename": file.filename or "", + "upload_metadata": metadata or "", + } + + try: + await object_store_client.put_object( + video_key, ObjectStoreItem(data=file_data, content_type=file.content_type, metadata=video_metadata)) + except KeyAlreadyExistsError as e: + raise HTTPException(status_code=409, detail=str(e)) from e + except Exception as e: + logger.error(f"Error uploading video: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to upload video: {str(e)}") from e + + return { + "video_key": video_key, + "filename": file.filename, + "content_type": file.content_type, + "size": len(file_data), + "uuid": video_uuid + } + + app.add_api_route(path="/videos", + endpoint=upload_video, + methods=["POST"], + description="Upload a video file to the object store") + + async def list_videos(): + """List all videos in the object store""" + try: + video_objects = await object_store_client.list_objects(prefix="videos/") + + videos = [] + for obj in video_objects: + # Extract UUID and filename from path: videos/{uuid}/{filename} + parts = obj.key.split('/') + if len(parts) >= 3 and parts[0] == 'videos': + video_uuid = parts[1] + filename = '/'.join(parts[2:]) + + videos.append({ + "video_key": obj.key, + "filename": filename, + "content_type": obj.content_type or "video/mp4", + "size": obj.size, + "uuid": video_uuid, + "uploaded_at": obj.last_modified.isoformat() if obj.last_modified else None + }) + + # Sort by uploaded_at descending + videos.sort(key=lambda x: x.get('uploaded_at') or '', reverse=True) + return {"videos": videos} + except Exception as e: + logger.error(f"Error listing videos: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to list videos: {str(e)}") from e + + app.add_api_route(path="/videos", + endpoint=list_videos, + methods=["GET"], + description="List all videos in the object store") + + async def delete_video(video_key: str): + """Delete a video from the object store""" + + if not video_key.startswith('videos/'): + raise HTTPException(status_code=400, detail="Invalid video key. Must start with 'videos/'") + + try: + try: + await object_store_client.delete_object(video_key) + except NoSuchKeyError: + logger.info(f"Video {video_key} not found during delete - treating as success (idempotent)") + return {"message": "Video deleted successfully (was already deleted)", "video_key": video_key} + + return {"message": "Video deleted successfully", "video_key": video_key} + except NoSuchKeyError as e: + logger.info(f"Video {video_key} not found: {e}") + return {"message": "Video deleted successfully (was already deleted)", "video_key": video_key} + except HTTPException: + raise + except Exception as e: + logger.error(f"Error deleting video {video_key}: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to delete video: {str(e)}") from e + + app.add_api_route(path="/videos/{video_key:path}", + endpoint=delete_video, + methods=["DELETE"], + description="Delete a video from the object store") + async def add_route(self, app: FastAPI, endpoint: FastApiFrontEndConfig.EndpointBase, diff --git a/src/nat/object_store/in_memory_object_store.py b/src/nat/object_store/in_memory_object_store.py index d2a359dc25..bc8415f8c2 100644 --- a/src/nat/object_store/in_memory_object_store.py +++ b/src/nat/object_store/in_memory_object_store.py @@ -24,6 +24,7 @@ from .interfaces import ObjectStore from .models import ObjectStoreItem +from .models import ObjectStoreListItem class InMemoryObjectStoreConfig(ObjectStoreBaseConfig, name="in_memory"): @@ -70,6 +71,29 @@ async def delete_object(self, key: str) -> None: except KeyError: raise NoSuchKeyError(key) + @override + async def list_objects(self, prefix: str | None = None) -> list[ObjectStoreListItem]: + """ + List objects in the in-memory store, optionally filtered by key prefix. + """ + async with self._lock: + result = [] + for key, item in self._store.items(): + if prefix is not None and not key.startswith(prefix): + continue + + if key.endswith('/'): + continue + + result.append( + ObjectStoreListItem(key=key, + size=len(item.data), + content_type=item.content_type, + metadata=item.metadata, + last_modified=None)) + + return result + @register_object_store(config_type=InMemoryObjectStoreConfig) async def in_memory_object_store(config: InMemoryObjectStoreConfig, builder: Builder): diff --git a/src/nat/object_store/interfaces.py b/src/nat/object_store/interfaces.py index c78bfa112e..d445f74d69 100644 --- a/src/nat/object_store/interfaces.py +++ b/src/nat/object_store/interfaces.py @@ -17,6 +17,7 @@ from abc import abstractmethod from .models import ObjectStoreItem +from .models import ObjectStoreListItem class ObjectStore(ABC): @@ -82,3 +83,17 @@ async def delete_object(self, key: str) -> None: NoSuchKeyError: If the item does not exist. """ pass + + @abstractmethod + async def list_objects(self, prefix: str | None = None) -> list[ObjectStoreListItem]: + """ + List objects in the object store, optionally filtered by key prefix. + + Args: + prefix (str | None): Optional prefix to filter object keys. + If None, returns all objects. + + Returns: + list[ObjectStoreListItem]: List of object metadata (without the actual data). + """ + pass diff --git a/src/nat/object_store/models.py b/src/nat/object_store/models.py index 156b677724..7edf14e6a6 100644 --- a/src/nat/object_store/models.py +++ b/src/nat/object_store/models.py @@ -13,6 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from datetime import datetime + from pydantic import BaseModel from pydantic import ConfigDict from pydantic import Field @@ -36,3 +38,28 @@ class ObjectStoreItem(BaseModel): data: bytes = Field(description="The data to store in the object store.") content_type: str | None = Field(description="The content type of the data.", default=None) metadata: dict[str, str] | None = Field(description="The metadata of the data.", default=None) + + +class ObjectStoreListItem(BaseModel): + """ + Represents metadata about an object in the store without the actual data. + + Attributes + ---------- + key : str + The key/path of the object in the store. + size : int + The size of the object in bytes. + content_type : str | None + The content type of the data. + metadata : dict[str, str] | None + Metadata associated with the object. + last_modified : datetime | None + The last modification timestamp of the object. + """ + + key: str = Field(description="The key/path of the object in the store.") + size: int = Field(description="The size of the object in bytes.") + content_type: str | None = Field(description="The content type of the data.", default=None) + metadata: dict[str, str] | None = Field(description="The metadata of the data.", default=None) + last_modified: datetime | None = Field(description="The last modification timestamp.", default=None) diff --git a/tests/nat/front_ends/fastapi/test_video_upload_routes.py b/tests/nat/front_ends/fastapi/test_video_upload_routes.py new file mode 100644 index 0000000000..f8a1156268 --- /dev/null +++ b/tests/nat/front_ends/fastapi/test_video_upload_routes.py @@ -0,0 +1,181 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Integration tests for video upload, list, and delete routes using InMemoryObjectStore""" + +import io + +import pytest + +from nat.data_models.config import Config +from nat.data_models.config import GeneralConfig +from nat.front_ends.fastapi.fastapi_front_end_config import FastApiFrontEndConfig +from nat.object_store.in_memory_object_store import InMemoryObjectStoreConfig +from nat.test.functions import EchoFunctionConfig +from nat.test.utils import build_nat_client + + +@pytest.fixture +def object_store_name(): + return "test_video_store" + + +@pytest.fixture +async def client(object_store_name): + """Create an async test client for the FastAPI app with InMemoryObjectStore""" + config = Config( + general=GeneralConfig(front_end=FastApiFrontEndConfig(object_store=object_store_name)), + object_stores={object_store_name: InMemoryObjectStoreConfig()}, + workflow=EchoFunctionConfig(), # Dummy workflow for tests + ) + + async with build_nat_client(config) as test_client: + yield test_client + + +@pytest.mark.asyncio +class TestVideoUploadRoutes: + """Test video upload, list, and delete functionality with InMemoryObjectStore""" + + async def test_upload_video_success(self, client): + """Test successful video upload""" + + video_data = b"fake_video_data_mp4" + + response = await client.post("/videos", + files={"file": ("test_video.mp4", io.BytesIO(video_data), "video/mp4")}, + data={"metadata": "test metadata"}) + + assert response.status_code == 200 + result = response.json() + + assert "video_key" in result + assert result["video_key"].startswith("videos/") + assert result["filename"] == "test_video.mp4" + assert result["content_type"] == "video/mp4" + assert result["size"] == len(video_data) + assert "uuid" in result + + async def test_upload_non_video_file_rejected(self, client): + """Test that non-video files are rejected""" + + text_data = b"not a video" + + response = await client.post("/videos", files={"file": ("test.txt", io.BytesIO(text_data), "text/plain")}) + + assert response.status_code == 400 + assert "video" in response.json()["detail"].lower() + + async def test_list_videos_empty(self, client): + """Test listing videos when none exist""" + + response = await client.get("/videos") + + assert response.status_code == 200 + result = response.json() + assert "videos" in result + assert isinstance(result["videos"], list) + + async def test_list_videos_after_upload(self, client): + """Test listing videos after uploading some""" + + video1_data = b"fake_video_1" + response1 = await client.post("/videos", files={"file": ("video1.mp4", io.BytesIO(video1_data), "video/mp4")}) + assert response1.status_code == 200 + video1_key = response1.json()["video_key"] + + video2_data = b"fake_video_2" + response2 = await client.post("/videos", files={"file": ("video2.avi", io.BytesIO(video2_data), "video/avi")}) + assert response2.status_code == 200 + video2_key = response2.json()["video_key"] + + # List videos + response = await client.get("/videos") + assert response.status_code == 200 + result = response.json() + + # Find our uploaded videos + video_keys = {v["video_key"] for v in result["videos"]} + assert video1_key in video_keys + assert video2_key in video_keys + + # Verify video metadata + for video in result["videos"]: + if video["video_key"] == video1_key: + assert video["filename"] == "video1.mp4" + assert video["size"] == len(video1_data) + elif video["video_key"] == video2_key: + assert video["filename"] == "video2.avi" + assert video["size"] == len(video2_data) + + async def test_delete_video_success(self, client): + """Test successful video deletion""" + + video_data = b"video_to_delete" + response = await client.post("/videos", files={"file": ("delete_me.mp4", io.BytesIO(video_data), "video/mp4")}) + assert response.status_code == 200 + video_key = response.json()["video_key"] + + delete_response = await client.delete(f"/videos/{video_key}") + assert delete_response.status_code == 200 + result = delete_response.json() + assert "deleted successfully" in result["message"] + assert result["video_key"] == video_key + + list_response = await client.get("/videos") + video_keys = {v["video_key"] for v in list_response.json()["videos"]} + assert video_key not in video_keys + + async def test_delete_video_idempotent(self, client): + """Test that deleting a non-existent video is idempotent (doesn't error)""" + + # Try to delete a video that doesn't exist + fake_key = "videos/00000000-0000-0000-0000-000000000000/nonexistent.mp4" + response = await client.delete(f"/videos/{fake_key}") + + assert response.status_code == 200 + result = response.json() + assert "deleted successfully" in result["message"].lower() + + async def test_delete_invalid_key_rejected(self, client): + """Test that invalid video keys are rejected""" + + # Try to delete with a key that doesn't start with "videos/" + response = await client.delete("/videos/invalid/path/video.mp4") + + assert response.status_code == 400 + assert "invalid" in response.json()["detail"].lower() + + async def test_full_workflow(self, client): + """Test complete upload → list → delete → list workflow""" + + video_data = b"workflow_test_video" + upload_response = await client.post("/videos", + files={"file": ("workflow.mp4", io.BytesIO(video_data), "video/mp4")}, + data={"metadata": "workflow test"}) + assert upload_response.status_code == 200 + video_key = upload_response.json()["video_key"] + + list_response_1 = await client.get("/videos") + assert list_response_1.status_code == 200 + video_keys_1 = {v["video_key"] for v in list_response_1.json()["videos"]} + assert video_key in video_keys_1 + + delete_response = await client.delete(f"/videos/{video_key}") + assert delete_response.status_code == 200 + + list_response_2 = await client.get("/videos") + assert list_response_2.status_code == 200 + video_keys_2 = {v["video_key"] for v in list_response_2.json()["videos"]} + assert video_key not in video_keys_2