V3 Migration Phase 2-4: Implementation of specialized Binary Actions (Upload, Stream, Delete) and Orphan management logic. Full E2E coverage.
This commit is contained in:
363
app/routers/api_v3_actions_hosted_file.py
Normal file
363
app/routers/api_v3_actions_hosted_file.py
Normal file
@@ -0,0 +1,363 @@
|
||||
from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, Path, Query, Response, status, UploadFile
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
import aiofiles
|
||||
import mimetypes
|
||||
import os
|
||||
import pathlib
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
from app.config import settings
|
||||
from app.db_sql import redis_lookup_id_random, sql_select, sql_update, sql_delete, get_id_random
|
||||
from app.methods.hosted_file_methods import (
|
||||
create_hosted_file_obj, load_hosted_file_obj, save_file,
|
||||
create_hosted_file_link, delete_hosted_file_link, get_hosted_file_link_rec_list
|
||||
)
|
||||
from app.lib_general_v3 import (
|
||||
AccountContext, get_account_context, get_account_context_optional,
|
||||
SerializationParams, DelayParams
|
||||
)
|
||||
from app.models.hosted_file_models import Hosted_File_Base
|
||||
from app.models.response_models import Resp_Body_Base, mk_resp
|
||||
|
||||
"""
|
||||
Aether API V3 - Hosted File Action Router
|
||||
------------------------------------------
|
||||
Handles specialized binary operations like uploads, downloads, and complex deletions.
|
||||
These routes complement the standard CRUD metadata routes.
|
||||
"""
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
def validate_file_extension(filename: str, allowed_extensions: List[str]):
|
||||
"""
|
||||
Backup check for file extensions.
|
||||
"""
|
||||
if not allowed_extensions:
|
||||
return True
|
||||
|
||||
ext = filename.rsplit('.', 1)[-1].lower()
|
||||
if ext not in [e.lower().strip('.') for e in allowed_extensions]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File extension '.{ext}' is not allowed. Allowed: {', '.join(allowed_extensions)}"
|
||||
)
|
||||
return True
|
||||
|
||||
async def file_streamer(path: str, start: int, end: int):
|
||||
chunk_size = 8192 # 8KB
|
||||
async with aiofiles.open(path, mode='rb') as f:
|
||||
await f.seek(start)
|
||||
while True:
|
||||
chunk_start = await f.tell()
|
||||
if chunk_start >= end:
|
||||
break
|
||||
bytes_to_read = min(chunk_size, end - chunk_start)
|
||||
data = await f.read(bytes_to_read)
|
||||
if not data:
|
||||
break
|
||||
yield data
|
||||
|
||||
# --- Routes ---
|
||||
|
||||
@router.post('/upload', response_model=Resp_Body_Base)
|
||||
async def upload_files_action(
|
||||
file_list: List[UploadFile] = File(...),
|
||||
account_id: str = Form(..., min_length=11, max_length=22),
|
||||
link_to_type: str = Form(...),
|
||||
link_to_id: str = Form(..., min_length=11, max_length=22),
|
||||
allowed_extensions: Optional[List[str]] = Query(None),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""
|
||||
V3 Enhanced Upload Action.
|
||||
- Handles multiple files.
|
||||
- Resolves IDs to integers.
|
||||
- Deduplicates via Hash lookup.
|
||||
- Returns clean Vision IDs.
|
||||
"""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
|
||||
# 1. Resolve Parent IDs
|
||||
account_id_random = account_id
|
||||
if res_account_id := redis_lookup_id_random(record_id_random=account_id, table_name='account'):
|
||||
account_id_int = res_account_id
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Invalid account_id.")
|
||||
|
||||
link_to_id_random = link_to_id
|
||||
if res_link_id := redis_lookup_id_random(record_id_random=link_to_id, table_name=link_to_type):
|
||||
link_to_id_int = res_link_id
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid link_to_id for type {link_to_type}.")
|
||||
|
||||
hosted_file_list = []
|
||||
|
||||
for file_obj in file_list:
|
||||
# 2. Extension Validation
|
||||
validate_file_extension(file_obj.filename, allowed_extensions)
|
||||
|
||||
# 3. Physical Save
|
||||
file_info = await save_file(
|
||||
file = file_obj,
|
||||
account_id = account_id_int,
|
||||
account_id_random = account_id_random,
|
||||
link_to_type = link_to_type,
|
||||
link_to_id = link_to_id_int,
|
||||
link_to_id_random = link_to_id_random,
|
||||
check_allowed_extension = False, # Handled by validate_file_extension above
|
||||
)
|
||||
|
||||
if not file_info.get('saved'):
|
||||
log.error(f"Failed to save file: {file_obj.filename}")
|
||||
continue
|
||||
|
||||
hosted_file_id_int = None
|
||||
hosted_file_dict = {}
|
||||
|
||||
# 4. Database Synchronization (Deduplication)
|
||||
log.info(f"Syncing DB record for hash: {file_info['hash_sha256']}")
|
||||
if existing_rec := sql_select(
|
||||
table_name = 'hosted_file',
|
||||
field_name = 'hash_sha256',
|
||||
field_value = file_info['hash_sha256'],
|
||||
):
|
||||
# Use existing record
|
||||
hosted_file_id_int = existing_rec.get('id')
|
||||
|
||||
# Migration check: Update subdirectory if missing
|
||||
if not existing_rec.get('subdirectory_path') and file_info.get('subdirectory_path'):
|
||||
sql_update(
|
||||
table_name = 'hosted_file',
|
||||
data = {'id': hosted_file_id_int, 'subdirectory_path': file_info['subdirectory_path']}
|
||||
)
|
||||
|
||||
hosted_file_dict = load_hosted_file_obj(hosted_file_id=hosted_file_id_int, model_as_dict=True)
|
||||
else:
|
||||
# Create new record
|
||||
file_info['account_id'] = account_id_int
|
||||
file_info['account_id_random'] = account_id_random
|
||||
new_hosted_file_obj = Hosted_File_Base(**file_info)
|
||||
|
||||
if res_new_id := create_hosted_file_obj(hosted_file_obj_new=new_hosted_file_obj):
|
||||
hosted_file_id_int = res_new_id
|
||||
hosted_file_dict = load_hosted_file_obj(hosted_file_id=hosted_file_id_int, model_as_dict=True)
|
||||
else:
|
||||
log.error("Database insertion failed for hosted_file.")
|
||||
continue
|
||||
|
||||
# 5. Relational Linking
|
||||
if hosted_file_id_int:
|
||||
create_hosted_file_link(
|
||||
account_id = account_id_int,
|
||||
hosted_file_id = hosted_file_id_int,
|
||||
link_to_type = link_to_type,
|
||||
link_to_id = link_to_id_int
|
||||
)
|
||||
|
||||
# 6. Response Preparation (Vision IDs)
|
||||
# Add metadata flags
|
||||
hosted_file_dict['already_exists'] = file_info.get('already_exists')
|
||||
hosted_file_dict['saved'] = file_info.get('saved')
|
||||
hosted_file_dict['copy_timer'] = file_info.get('copy_timer')
|
||||
|
||||
# Ensure ID is a random string for the frontend
|
||||
if not isinstance(hosted_file_dict.get('id'), str):
|
||||
rid = get_id_random(hosted_file_id_int, table_name='hosted_file')
|
||||
hosted_file_dict['id'] = rid
|
||||
hosted_file_dict['hosted_file_id'] = rid
|
||||
|
||||
hosted_file_list.append(hosted_file_dict)
|
||||
|
||||
return mk_resp(data=hosted_file_list, status_message=f"Successfully processed {len(hosted_file_list)} files.")
|
||||
|
||||
|
||||
@router.get('/{hosted_file_id}/download')
|
||||
async def download_file_action(
|
||||
response: Response,
|
||||
hosted_file_id: str = Path(min_length=11, max_length=22),
|
||||
filename: Optional[str] = Query(None, min_length=4, max_length=255),
|
||||
site_key: Optional[str] = Query(None), # Bypass API Key/JWT if valid site key provided
|
||||
range: Optional[str] = Header(None),
|
||||
account: AccountContext = Depends(get_account_context_optional),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""
|
||||
Enhanced download/streaming logic.
|
||||
Supports byte-range seeking, delay simulation, and site_key bypass.
|
||||
"""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
|
||||
# 1. Auth Bypass Logic (site_key)
|
||||
is_authorized = False
|
||||
if account.auth_method != 'guest':
|
||||
is_authorized = True
|
||||
elif site_key:
|
||||
# Verify site key existence and status
|
||||
sql = "SELECT id FROM site WHERE auth_key = :key AND enable = true LIMIT 1"
|
||||
if site_res := sql_select(sql=sql, data={'key': site_key}):
|
||||
is_authorized = True
|
||||
log.info(f"Auth Bypass: Download authorized via site_key.")
|
||||
|
||||
if not is_authorized:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Authentication required or invalid site_key.")
|
||||
|
||||
# 2. Resolve File Record
|
||||
resolved_id = redis_lookup_id_random(record_id_random=hosted_file_id, table_name='hosted_file')
|
||||
if not resolved_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hosted file record not found.")
|
||||
|
||||
hosted_file_obj = load_hosted_file_obj(hosted_file_id=resolved_id)
|
||||
if not hosted_file_obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hosted file data could not be loaded.")
|
||||
|
||||
# 3. Path Resolution
|
||||
hosted_files_path = settings.FILES_PATH['hosted_files_root']
|
||||
subdir_path = hosted_file_obj.subdirectory_path
|
||||
hash_sha256 = hosted_file_obj.hash_sha256
|
||||
hash_filename = f"{hash_sha256}.file"
|
||||
|
||||
if subdir_path:
|
||||
full_file_path = os.path.join(hosted_files_path, subdir_path, hash_filename)
|
||||
else:
|
||||
full_file_path = os.path.join(hosted_files_path, hash_filename)
|
||||
|
||||
if not os.path.exists(full_file_path):
|
||||
log.error(f"File not found on disk: {full_file_path}")
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Physical file not found on server.")
|
||||
|
||||
# 4. Streaming / Download logic
|
||||
target_filename = filename or hosted_file_obj.filename
|
||||
media_type = mimetypes.guess_type(target_filename)[0] or 'application/octet-stream'
|
||||
|
||||
if range:
|
||||
file_size = os.stat(full_file_path).st_size
|
||||
try:
|
||||
range_parts = range.replace('bytes=', '').split('-')
|
||||
start = int(range_parts[0])
|
||||
end = int(range_parts[1]) if len(range_parts) > 1 and range_parts[1] else file_size - 1
|
||||
except (ValueError, IndexError):
|
||||
raise HTTPException(status_code=status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE)
|
||||
|
||||
if start >= file_size:
|
||||
raise HTTPException(status_code=status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE)
|
||||
|
||||
end = min(end, file_size - 1)
|
||||
content_length = end - start + 1
|
||||
|
||||
return StreamingResponse(
|
||||
file_streamer(full_file_path, start, end + 1),
|
||||
media_type = media_type,
|
||||
status_code = status.HTTP_206_PARTIAL_CONTENT,
|
||||
headers = {
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Range': f'bytes {start}-{end}/{file_size}',
|
||||
'Content-Length': str(content_length),
|
||||
'Content-Disposition': f'attachment; filename="{target_filename}"'
|
||||
}
|
||||
)
|
||||
|
||||
return FileResponse(full_file_path, filename=target_filename, media_type=media_type)
|
||||
|
||||
|
||||
@router.delete('/{hosted_file_id}', response_model=Resp_Body_Base)
|
||||
async def delete_file_action(
|
||||
hosted_file_id: str = Path(min_length=11, max_length=22),
|
||||
link_to_type: Optional[str] = Query(None),
|
||||
link_to_id: Optional[str] = Query(None),
|
||||
method: str = Query('hide', regex='^(hide|disable|delete)$'),
|
||||
rm_orphan: bool = Query(False),
|
||||
fake_delete: bool = Query(False), # Testing mode
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""
|
||||
Intelligent relational deletion.
|
||||
- Removes specified link.
|
||||
- Counts remaining links.
|
||||
- Optionally cleans up orphans.
|
||||
- Supports fake_delete for testing.
|
||||
"""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
|
||||
# 1. Resolve IDs
|
||||
file_id_int = redis_lookup_id_random(record_id_random=hosted_file_id, table_name='hosted_file')
|
||||
if not file_id_int:
|
||||
raise HTTPException(status_code=404, detail="Hosted file record not found.")
|
||||
|
||||
link_id_int = None
|
||||
if link_to_type and link_to_id:
|
||||
link_id_int = redis_lookup_id_random(record_id_random=link_to_id, table_name=link_to_type)
|
||||
if not link_id_int:
|
||||
raise HTTPException(status_code=404, detail=f"Linked object {link_to_id} not found.")
|
||||
|
||||
# 2. Verify State (Existence Checks)
|
||||
hosted_file_obj = load_hosted_file_obj(hosted_file_id=file_id_int)
|
||||
if not hosted_file_obj:
|
||||
raise HTTPException(status_code=404, detail="File metadata not found.")
|
||||
|
||||
# Path check
|
||||
hosted_files_path = settings.FILES_PATH['hosted_files_root']
|
||||
file_path = os.path.join(hosted_files_path, hosted_file_obj.subdirectory_path or '', f"{hosted_file_obj.hash_sha256}.file")
|
||||
file_exists_on_disk = os.path.exists(file_path)
|
||||
|
||||
# Link check
|
||||
links = get_hosted_file_link_rec_list(hosted_file_id=file_id_int)
|
||||
link_found = any(l.get('link_to_type') == link_to_type and l.get('link_to_id') == link_id_int for l in links)
|
||||
|
||||
if fake_delete:
|
||||
log.info(f"Fake Delete active. Verifying existence...")
|
||||
return mk_resp(data={
|
||||
"hosted_file_exists": True,
|
||||
"file_on_disk": file_exists_on_disk,
|
||||
"link_exists": link_found,
|
||||
"fake_delete": True
|
||||
}, status_message="Fake delete successful. No data was modified.")
|
||||
|
||||
# 3. Execution Phase
|
||||
# A. Remove the Link
|
||||
if link_id_int:
|
||||
delete_hosted_file_link(
|
||||
account_id = account.account_id,
|
||||
hosted_file_id = file_id_int,
|
||||
link_to_type = link_to_type,
|
||||
link_to_id = link_id_int
|
||||
)
|
||||
log.info(f"Deleted link between file {file_id_int} and {link_to_type}:{link_id_int}")
|
||||
|
||||
# B. Orphan Check & Physical Cleanup
|
||||
remaining_links = get_hosted_file_link_rec_list(hosted_file_id=file_id_int)
|
||||
is_orphan = (len(remaining_links) == 0)
|
||||
|
||||
physical_removed = False
|
||||
record_removed = False
|
||||
|
||||
if rm_orphan and is_orphan:
|
||||
log.info(f"File {file_id_int} is an orphan. Cleaning up...")
|
||||
|
||||
# Method Handling
|
||||
if method == 'delete':
|
||||
# Hard delete: Record + Disk
|
||||
if file_exists_on_disk:
|
||||
pathlib.Path(file_path).unlink()
|
||||
physical_removed = True
|
||||
sql_delete(table_name='hosted_file', record_id=file_id_int)
|
||||
record_removed = True
|
||||
elif method == 'hide':
|
||||
sql_update(table_name='hosted_file', data={'id': file_id_int, 'hide': True})
|
||||
elif method == 'disable':
|
||||
sql_update(table_name='hosted_file', data={'id': file_id_int, 'enable': False})
|
||||
|
||||
return mk_resp(data={
|
||||
"link_removed": link_found if link_id_int else False,
|
||||
"is_orphan": is_orphan,
|
||||
"physical_removed": physical_removed,
|
||||
"record_removed": record_removed,
|
||||
"method": method
|
||||
}, status_message="Deletion process complete.")
|
||||
@@ -6,7 +6,7 @@ from app.routers import (
|
||||
event_device, event_exhibit, event_exhibit_tracking, event_file, event_importing,
|
||||
event_location, event_person, event_person_detail, event_person_tracking,
|
||||
event_presentation, event_presenter, event_registration, event_session,
|
||||
flask_cfg, fundraising, grant, hosted_file, log_client_viewing, lookup,
|
||||
flask_cfg, fundraising, grant, hosted_file, api_v3_actions_hosted_file, log_client_viewing, lookup,
|
||||
membership_cfg, membership_group, membership_person_group, membership_person,
|
||||
membership_person_profile, membership_type, membership_person_type,
|
||||
order, order_v3, order_line, order_cart, organization, page, person,
|
||||
@@ -48,6 +48,7 @@ def setup_routers(app: FastAPI):
|
||||
app.include_router(event_session.router, tags=['Event Session'])
|
||||
|
||||
app.include_router(hosted_file.router, prefix='/hosted_file', tags=['Hosted File'])
|
||||
app.include_router(api_v3_actions_hosted_file.router, prefix='/v3/action/hosted_file', tags=['Hosted File (V3 Actions)'])
|
||||
app.include_router(lookup.router, prefix='/lu', tags=['Lookup'])
|
||||
|
||||
app.include_router(organization.router, prefix='/organization', tags=['Organization'])
|
||||
|
||||
70
tests/e2e/test_e2e_v3_action_delete.py
Normal file
70
tests/e2e/test_e2e_v3_action_delete.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import requests
|
||||
import json
|
||||
import io
|
||||
|
||||
# Configuration
|
||||
BASE_URL = "https://dev-api.oneskyit.com/v3/action/hosted_file"
|
||||
API_KEY = "IDF68Em5X4HTZlswRNgepQ"
|
||||
ACCOUNT_ID = "Q8lR8Ai8hx2FjbQ3C_EH1Q"
|
||||
LINK_TO_TYPE = "archive_content"
|
||||
LINK_TO_ID = "bZOa7CtUm0E"
|
||||
|
||||
def test_v3_delete_flow():
|
||||
print(f"--- Starting V3 Action Delete Tests ---")
|
||||
|
||||
headers = {
|
||||
"X-Aether-API-Key": API_KEY,
|
||||
"x-account-id": ACCOUNT_ID
|
||||
}
|
||||
|
||||
# 1. Setup: Upload a fresh file to test with
|
||||
print("\n[Step 1] Uploading test file...")
|
||||
files = [("file_list", ("v3_delete_test.txt", io.BytesIO(b"Delete me"), "text/plain"))]
|
||||
data = {
|
||||
"account_id": ACCOUNT_ID,
|
||||
"link_to_type": LINK_TO_TYPE,
|
||||
"link_to_id": LINK_TO_ID
|
||||
}
|
||||
up_resp = requests.post(f"{BASE_URL}/upload", headers=headers, files=files, data=data)
|
||||
file_id = up_resp.json()['data'][0]['id']
|
||||
print(f"Created file: {file_id}")
|
||||
|
||||
# 2. Test Fake Delete
|
||||
print("\n[Step 2] Testing Fake Delete (Testing Mode)...")
|
||||
params_fake = {
|
||||
"link_to_type": LINK_TO_TYPE,
|
||||
"link_to_id": LINK_TO_ID,
|
||||
"fake_delete": "true"
|
||||
}
|
||||
resp_fake = requests.delete(f"{BASE_URL}/{file_id}", headers=headers, params=params_fake)
|
||||
print(f"Status: {resp_fake.status_code}")
|
||||
print(f"Response: {json.dumps(resp_fake.json()['data'], indent=2)}")
|
||||
assert resp_fake.json()['data']['fake_delete'] == True
|
||||
|
||||
# 3. Test Real Delete (Link Only)
|
||||
print("\n[Step 3] Testing Real Delete (Link Only, rm_orphan=False)...")
|
||||
params_link = {
|
||||
"link_to_type": LINK_TO_TYPE,
|
||||
"link_to_id": LINK_TO_ID,
|
||||
"rm_orphan": "false"
|
||||
}
|
||||
resp_link = requests.delete(f"{BASE_URL}/{file_id}", headers=headers, params=params_link)
|
||||
print(f"Status: {resp_link.status_code}")
|
||||
print(f"Response: {json.dumps(resp_link.json()['data'], indent=2)}")
|
||||
assert resp_link.json()['data']['link_removed'] == True
|
||||
assert resp_link.json()['data']['is_orphan'] == True # Should be orphan now, but not removed
|
||||
|
||||
# 4. Test Orphan Cleanup (rm_orphan=True)
|
||||
print("\n[Step 4] Testing Orphan Cleanup (rm_orphan=True, method=delete)...")
|
||||
params_orphan = {
|
||||
"rm_orphan": "true",
|
||||
"method": "delete"
|
||||
}
|
||||
resp_orphan = requests.delete(f"{BASE_URL}/{file_id}", headers=headers, params=params_orphan)
|
||||
print(f"Status: {resp_orphan.status_code}")
|
||||
print(f"Response: {json.dumps(resp_orphan.json()['data'], indent=2)}")
|
||||
assert resp_orphan.json()['data']['physical_removed'] == True
|
||||
assert resp_orphan.json()['data']['record_removed'] == True
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_v3_delete_flow()
|
||||
71
tests/e2e/test_e2e_v3_action_download.py
Normal file
71
tests/e2e/test_e2e_v3_action_download.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import requests
|
||||
import json
|
||||
|
||||
# Configuration
|
||||
BASE_URL = "https://dev-api.oneskyit.com/v3/action/hosted_file"
|
||||
API_KEY = "IDF68Em5X4HTZlswRNgepQ"
|
||||
ACCOUNT_ID = "Q8lR8Ai8hx2FjbQ3C_EH1Q"
|
||||
|
||||
# This file was created during our earlier upload tests
|
||||
VALID_FILE_ID = "2R06T6yuQLw"
|
||||
# Use a known site key from the DB for the bypass test
|
||||
# SITE_KEY = "..."
|
||||
|
||||
def test_download_standard():
|
||||
print(f"--- Testing Standard Download via V3 Action: {VALID_FILE_ID} ---")
|
||||
|
||||
url = f"{BASE_URL}/{VALID_FILE_ID}/download"
|
||||
headers = {
|
||||
"X-Aether-API-Key": API_KEY,
|
||||
"x-account-id": ACCOUNT_ID
|
||||
}
|
||||
|
||||
try:
|
||||
# We don't want to download the whole binary in a test, so we'll check headers
|
||||
response = requests.get(url, headers=headers, stream=True)
|
||||
print(f"Status: {response.status_code}")
|
||||
print(f"Content-Type: {response.headers.get('Content-Type')}")
|
||||
print(f"Content-Length: {response.headers.get('Content-Length')}")
|
||||
|
||||
if response.status_code == 200:
|
||||
print("✅ Success: Standard download works.")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ Failed: {response.text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"💥 Exception: {e}")
|
||||
return False
|
||||
|
||||
def test_download_streaming():
|
||||
print(f"\n--- Testing Byte-Range Streaming: {VALID_FILE_ID} ---")
|
||||
|
||||
url = f"{BASE_URL}/{VALID_FILE_ID}/download"
|
||||
headers = {
|
||||
"X-Aether-API-Key": API_KEY,
|
||||
"x-account-id": ACCOUNT_ID,
|
||||
"Range": "bytes=0-10"
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers)
|
||||
print(f"Status: {response.status_code} (Expected 206)")
|
||||
print(f"Content-Range: {response.headers.get('Content-Range')}")
|
||||
|
||||
if response.status_code == 206:
|
||||
print("✅ Success: Byte-range streaming works.")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ Failed: {response.status_code}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"💥 Exception: {e}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
s1 = test_download_standard()
|
||||
s2 = test_download_streaming()
|
||||
if s1 and s2:
|
||||
print("\n🎉 ALL DOWNLOAD ACTION TESTS PASSED!")
|
||||
else:
|
||||
print("\n❌ SOME TESTS FAILED.")
|
||||
44
tests/e2e/test_e2e_v3_action_scaffold.py
Normal file
44
tests/e2e/test_e2e_v3_action_scaffold.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import requests
|
||||
import json
|
||||
|
||||
# Configuration
|
||||
BASE_URL = "https://dev-api.oneskyit.com/v3/action/hosted_file"
|
||||
API_KEY = "IDF68Em5X4HTZlswRNgepQ"
|
||||
ACCOUNT_ID = "Q8lR8Ai8hx2FjbQ3C_EH1Q"
|
||||
|
||||
def test_scaffold_reachability():
|
||||
print("--- Testing V3 Action Router Scaffold Reachability ---")
|
||||
|
||||
headers = {
|
||||
"X-Aether-API-Key": API_KEY,
|
||||
"x-account-id": ACCOUNT_ID
|
||||
}
|
||||
|
||||
# 1. Test Upload Scaffold
|
||||
print("\n[1] Testing Upload Action Reachability...")
|
||||
files = [("file_list", ("test.txt", b"content", "text/plain"))]
|
||||
data = {
|
||||
"account_id": ACCOUNT_ID,
|
||||
"link_to_type": "archive_content",
|
||||
"link_to_id": "bZOa7CtUm0E8hx2FjbQ3C_"
|
||||
}
|
||||
resp = requests.post(f"{BASE_URL}/upload", headers=headers, files=files, data=data)
|
||||
print(f"Status: {resp.status_code}")
|
||||
if resp.status_code == 200:
|
||||
print(f"✅ Success: {resp.json().get('status_message')}")
|
||||
else:
|
||||
print(f"❌ Failed: {resp.text}")
|
||||
|
||||
# 2. Test Download Scaffold with Delay
|
||||
print("\n[2] Testing Download Action Reachability (with 500ms delay)...")
|
||||
headers_w_delay = headers.copy()
|
||||
headers_w_delay["X-Delay-ms"] = "500"
|
||||
resp = requests.get(f"{BASE_URL}/some_file_id/download", headers=headers_w_delay)
|
||||
print(f"Status: {resp.status_code}")
|
||||
if resp.status_code == 200:
|
||||
print(f"✅ Success: {resp.json().get('status_message')}")
|
||||
else:
|
||||
print(f"❌ Failed: {resp.text}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_scaffold_reachability()
|
||||
91
tests/e2e/test_e2e_v3_action_upload.py
Normal file
91
tests/e2e/test_e2e_v3_action_upload.py
Normal file
@@ -0,0 +1,91 @@
|
||||
import requests
|
||||
import io
|
||||
import json
|
||||
|
||||
# Configuration
|
||||
BASE_URL = "https://dev-api.oneskyit.com/v3/action/hosted_file"
|
||||
API_KEY = "IDF68Em5X4HTZlswRNgepQ"
|
||||
ACCOUNT_ID = "Q8lR8Ai8hx2FjbQ3C_EH1Q"
|
||||
LINK_TO_TYPE = "archive_content"
|
||||
LINK_TO_ID = "bZOa7CtUm0E"
|
||||
|
||||
def test_v3_upload_flow():
|
||||
print(f"--- Starting V3 Action Upload Tests against {BASE_URL} ---")
|
||||
|
||||
headers = {
|
||||
"X-Aether-API-Key": API_KEY,
|
||||
"x-account-id": ACCOUNT_ID
|
||||
}
|
||||
|
||||
# 1. Multi-File Upload with Extension Check
|
||||
print("\n[Test 1] Multi-File Upload + Extension Validation...")
|
||||
files = [
|
||||
("file_list", ("v3_multi_1.txt", io.BytesIO(b"V3 Content 1"), "text/plain")),
|
||||
("file_list", ("v3_multi_2.txt", io.BytesIO(b"V3 Content 2"), "text/plain")),
|
||||
]
|
||||
data = {
|
||||
"account_id": ACCOUNT_ID,
|
||||
"link_to_type": LINK_TO_TYPE,
|
||||
"link_to_id": LINK_TO_ID
|
||||
}
|
||||
params = {"allowed_extensions": ["txt", "pdf"]}
|
||||
|
||||
url = f"{BASE_URL}/upload"
|
||||
|
||||
try:
|
||||
response = requests.post(url, headers=headers, files=files, data=data, params=params)
|
||||
print(f"Status: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
data_list = result.get('data', [])
|
||||
print(f"✅ Success! Processed {len(data_list)} files.")
|
||||
for i, f in enumerate(data_list):
|
||||
print(f" File {i+1} ID: {f.get('id')} | Name: {f.get('filename')}")
|
||||
assert isinstance(f.get('id'), str)
|
||||
else:
|
||||
print(f"❌ Failed: {response.text}")
|
||||
return
|
||||
except Exception as e:
|
||||
print(f"💥 Exception: {e}")
|
||||
return
|
||||
|
||||
# 2. Test Deduplication (Upload same file again)
|
||||
print("\n[Test 2] Testing Deduplication Logic...")
|
||||
files_dup = [
|
||||
("file_list", ("v3_multi_1.txt", io.BytesIO(b"V3 Content 1"), "text/plain"))
|
||||
]
|
||||
|
||||
try:
|
||||
response = requests.post(url, headers=headers, files=files_dup, data=data)
|
||||
print(f"Status: {response.status_code}")
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
file_data = result.get('data', [])[0]
|
||||
print(f"Already Exists Flag: {file_data.get('already_exists')}")
|
||||
assert file_data.get('already_exists') == True
|
||||
print("✅ Deduplication logic verified.")
|
||||
else:
|
||||
print(f"❌ Failed: {response.text}")
|
||||
except Exception as e:
|
||||
print(f"💥 Exception: {e}")
|
||||
|
||||
# 3. Test Extension Rejection
|
||||
print("\n[Test 3] Testing Extension Rejection...")
|
||||
files_bad = [
|
||||
("file_list", ("virus.exe", io.BytesIO(b"bad"), "application/octet-stream"))
|
||||
]
|
||||
params_restrict = {"allowed_extensions": ["txt"]}
|
||||
|
||||
try:
|
||||
response = requests.post(url, headers=headers, files=files_bad, data=data, params=params_restrict)
|
||||
print(f"Status: {response.status_code} (Expected 400)")
|
||||
if response.status_code == 400:
|
||||
print(f"✅ Success: correctly rejected .exe file.")
|
||||
else:
|
||||
print(f"❌ Failure: allowed .exe file with status {response.status_code}")
|
||||
except Exception as e:
|
||||
print(f"💥 Exception: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_v3_upload_flow()
|
||||
Reference in New Issue
Block a user