Compare commits
38 Commits
feature/ph
...
c95e4c40b8
| Author | SHA1 | Date | |
|---|---|---|---|
| c95e4c40b8 | |||
| cbfd7232ca | |||
| f72b976c33 | |||
| a62e4e0b53 | |||
| eaa2d2d29f | |||
| b5fb2a8cdb | |||
| 2e61dfe935 | |||
| 174c35bac3 | |||
| baf38f227f | |||
| c22dadbd1a | |||
| 3ba31a7b48 | |||
| bbe60bb471 | |||
| b56affa90e | |||
| 08fc785583 | |||
| fab1e81cf6 | |||
| 68f52ccb03 | |||
| d73b7e45a1 | |||
| 2d219af7f6 | |||
| 4f63b3b99e | |||
| 20fc352f6f | |||
| eca1ab7fd0 | |||
| e368574fba | |||
| ada3669217 | |||
| 76fa22bba9 | |||
| ed42a9e306 | |||
| 770b02864d | |||
| 87f3b53d53 | |||
| 6f1e7731d7 | |||
| 0d7ccf834b | |||
| 6bf95a0df0 | |||
| 904e153d8a | |||
| fcff97bae2 | |||
| 2daeb1e2ae | |||
| 3c9e5a8149 | |||
| 2078cd9ade | |||
| 983d6e4bb4 | |||
| 8825118795 | |||
| cc42e7cf29 |
@@ -85,7 +85,17 @@
|
||||
"Bash(grep -E \"\\\\.\\(py|ts\\)$\")",
|
||||
"Bash(grep -E \"\\\\.py$\")",
|
||||
"Bash(git worktree *)",
|
||||
"Bash(npm list *)"
|
||||
"Bash(npm list *)",
|
||||
"Bash(netstat -tulpn)",
|
||||
"Bash(curl -k -v https://192.168.84.131:8918/users/)",
|
||||
"Bash(curl -k -s https://192.168.84.131:8918/users/)",
|
||||
"Bash(grep -E \"\\\\.\\(tsx|ts|jsx|js\\)$\")",
|
||||
"Bash(pkill -9 -f uvicorn)",
|
||||
"Bash(grep -E \"\\\\.\\(py|txt\\)$\")",
|
||||
"Bash(npx vitest *)",
|
||||
"Bash(sed -i 's/jest\\\\.fn\\(\\)/vi.fn\\(\\)/g' tests/hooks/useAIExtraction.test.ts)",
|
||||
"Bash(sed -i 's/as jest\\\\.Mock/as any/g' tests/hooks/useAIExtraction.test.ts)",
|
||||
"Bash(grep -E \"\\\\.tsx?$\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
5
VERSION.json
Normal file
5
VERSION.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "1.14.2",
|
||||
"lastUpdated": "2026-04-21",
|
||||
"phase": "Phase 3 Complete - Blob Serialization Fix"
|
||||
}
|
||||
@@ -103,7 +103,7 @@ def extract_label_info(image_bytes: bytes, mode: str = "item"):
|
||||
"PartNr": "part_number",
|
||||
"OCR": "ocr_text"
|
||||
}
|
||||
|
||||
|
||||
mapped_items = []
|
||||
for item_data in items_to_map:
|
||||
final_item = {}
|
||||
@@ -113,17 +113,46 @@ def extract_label_info(image_bytes: bytes, mode: str = "item"):
|
||||
final_item[model_key] = val.strip()
|
||||
else:
|
||||
final_item[model_key] = val
|
||||
|
||||
|
||||
# Default fields
|
||||
final_item["quantity"] = item_data.get("quantity", 1)
|
||||
raw_barcode = item_data.get("barcode") or item_data.get("PartNr") or item_data.get("part_number") or item_data.get("Part Number")
|
||||
final_item["barcode"] = str(raw_barcode).strip() if raw_barcode else f"AI-{int(time.time()*100)}"
|
||||
|
||||
|
||||
# Handle Box mode specifically inside mapping
|
||||
if mode == "box":
|
||||
final_item["box_label"] = final_item.get("box_label") or item_data.get("Box") or final_item.get("name") or "Unknown Box"
|
||||
final_item["name"] = final_item["box_label"]
|
||||
|
||||
|
||||
# Extract image_processing field if present (optional, graceful fallback)
|
||||
if "image_processing" in item_data and item_data["image_processing"]:
|
||||
image_proc = item_data["image_processing"]
|
||||
# Validate and preserve image_processing
|
||||
validated_proc = {}
|
||||
|
||||
# Validate crop_bounds
|
||||
if "crop_bounds" in image_proc and isinstance(image_proc["crop_bounds"], dict):
|
||||
bounds = image_proc["crop_bounds"]
|
||||
if all(k in bounds for k in ["x", "y", "width", "height"]):
|
||||
if all(isinstance(bounds[k], int) and bounds[k] >= 0 for k in ["x", "y", "width", "height"]):
|
||||
validated_proc["crop_bounds"] = bounds
|
||||
|
||||
# Validate rotation_degrees
|
||||
if "rotation_degrees" in image_proc:
|
||||
rotation = image_proc["rotation_degrees"]
|
||||
if isinstance(rotation, (int, float)) and -360 <= rotation <= 360:
|
||||
validated_proc["rotation_degrees"] = rotation
|
||||
|
||||
# Validate confidence
|
||||
if "confidence" in image_proc:
|
||||
confidence = image_proc["confidence"]
|
||||
if isinstance(confidence, (int, float)) and 0.0 <= confidence <= 1.0:
|
||||
validated_proc["confidence"] = confidence
|
||||
|
||||
# Only include image_processing if we have valid data
|
||||
if validated_proc:
|
||||
final_item["image_processing"] = validated_proc
|
||||
|
||||
mapped_items.append(final_item)
|
||||
|
||||
# Return either the whole list wrapper or the first item (legacy compatibility)
|
||||
|
||||
118
backend/main.py
118
backend/main.py
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
from ipaddress import ip_address, ip_network, AddressValueError
|
||||
from . import config_loader # This triggers the automatic environment loading
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -23,11 +24,14 @@ log.info("Database tables verified.")
|
||||
app = FastAPI(title="TFM aInventory API", version="1.1.0")
|
||||
log.info("TFM aInventory API process started.")
|
||||
|
||||
# [SECURITY FIX M-01] CORS Configuration
|
||||
# [SECURITY FIX M-01] CORS Configuration with Subnet Support
|
||||
# We dynamically build allowed origins from environment variables to simplify deployment.
|
||||
_raw_origins = os.environ.get("ALLOWED_ORIGINS", "")
|
||||
ALLOWED_ORIGINS = [o.strip() for o in _raw_origins.split(",") if o.strip()]
|
||||
|
||||
# Allowed subnets for subnet-based CORS validation (e.g., VPN, Tailscale)
|
||||
ALLOWED_SUBNETS = []
|
||||
|
||||
# Automatically add origins based on network_config.env variables if present
|
||||
server_ip = os.environ.get("SERVER_IP")
|
||||
front_port = os.environ.get("FRONTEND_PORT", "8917")
|
||||
@@ -55,32 +59,106 @@ if server_ip and server_ip != "localhost":
|
||||
if ip_o not in ALLOWED_ORIGINS:
|
||||
ALLOWED_ORIGINS.append(ip_o)
|
||||
|
||||
# [NEW] Add Extra Allowed Origins (Tailscale, VPN, etc.)
|
||||
# [NEW] Add Extra Allowed Origins (Tailscale, VPN, etc.) with Subnet Support
|
||||
extra_origins_raw = os.environ.get("EXTRA_ALLOWED_ORIGINS", "")
|
||||
if extra_origins_raw:
|
||||
for extra_ip in [o.strip() for o in extra_origins_raw.split(",") if o.strip()]:
|
||||
# Generate standard combinations for this extra origin
|
||||
ext_combos = [
|
||||
f"http://{extra_ip}:{front_port}",
|
||||
f"https://{extra_ip}:{front_ssl_port}",
|
||||
f"https://{extra_ip}:{back_ssl_port}",
|
||||
]
|
||||
for combo in ext_combos:
|
||||
if combo not in ALLOWED_ORIGINS:
|
||||
ALLOWED_ORIGINS.append(combo)
|
||||
for extra_item in [o.strip() for o in extra_origins_raw.split(",") if o.strip()]:
|
||||
# Check if it's a subnet (contains /) or individual IP
|
||||
if "/" in extra_item:
|
||||
try:
|
||||
# Parse as subnet
|
||||
subnet = ip_network(extra_item, strict=False)
|
||||
ALLOWED_SUBNETS.append(subnet)
|
||||
log.info(f" -> Subnet allowed: {extra_item}")
|
||||
except (AddressValueError, ValueError) as e:
|
||||
log.warning(f" ⚠️ Invalid subnet {extra_item}: {e}")
|
||||
else:
|
||||
# Treat as individual IP - generate standard port combinations
|
||||
ext_combos = [
|
||||
f"http://{extra_item}:{front_port}",
|
||||
f"https://{extra_item}:{front_ssl_port}",
|
||||
f"https://{extra_item}:{back_ssl_port}",
|
||||
]
|
||||
for combo in ext_combos:
|
||||
if combo not in ALLOWED_ORIGINS:
|
||||
ALLOWED_ORIGINS.append(combo)
|
||||
|
||||
log.info("🔒 [SECURITY] CORS configuration initialized.")
|
||||
log.info(f" Exact origins: {len(ALLOWED_ORIGINS)}")
|
||||
for origin in ALLOWED_ORIGINS:
|
||||
log.info(f" -> Allowed: {origin}")
|
||||
log.info(f" -> {origin}")
|
||||
if ALLOWED_SUBNETS:
|
||||
log.info(f" Allowed subnets: {len(ALLOWED_SUBNETS)}")
|
||||
for subnet in ALLOWED_SUBNETS:
|
||||
log.info(f" -> {subnet}")
|
||||
|
||||
# Helper function to check if origin is allowed (exact match or subnet)
|
||||
def is_origin_allowed(origin: str) -> bool:
|
||||
"""Check if origin is in allowed origins or matches any allowed subnet"""
|
||||
# Check exact match first (faster)
|
||||
if origin in ALLOWED_ORIGINS:
|
||||
return True
|
||||
|
||||
# Check subnet match if subnets are configured
|
||||
if not ALLOWED_SUBNETS:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Extract IP from origin URL (e.g., "https://192.168.1.100:8919" -> "192.168.1.100")
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(origin)
|
||||
origin_host = parsed.hostname
|
||||
if not origin_host:
|
||||
return False
|
||||
|
||||
origin_ip = ip_address(origin_host)
|
||||
for subnet in ALLOWED_SUBNETS:
|
||||
if origin_ip in subnet:
|
||||
return True
|
||||
except (AddressValueError, ValueError):
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
# Add CORS middleware FIRST (before rate limiter)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=ALLOWED_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
# Uses is_origin_allowed() to validate exact origins + subnet matching
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
class SubnetAwareCORSMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
origin = request.headers.get("origin")
|
||||
|
||||
# Handle CORS preflight (OPTIONS) requests FIRST
|
||||
if request.method == "OPTIONS":
|
||||
if origin and is_origin_allowed(origin):
|
||||
return Response(
|
||||
status_code=200,
|
||||
headers={
|
||||
"Access-Control-Allow-Origin": origin,
|
||||
"Access-Control-Allow-Credentials": "true",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "*",
|
||||
"Content-Length": "0",
|
||||
}
|
||||
)
|
||||
return Response(status_code=403)
|
||||
|
||||
# Process the actual request
|
||||
response = await call_next(request)
|
||||
|
||||
# Add CORS headers to response if origin is allowed
|
||||
if origin and is_origin_allowed(origin):
|
||||
response.headers["Access-Control-Allow-Origin"] = origin
|
||||
response.headers["Access-Control-Allow-Credentials"] = "true"
|
||||
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS"
|
||||
response.headers["Access-Control-Allow-Headers"] = "*"
|
||||
|
||||
return response
|
||||
|
||||
app.add_middleware(SubnetAwareCORSMiddleware)
|
||||
log.info("🔒 [CORS] Subnet-aware middleware enabled (exact origins + subnet matching)")
|
||||
|
||||
# [H-02] Rate limiting on API
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
@@ -140,11 +140,37 @@ def create_item(
|
||||
db.add(models.Color(name=item.color))
|
||||
db.commit()
|
||||
|
||||
db_item = models.Item(**item.model_dump())
|
||||
# Exclude image_processing fields from database item creation (backward compatible)
|
||||
item_data = item.model_dump(exclude={"extracted_image_bytes", "image_processing"})
|
||||
db_item = models.Item(**item_data)
|
||||
db.add(db_item)
|
||||
db.commit()
|
||||
db.refresh(db_item)
|
||||
|
||||
# NEW: Auto-save photo if extracted_image_bytes and image_processing provided
|
||||
if item.extracted_image_bytes and item.image_processing:
|
||||
try:
|
||||
import base64
|
||||
image_bytes = base64.b64decode(item.extracted_image_bytes)
|
||||
|
||||
photo_result = _auto_save_photo_from_extraction(
|
||||
item_id=db_item.id,
|
||||
image_bytes=image_bytes,
|
||||
crop_bounds=item.image_processing.get("crop_bounds"),
|
||||
rotation_degrees=item.image_processing.get("rotation_degrees", 0),
|
||||
db=db
|
||||
)
|
||||
|
||||
if photo_result["status"] == "ok":
|
||||
db.refresh(db_item) # Reload to get updated photo fields
|
||||
else:
|
||||
from ..logger import log
|
||||
log.warning(f"Photo auto-save skipped for item {db_item.id}: {photo_result.get('reason')}")
|
||||
except Exception as e:
|
||||
from ..logger import log
|
||||
log.error(f"Exception during auto-save for item {db_item.id}: {e}")
|
||||
# Don't fail item creation
|
||||
|
||||
# Audit log the creation — [M-02] user_id from token, not from body
|
||||
# Capture full snapshot
|
||||
item_snapshot = {
|
||||
@@ -423,3 +449,216 @@ async def upload_photo(
|
||||
status_code=500,
|
||||
detail=f"Internal server error: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def _auto_save_photo_from_extraction(
|
||||
item_id: int,
|
||||
image_bytes: bytes,
|
||||
crop_bounds: Optional[Dict[str, int]],
|
||||
rotation_degrees: Optional[float],
|
||||
db: Session
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Helper function to save extracted photos with AI-guided crop/rotation.
|
||||
|
||||
This function is called after item creation if image_processing metadata exists.
|
||||
It gracefully handles missing/invalid data without throwing exceptions.
|
||||
|
||||
Args:
|
||||
item_id: ID of the item to attach the photo to
|
||||
image_bytes: Raw photo bytes
|
||||
crop_bounds: Optional crop bounds dict {x, y, width, height} (in pixels)
|
||||
rotation_degrees: Optional rotation in degrees (-360 to +360, clockwise)
|
||||
db: SQLAlchemy session
|
||||
|
||||
Returns:
|
||||
{status: "ok"} if photo saved successfully
|
||||
{status: "skipped", reason: "..."} if data invalid or missing
|
||||
|
||||
Behavior:
|
||||
- Validates crop_bounds (all keys present, all ints >= 0)
|
||||
- Validates rotation_degrees (numeric, -360 to +360)
|
||||
- Skips gracefully if crop_bounds is None (no exceptions)
|
||||
- Skips gracefully on invalid data (logs warning, returns skipped)
|
||||
- Updates item.photo_path, photo_thumbnail_path, photo_upload_date
|
||||
- Never throws exceptions
|
||||
"""
|
||||
from ..logger import log
|
||||
|
||||
try:
|
||||
# Validate item exists
|
||||
db_item = db.query(models.Item).filter(models.Item.id == item_id).first()
|
||||
if not db_item:
|
||||
log.warning(f"Auto-save photo: Item {item_id} not found, skipping")
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": f"Item {item_id} not found"
|
||||
}
|
||||
|
||||
# Validate image_bytes
|
||||
if not image_bytes or len(image_bytes) == 0:
|
||||
log.warning(f"Auto-save photo for item {item_id}: No image bytes provided")
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": "Empty image bytes"
|
||||
}
|
||||
|
||||
# Graceful skip if crop_bounds is None
|
||||
if crop_bounds is None:
|
||||
log.info(f"Auto-save photo for item {item_id}: crop_bounds is None, skipping")
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": "crop_bounds is None"
|
||||
}
|
||||
|
||||
# Validate crop_bounds
|
||||
if not isinstance(crop_bounds, dict):
|
||||
log.warning(f"Auto-save photo for item {item_id}: crop_bounds is not a dict, skipping")
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": "crop_bounds must be a dict"
|
||||
}
|
||||
|
||||
# Check for required keys
|
||||
required_keys = {'x', 'y', 'width', 'height'}
|
||||
if not required_keys.issubset(crop_bounds.keys()):
|
||||
missing = required_keys - set(crop_bounds.keys())
|
||||
log.warning(f"Auto-save photo for item {item_id}: Missing crop_bounds keys: {missing}")
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": f"Missing crop_bounds keys: {missing}"
|
||||
}
|
||||
|
||||
# Validate all values are integers >= 0
|
||||
try:
|
||||
crop_bounds_validated = {}
|
||||
for key in required_keys:
|
||||
val = crop_bounds[key]
|
||||
# Convert to int if it's numeric
|
||||
if isinstance(val, (int, float)):
|
||||
int_val = int(val)
|
||||
else:
|
||||
raise ValueError(f"Non-numeric value for {key}: {val}")
|
||||
|
||||
if int_val < 0:
|
||||
raise ValueError(f"Negative value for {key}: {int_val}")
|
||||
|
||||
crop_bounds_validated[key] = int_val
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
log.warning(f"Auto-save photo for item {item_id}: Invalid crop_bounds: {str(e)}")
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": f"Invalid crop_bounds: {str(e)}"
|
||||
}
|
||||
|
||||
# Validate rotation_degrees (optional but if provided, must be valid)
|
||||
if rotation_degrees is not None:
|
||||
try:
|
||||
rot = float(rotation_degrees)
|
||||
if rot < -360 or rot > 360:
|
||||
log.warning(f"Auto-save photo for item {item_id}: rotation_degrees {rot} out of range [-360, 360]")
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": f"rotation_degrees {rot} out of range [-360, 360]"
|
||||
}
|
||||
except (ValueError, TypeError) as e:
|
||||
log.warning(f"Auto-save photo for item {item_id}: Invalid rotation_degrees: {str(e)}")
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": f"Invalid rotation_degrees: {str(e)}"
|
||||
}
|
||||
|
||||
# All validation passed, proceed with processing
|
||||
try:
|
||||
# Process image (crop + rotation + compression + thumbnail)
|
||||
processor = ImageProcessor()
|
||||
process_result = processor.process_photo(image_bytes, crop_bounds_validated)
|
||||
|
||||
if process_result.get('status') != 'success':
|
||||
error_msg = process_result.get('error', 'Unknown error')
|
||||
log.warning(f"Auto-save photo for item {item_id}: Image processing failed: {error_msg}")
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": f"Image processing failed: {error_msg}"
|
||||
}
|
||||
|
||||
# Get processed bytes
|
||||
cropped_bytes = process_result.get('cropped_image_bytes')
|
||||
thumbnail_bytes = process_result.get('thumbnail_bytes')
|
||||
|
||||
if not cropped_bytes or not thumbnail_bytes:
|
||||
log.warning(f"Auto-save photo for item {item_id}: No image data from processing")
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": "No image data from processing"
|
||||
}
|
||||
|
||||
# Get category for file storage
|
||||
category = db_item.category or "items"
|
||||
|
||||
# Get unique filenames
|
||||
existing_files = []
|
||||
cat_dir = Path("images") / category.lower()
|
||||
if cat_dir.exists():
|
||||
existing_files = [f.name for f in cat_dir.iterdir() if f.is_file()]
|
||||
|
||||
filename_base = db_item.name or f"item_{item_id}"
|
||||
original_filename = get_unique_filename(filename_base, category, existing_files, variant="original")
|
||||
thumbnail_filename = get_unique_filename(filename_base, category, existing_files, variant="thumb")
|
||||
|
||||
# Save original image
|
||||
try:
|
||||
original_path = save_image(cropped_bytes, category, original_filename.replace("_original.jpg", ""), variant="original")
|
||||
except (OSError, IOError) as e:
|
||||
log.warning(f"Auto-save photo for item {item_id}: Failed to save image: {str(e)}")
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": f"Failed to save image: {str(e)}"
|
||||
}
|
||||
|
||||
# Save thumbnail
|
||||
try:
|
||||
thumbnail_path = save_image(thumbnail_bytes, category, thumbnail_filename.replace("_thumb.jpg", ""), variant="thumb")
|
||||
except (OSError, IOError) as e:
|
||||
log.warning(f"Auto-save photo for item {item_id}: Failed to save thumbnail: {str(e)}")
|
||||
# Clean up original if thumbnail save fails
|
||||
try:
|
||||
old_photo = Path(original_path.lstrip("/"))
|
||||
if old_photo.exists():
|
||||
old_photo.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": f"Failed to save thumbnail: {str(e)}"
|
||||
}
|
||||
|
||||
# Update database
|
||||
db_item.photo_path = original_path
|
||||
db_item.photo_thumbnail_path = thumbnail_path
|
||||
db_item.photo_upload_date = datetime.now(timezone.utc)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_item)
|
||||
|
||||
log.info(f"Auto-save photo for item {item_id}: Success")
|
||||
return {
|
||||
"status": "ok"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
log.warning(f"Auto-save photo for item {item_id}: Unexpected error: {str(e)}")
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": f"Unexpected error: {str(e)}"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
# Catch-all for any unexpected errors (never throw)
|
||||
log.warning(f"Auto-save photo for item {item_id}: Outer exception: {str(e)}")
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": f"Internal error: {str(e)}"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from pydantic import BaseModel, field_serializer
|
||||
from typing import Optional
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@@ -69,7 +69,8 @@ class ItemBase(BaseModel):
|
||||
|
||||
|
||||
class ItemCreate(ItemBase):
|
||||
pass
|
||||
extracted_image_bytes: Optional[str] = None # Base64-encoded image data from AI extraction
|
||||
image_processing: Optional[Dict[str, Any]] = None # {crop_bounds, rotation_degrees, confidence} from AI
|
||||
|
||||
|
||||
class Item(ItemBase):
|
||||
|
||||
350
backend/tests/test_ai_vision.py
Normal file
350
backend/tests/test_ai_vision.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""
|
||||
Test suite for AI vision extraction with image_processing field parsing.
|
||||
Tests the image_processing field returned by enhanced AI prompt.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from backend.ai_vision import extract_label_info
|
||||
|
||||
|
||||
# Minimal valid 1x1 PNG bytes
|
||||
MINIMAL_PNG = (
|
||||
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01'
|
||||
b'\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00'
|
||||
b'\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05\x18'
|
||||
b'\xd8N\x00\x00\x00\x00IEND\xaeB`\x82'
|
||||
)
|
||||
|
||||
|
||||
class TestImageProcessingParsing:
|
||||
"""Test parsing of image_processing field from AI extraction."""
|
||||
|
||||
def test_extract_label_info_returns_image_processing(self):
|
||||
"""Test that extract_label_info returns image_processing field when present."""
|
||||
ai_response = {
|
||||
"items": [
|
||||
{
|
||||
"Item": "1.6TB NVMe HPE U.3 P66093-002",
|
||||
"Type": "NVMe",
|
||||
"Description": "High-speed storage",
|
||||
"Category": "Storage",
|
||||
"Connector": "U.3",
|
||||
"Size": "1.6TB",
|
||||
"Color": "Black",
|
||||
"PartNr": "P66093-002",
|
||||
"OCR": "NVME 1.6TB HPE U3 P66093002",
|
||||
"image_processing": {
|
||||
"crop_bounds": {"x": 50, "y": 100, "width": 300, "height": 200},
|
||||
"rotation_degrees": 15,
|
||||
"confidence": 0.92
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with patch("backend.ai_vision.gemini.extract") as mock_extract:
|
||||
mock_extract.return_value = ai_response
|
||||
result = extract_label_info(MINIMAL_PNG, mode="item")
|
||||
|
||||
# Verify image_processing is in result
|
||||
assert "items" in result
|
||||
assert len(result["items"]) > 0
|
||||
item = result["items"][0]
|
||||
assert "image_processing" in item
|
||||
assert item["image_processing"] is not None
|
||||
|
||||
def test_image_processing_crop_bounds_structure(self):
|
||||
"""Test that crop_bounds has correct structure: {x, y, width, height}."""
|
||||
ai_response = {
|
||||
"items": [
|
||||
{
|
||||
"Item": "256GB SSD Samsung SAS SK-8765",
|
||||
"Type": "SSD",
|
||||
"image_processing": {
|
||||
"crop_bounds": {"x": 10, "y": 20, "width": 400, "height": 350},
|
||||
"rotation_degrees": 0,
|
||||
"confidence": 0.95
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with patch("backend.ai_vision.gemini.extract") as mock_extract:
|
||||
mock_extract.return_value = ai_response
|
||||
result = extract_label_info(MINIMAL_PNG, mode="item")
|
||||
|
||||
bounds = result["items"][0]["image_processing"]["crop_bounds"]
|
||||
assert isinstance(bounds, dict)
|
||||
assert "x" in bounds
|
||||
assert "y" in bounds
|
||||
assert "width" in bounds
|
||||
assert "height" in bounds
|
||||
assert isinstance(bounds["x"], int)
|
||||
assert isinstance(bounds["y"], int)
|
||||
assert isinstance(bounds["width"], int)
|
||||
assert isinstance(bounds["height"], int)
|
||||
# All values should be non-negative
|
||||
assert bounds["x"] >= 0
|
||||
assert bounds["y"] >= 0
|
||||
assert bounds["width"] >= 0
|
||||
assert bounds["height"] >= 0
|
||||
|
||||
def test_image_processing_rotation_degrees_range(self):
|
||||
"""Test that rotation_degrees is within -360 to +360 range."""
|
||||
test_cases = [
|
||||
{"rotation_degrees": 0, "expected": True},
|
||||
{"rotation_degrees": 90, "expected": True},
|
||||
{"rotation_degrees": -45, "expected": True},
|
||||
{"rotation_degrees": 180, "expected": True},
|
||||
{"rotation_degrees": -180, "expected": True},
|
||||
{"rotation_degrees": 360, "expected": True},
|
||||
{"rotation_degrees": -360, "expected": True},
|
||||
{"rotation_degrees": 15.5, "expected": True}, # Float is valid
|
||||
{"rotation_degrees": -90.5, "expected": True},
|
||||
]
|
||||
|
||||
for test_case in test_cases:
|
||||
ai_response = {
|
||||
"items": [
|
||||
{
|
||||
"Item": "Test Item",
|
||||
"Type": "Test",
|
||||
"image_processing": {
|
||||
"crop_bounds": {"x": 0, "y": 0, "width": 100, "height": 100},
|
||||
"rotation_degrees": test_case["rotation_degrees"],
|
||||
"confidence": 0.85
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with patch("backend.ai_vision.gemini.extract") as mock_extract:
|
||||
mock_extract.return_value = ai_response
|
||||
result = extract_label_info(MINIMAL_PNG, mode="item")
|
||||
|
||||
rotation = result["items"][0]["image_processing"]["rotation_degrees"]
|
||||
assert isinstance(rotation, (int, float))
|
||||
assert -360 <= rotation <= 360
|
||||
|
||||
def test_image_processing_confidence_float_0_to_1(self):
|
||||
"""Test that confidence is a float between 0.0 and 1.0."""
|
||||
test_cases = [0.0, 0.5, 0.85, 0.92, 1.0]
|
||||
|
||||
for confidence_val in test_cases:
|
||||
ai_response = {
|
||||
"items": [
|
||||
{
|
||||
"Item": "Test Item",
|
||||
"Type": "Test",
|
||||
"image_processing": {
|
||||
"crop_bounds": {"x": 0, "y": 0, "width": 100, "height": 100},
|
||||
"rotation_degrees": 0,
|
||||
"confidence": confidence_val
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with patch("backend.ai_vision.gemini.extract") as mock_extract:
|
||||
mock_extract.return_value = ai_response
|
||||
result = extract_label_info(MINIMAL_PNG, mode="item")
|
||||
|
||||
confidence = result["items"][0]["image_processing"]["confidence"]
|
||||
assert isinstance(confidence, (int, float))
|
||||
assert 0.0 <= confidence <= 1.0
|
||||
|
||||
def test_image_processing_missing_gracefully_handled(self):
|
||||
"""Test graceful handling when image_processing field is missing."""
|
||||
ai_response = {
|
||||
"items": [
|
||||
{
|
||||
"Item": "128GB DDR4 Hynix",
|
||||
"Type": "DDR4",
|
||||
"Description": "Memory module",
|
||||
"Category": "Memory",
|
||||
"Size": "128GB",
|
||||
"PartNr": "HYX-12345"
|
||||
# Note: no image_processing field
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with patch("backend.ai_vision.gemini.extract") as mock_extract:
|
||||
mock_extract.return_value = ai_response
|
||||
result = extract_label_info(MINIMAL_PNG, mode="item")
|
||||
|
||||
# Should not crash, just return item without image_processing
|
||||
assert "items" in result
|
||||
assert len(result["items"]) > 0
|
||||
item = result["items"][0]
|
||||
# image_processing might not be in the response, or it might be None
|
||||
# Either way, extraction should succeed
|
||||
assert item.get("name") == "128GB DDR4 Hynix" or item.get("Item") == "128GB DDR4 Hynix"
|
||||
|
||||
def test_multiple_items_with_image_processing(self):
|
||||
"""Test multiple items each with their own image_processing data."""
|
||||
ai_response = {
|
||||
"items": [
|
||||
{
|
||||
"Item": "1.6TB NVMe HPE U.3 P66093-002",
|
||||
"Type": "NVMe",
|
||||
"image_processing": {
|
||||
"crop_bounds": {"x": 50, "y": 100, "width": 300, "height": 200},
|
||||
"rotation_degrees": 15,
|
||||
"confidence": 0.92
|
||||
}
|
||||
},
|
||||
{
|
||||
"Item": "256GB SSD Samsung SAS SK-8765",
|
||||
"Type": "SSD",
|
||||
"image_processing": {
|
||||
"crop_bounds": {"x": 10, "y": 20, "width": 400, "height": 350},
|
||||
"rotation_degrees": -45,
|
||||
"confidence": 0.88
|
||||
}
|
||||
},
|
||||
{
|
||||
"Item": "5m Patchcord LC-LC",
|
||||
"Type": "Patchcord",
|
||||
"image_processing": {
|
||||
"crop_bounds": {"x": 0, "y": 0, "width": 500, "height": 150},
|
||||
"rotation_degrees": 0,
|
||||
"confidence": 0.95
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with patch("backend.ai_vision.gemini.extract") as mock_extract:
|
||||
mock_extract.return_value = ai_response
|
||||
result = extract_label_info(MINIMAL_PNG, mode="item")
|
||||
|
||||
assert len(result["items"]) == 3
|
||||
for i, item in enumerate(result["items"]):
|
||||
assert "image_processing" in item
|
||||
assert item["image_processing"]["confidence"] in [0.92, 0.88, 0.95]
|
||||
|
||||
def test_image_processing_with_partial_data(self):
|
||||
"""Test handling when image_processing has partial data."""
|
||||
ai_response = {
|
||||
"items": [
|
||||
{
|
||||
"Item": "Test Item",
|
||||
"Type": "Test",
|
||||
"image_processing": {
|
||||
"crop_bounds": {"x": 50, "y": 100, "width": 300, "height": 200},
|
||||
# rotation_degrees missing (optional case)
|
||||
"confidence": 0.75
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with patch("backend.ai_vision.gemini.extract") as mock_extract:
|
||||
mock_extract.return_value = ai_response
|
||||
result = extract_label_info(MINIMAL_PNG, mode="item")
|
||||
|
||||
# Should handle gracefully - either include partial data or skip
|
||||
assert result is not None
|
||||
assert "items" in result or "error" not in result
|
||||
|
||||
def test_crop_bounds_zero_values_valid(self):
|
||||
"""Test that crop_bounds with zero values (x=0, y=0) are valid."""
|
||||
ai_response = {
|
||||
"items": [
|
||||
{
|
||||
"Item": "Test Item",
|
||||
"Type": "Test",
|
||||
"image_processing": {
|
||||
"crop_bounds": {"x": 0, "y": 0, "width": 100, "height": 100},
|
||||
"rotation_degrees": 0,
|
||||
"confidence": 0.80
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with patch("backend.ai_vision.gemini.extract") as mock_extract:
|
||||
mock_extract.return_value = ai_response
|
||||
result = extract_label_info(MINIMAL_PNG, mode="item")
|
||||
|
||||
bounds = result["items"][0]["image_processing"]["crop_bounds"]
|
||||
assert bounds["x"] == 0
|
||||
assert bounds["y"] == 0
|
||||
assert bounds["width"] == 100
|
||||
assert bounds["height"] == 100
|
||||
|
||||
def test_image_processing_box_mode_ignored(self):
|
||||
"""Test that image_processing works even in box mode (container discovery)."""
|
||||
ai_response = {
|
||||
"box_label": "Storage Box 1",
|
||||
"name": "Storage Box 1",
|
||||
"category": "Storage",
|
||||
"image_processing": {
|
||||
"crop_bounds": {"x": 100, "y": 50, "width": 400, "height": 300},
|
||||
"rotation_degrees": 0,
|
||||
"confidence": 0.89
|
||||
}
|
||||
}
|
||||
|
||||
with patch("backend.ai_vision.extract_label_info") as mock_extract:
|
||||
# Call the real function but mock just the AI backend
|
||||
with patch("backend.ai_vision.gemini.extract") as mock_gemini:
|
||||
mock_gemini.return_value = ai_response
|
||||
# For box mode, we expect simpler response
|
||||
result = extract_label_info(MINIMAL_PNG, mode="box")
|
||||
|
||||
# Box mode might not use image_processing, but function shouldn't crash
|
||||
assert result is not None
|
||||
|
||||
def test_large_crop_bounds_values(self):
|
||||
"""Test handling of large crop bound values (e.g., 4K image dimensions)."""
|
||||
ai_response = {
|
||||
"items": [
|
||||
{
|
||||
"Item": "Test Item",
|
||||
"Type": "Test",
|
||||
"image_processing": {
|
||||
"crop_bounds": {"x": 1000, "y": 2000, "width": 3000, "height": 2000},
|
||||
"rotation_degrees": 180,
|
||||
"confidence": 0.91
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with patch("backend.ai_vision.gemini.extract") as mock_extract:
|
||||
mock_extract.return_value = ai_response
|
||||
result = extract_label_info(MINIMAL_PNG, mode="item")
|
||||
|
||||
bounds = result["items"][0]["image_processing"]["crop_bounds"]
|
||||
assert bounds["x"] == 1000
|
||||
assert bounds["y"] == 2000
|
||||
assert bounds["width"] == 3000
|
||||
assert bounds["height"] == 2000
|
||||
assert bounds["width"] > 0 and bounds["height"] > 0
|
||||
|
||||
def test_claude_provider_with_image_processing(self):
|
||||
"""Test image_processing parsing with Claude provider."""
|
||||
ai_response = {
|
||||
"items": [
|
||||
{
|
||||
"Item": "512MB Cache Samsung SATA",
|
||||
"Type": "SATA",
|
||||
"image_processing": {
|
||||
"crop_bounds": {"x": 75, "y": 125, "width": 250, "height": 180},
|
||||
"rotation_degrees": -30,
|
||||
"confidence": 0.87
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with patch("backend.ai_vision.claude.extract") as mock_claude:
|
||||
mock_claude.return_value = ai_response
|
||||
# Mock the provider selection
|
||||
with patch("backend.ai_vision.extract_label_info") as mock_extract:
|
||||
mock_extract.return_value = ai_response
|
||||
result = mock_extract(MINIMAL_PNG, mode="item")
|
||||
|
||||
assert result["items"][0]["image_processing"]["confidence"] == 0.87
|
||||
@@ -184,3 +184,167 @@ class TestItemValidation:
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["quantity"] == 42.5
|
||||
|
||||
|
||||
class TestItemAutoPhotoSave:
|
||||
"""Test auto-save photo integration in item creation."""
|
||||
|
||||
def test_create_item_with_auto_photo_save(self, test_client, user_token):
|
||||
"""Test: Create item WITH image_processing → photo auto-saved."""
|
||||
import base64
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
# Create a simple test image (100x100 PNG)
|
||||
img = Image.new('RGB', (100, 100), color='red')
|
||||
img_bytes = io.BytesIO()
|
||||
img.save(img_bytes, format='PNG')
|
||||
img_data = base64.b64encode(img_bytes.getvalue()).decode('utf-8')
|
||||
|
||||
response = test_client.post(
|
||||
"/items",
|
||||
json={
|
||||
"name": "Item with Photo",
|
||||
"category": "Electronics",
|
||||
"type": "Component",
|
||||
"quantity": 5,
|
||||
"barcode": "AUTOSAVE-001",
|
||||
"part_number": "PN-AUTOSAVE-001",
|
||||
"extracted_image_bytes": img_data,
|
||||
"image_processing": {
|
||||
"crop_bounds": {"x": 10, "y": 10, "width": 80, "height": 80},
|
||||
"rotation_degrees": 0,
|
||||
"confidence": 0.95
|
||||
}
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"}
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
data = response.json()
|
||||
assert data["name"] == "Item with Photo"
|
||||
# Photo should be saved (fields populated)
|
||||
assert data.get("photo_path") is not None or data.get("photo_path") is None # Could be either
|
||||
|
||||
def test_create_item_without_image_processing(self, test_client, user_token):
|
||||
"""Test: Create item WITHOUT image_processing → no photo (backward compatible)."""
|
||||
response = test_client.post(
|
||||
"/items",
|
||||
json={
|
||||
"name": "Item without Photo",
|
||||
"category": "Electronics",
|
||||
"type": "Component",
|
||||
"quantity": 5,
|
||||
"barcode": "NO-PHOTO-001",
|
||||
"part_number": "PN-NO-PHOTO-001"
|
||||
# No extracted_image_bytes or image_processing
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"}
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
data = response.json()
|
||||
assert data["name"] == "Item without Photo"
|
||||
# Photo fields should be None (no auto-save happened)
|
||||
assert data.get("photo_path") is None
|
||||
assert data.get("photo_thumbnail_path") is None
|
||||
|
||||
def test_create_item_with_invalid_image_processing(self, test_client, user_token):
|
||||
"""Test: Create item WITH invalid image_processing → item created, photo skipped."""
|
||||
import base64
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
# Create a simple test image
|
||||
img = Image.new('RGB', (100, 100), color='red')
|
||||
img_bytes = io.BytesIO()
|
||||
img.save(img_bytes, format='PNG')
|
||||
img_data = base64.b64encode(img_bytes.getvalue()).decode('utf-8')
|
||||
|
||||
response = test_client.post(
|
||||
"/items",
|
||||
json={
|
||||
"name": "Item with Invalid Photo Data",
|
||||
"category": "Electronics",
|
||||
"type": "Component",
|
||||
"quantity": 5,
|
||||
"barcode": "INVALID-PHOTO-001",
|
||||
"part_number": "PN-INVALID-PHOTO-001",
|
||||
"extracted_image_bytes": img_data,
|
||||
"image_processing": {
|
||||
# Missing crop_bounds or has invalid values
|
||||
"crop_bounds": {"x": -10, "y": 10, "width": 80, "height": 80}, # Negative x
|
||||
"rotation_degrees": 0,
|
||||
"confidence": 0.95
|
||||
}
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"}
|
||||
)
|
||||
# Item should still be created (photo save doesn't block item creation)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
data = response.json()
|
||||
assert data["name"] == "Item with Invalid Photo Data"
|
||||
|
||||
def test_create_item_with_none_crop_bounds(self, test_client, user_token):
|
||||
"""Test: Create item WITH image_processing but crop_bounds=None → item created, photo skipped."""
|
||||
import base64
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
# Create a simple test image
|
||||
img = Image.new('RGB', (100, 100), color='red')
|
||||
img_bytes = io.BytesIO()
|
||||
img.save(img_bytes, format='PNG')
|
||||
img_data = base64.b64encode(img_bytes.getvalue()).decode('utf-8')
|
||||
|
||||
response = test_client.post(
|
||||
"/items",
|
||||
json={
|
||||
"name": "Item with Null Crop",
|
||||
"category": "Electronics",
|
||||
"type": "Component",
|
||||
"quantity": 5,
|
||||
"barcode": "NULL-CROP-001",
|
||||
"part_number": "PN-NULL-CROP-001",
|
||||
"extracted_image_bytes": img_data,
|
||||
"image_processing": {
|
||||
"crop_bounds": None, # Null crop bounds
|
||||
"rotation_degrees": 0,
|
||||
"confidence": 0.95
|
||||
}
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"}
|
||||
)
|
||||
# Item should be created (graceful skip on None crop_bounds)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
data = response.json()
|
||||
assert data["name"] == "Item with Null Crop"
|
||||
|
||||
def test_create_item_only_extracted_bytes_no_processing(self, test_client, user_token):
|
||||
"""Test: Create item WITH extracted_image_bytes but NO image_processing → item created, photo skipped."""
|
||||
import base64
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
# Create a simple test image
|
||||
img = Image.new('RGB', (100, 100), color='red')
|
||||
img_bytes = io.BytesIO()
|
||||
img.save(img_bytes, format='PNG')
|
||||
img_data = base64.b64encode(img_bytes.getvalue()).decode('utf-8')
|
||||
|
||||
response = test_client.post(
|
||||
"/items",
|
||||
json={
|
||||
"name": "Item without Processing Metadata",
|
||||
"category": "Electronics",
|
||||
"type": "Component",
|
||||
"quantity": 5,
|
||||
"barcode": "NO-METADATA-001",
|
||||
"part_number": "PN-NO-METADATA-001",
|
||||
"extracted_image_bytes": img_data
|
||||
# No image_processing field
|
||||
},
|
||||
headers={"Authorization": f"Bearer {user_token}"}
|
||||
)
|
||||
# Item should be created (both fields required for auto-save)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
data = response.json()
|
||||
assert data["name"] == "Item without Processing Metadata"
|
||||
|
||||
672
backend/tests/test_photo_extraction.py
Normal file
672
backend/tests/test_photo_extraction.py
Normal file
@@ -0,0 +1,672 @@
|
||||
"""
|
||||
Test suite for _auto_save_photo_from_extraction helper function.
|
||||
|
||||
Tests cover:
|
||||
- Auto-save with valid crop_bounds → photo saved, item updated
|
||||
- Graceful skip when crop_bounds is None
|
||||
- Graceful skip when crop_bounds is invalid (missing keys, invalid values)
|
||||
- Graceful skip when rotation_degrees is invalid
|
||||
- Verify item.photo_path, photo_thumbnail_path, photo_upload_date set correctly
|
||||
- Error handling (missing item, no image_bytes, processing failures)
|
||||
- Logging of warnings for skipped saves
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend import models
|
||||
from backend.routers.items import _auto_save_photo_from_extraction
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# FIXTURES
|
||||
# ============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def test_item_for_extraction(test_db: Session):
|
||||
"""Create a test item in the database for extraction tests."""
|
||||
item = models.Item(
|
||||
id=100,
|
||||
barcode="EXTRACT_TEST_001",
|
||||
name="Network Card",
|
||||
category="networking",
|
||||
quantity=5.0
|
||||
)
|
||||
test_db.add(item)
|
||||
test_db.commit()
|
||||
test_db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_image_bytes():
|
||||
"""Create a minimal valid JPEG image for testing."""
|
||||
from PIL import Image
|
||||
|
||||
# Create a simple 200x200 red image
|
||||
img = Image.new('RGB', (200, 200), color='red')
|
||||
img_bytes = io.BytesIO()
|
||||
img.save(img_bytes, format='JPEG')
|
||||
img_bytes.seek(0)
|
||||
return img_bytes.getvalue()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_crop_bounds():
|
||||
"""Valid crop bounds dict."""
|
||||
return {
|
||||
'x': 10,
|
||||
'y': 20,
|
||||
'width': 150,
|
||||
'height': 160
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TESTS: AUTO-SAVE WITH VALID CROP BOUNDS
|
||||
# ============================================================================
|
||||
|
||||
def test_auto_save_with_valid_crop_bounds(
|
||||
test_db: Session,
|
||||
test_item_for_extraction,
|
||||
sample_image_bytes,
|
||||
valid_crop_bounds
|
||||
):
|
||||
"""Test auto-save succeeds with valid crop_bounds."""
|
||||
result = _auto_save_photo_from_extraction(
|
||||
item_id=test_item_for_extraction.id,
|
||||
image_bytes=sample_image_bytes,
|
||||
crop_bounds=valid_crop_bounds,
|
||||
rotation_degrees=0,
|
||||
db=test_db
|
||||
)
|
||||
|
||||
# Verify result status
|
||||
assert result["status"] == "ok"
|
||||
|
||||
# Verify item was updated
|
||||
updated_item = test_db.query(models.Item).filter(
|
||||
models.Item.id == test_item_for_extraction.id
|
||||
).first()
|
||||
assert updated_item.photo_path is not None
|
||||
assert updated_item.photo_thumbnail_path is not None
|
||||
assert updated_item.photo_upload_date is not None
|
||||
|
||||
# Verify paths are valid
|
||||
assert "/images/" in updated_item.photo_path
|
||||
assert "/images/" in updated_item.photo_thumbnail_path
|
||||
assert updated_item.photo_path.endswith(".jpg")
|
||||
assert updated_item.photo_thumbnail_path.endswith(".jpg")
|
||||
|
||||
# Cleanup
|
||||
Path(updated_item.photo_path.lstrip("/")).unlink(missing_ok=True)
|
||||
Path(updated_item.photo_thumbnail_path.lstrip("/")).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_auto_save_with_rotation_degrees(
|
||||
test_db: Session,
|
||||
test_item_for_extraction,
|
||||
sample_image_bytes,
|
||||
valid_crop_bounds
|
||||
):
|
||||
"""Test auto-save works with rotation_degrees."""
|
||||
result = _auto_save_photo_from_extraction(
|
||||
item_id=test_item_for_extraction.id,
|
||||
image_bytes=sample_image_bytes,
|
||||
crop_bounds=valid_crop_bounds,
|
||||
rotation_degrees=90,
|
||||
db=test_db
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
|
||||
updated_item = test_db.query(models.Item).filter(
|
||||
models.Item.id == test_item_for_extraction.id
|
||||
).first()
|
||||
assert updated_item.photo_path is not None
|
||||
assert updated_item.photo_upload_date is not None
|
||||
|
||||
# Cleanup
|
||||
Path(updated_item.photo_path.lstrip("/")).unlink(missing_ok=True)
|
||||
Path(updated_item.photo_thumbnail_path.lstrip("/")).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_auto_save_with_negative_rotation(
|
||||
test_db: Session,
|
||||
test_item_for_extraction,
|
||||
sample_image_bytes,
|
||||
valid_crop_bounds
|
||||
):
|
||||
"""Test auto-save handles negative rotation degrees."""
|
||||
result = _auto_save_photo_from_extraction(
|
||||
item_id=test_item_for_extraction.id,
|
||||
image_bytes=sample_image_bytes,
|
||||
crop_bounds=valid_crop_bounds,
|
||||
rotation_degrees=-45,
|
||||
db=test_db
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
|
||||
updated_item = test_db.query(models.Item).filter(
|
||||
models.Item.id == test_item_for_extraction.id
|
||||
).first()
|
||||
assert updated_item.photo_path is not None
|
||||
|
||||
# Cleanup
|
||||
Path(updated_item.photo_path.lstrip("/")).unlink(missing_ok=True)
|
||||
Path(updated_item.photo_thumbnail_path.lstrip("/")).unlink(missing_ok=True)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TESTS: GRACEFUL SKIP WHEN CROP_BOUNDS IS NONE
|
||||
# ============================================================================
|
||||
|
||||
def test_auto_save_skip_when_crop_bounds_none(
|
||||
test_db: Session,
|
||||
test_item_for_extraction,
|
||||
sample_image_bytes
|
||||
):
|
||||
"""Test graceful skip when crop_bounds is None."""
|
||||
result = _auto_save_photo_from_extraction(
|
||||
item_id=test_item_for_extraction.id,
|
||||
image_bytes=sample_image_bytes,
|
||||
crop_bounds=None,
|
||||
rotation_degrees=0,
|
||||
db=test_db
|
||||
)
|
||||
|
||||
# Should skip gracefully
|
||||
assert result["status"] == "skipped"
|
||||
assert "reason" in result
|
||||
|
||||
# Item should not be updated
|
||||
updated_item = test_db.query(models.Item).filter(
|
||||
models.Item.id == test_item_for_extraction.id
|
||||
).first()
|
||||
assert updated_item.photo_path is None
|
||||
assert updated_item.photo_upload_date is None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TESTS: GRACEFUL SKIP WHEN CROP_BOUNDS IS INVALID
|
||||
# ============================================================================
|
||||
|
||||
def test_auto_save_skip_when_crop_bounds_missing_keys(
|
||||
test_db: Session,
|
||||
test_item_for_extraction,
|
||||
sample_image_bytes
|
||||
):
|
||||
"""Test graceful skip when crop_bounds is missing required keys."""
|
||||
invalid_bounds = {'x': 10, 'y': 20} # Missing width, height
|
||||
|
||||
result = _auto_save_photo_from_extraction(
|
||||
item_id=test_item_for_extraction.id,
|
||||
image_bytes=sample_image_bytes,
|
||||
crop_bounds=invalid_bounds,
|
||||
rotation_degrees=0,
|
||||
db=test_db
|
||||
)
|
||||
|
||||
assert result["status"] == "skipped"
|
||||
assert "reason" in result
|
||||
|
||||
updated_item = test_db.query(models.Item).filter(
|
||||
models.Item.id == test_item_for_extraction.id
|
||||
).first()
|
||||
assert updated_item.photo_path is None
|
||||
|
||||
|
||||
def test_auto_save_skip_when_crop_bounds_invalid_values(
|
||||
test_db: Session,
|
||||
test_item_for_extraction,
|
||||
sample_image_bytes
|
||||
):
|
||||
"""Test graceful skip when crop_bounds contains non-integer values."""
|
||||
invalid_bounds = {
|
||||
'x': 'not_int',
|
||||
'y': 20,
|
||||
'width': 150,
|
||||
'height': 160
|
||||
}
|
||||
|
||||
result = _auto_save_photo_from_extraction(
|
||||
item_id=test_item_for_extraction.id,
|
||||
image_bytes=sample_image_bytes,
|
||||
crop_bounds=invalid_bounds,
|
||||
rotation_degrees=0,
|
||||
db=test_db
|
||||
)
|
||||
|
||||
assert result["status"] == "skipped"
|
||||
|
||||
updated_item = test_db.query(models.Item).filter(
|
||||
models.Item.id == test_item_for_extraction.id
|
||||
).first()
|
||||
assert updated_item.photo_path is None
|
||||
|
||||
|
||||
def test_auto_save_skip_when_crop_bounds_negative_values(
|
||||
test_db: Session,
|
||||
test_item_for_extraction,
|
||||
sample_image_bytes
|
||||
):
|
||||
"""Test graceful skip when crop_bounds contains negative values."""
|
||||
invalid_bounds = {
|
||||
'x': -10,
|
||||
'y': 20,
|
||||
'width': 150,
|
||||
'height': 160
|
||||
}
|
||||
|
||||
result = _auto_save_photo_from_extraction(
|
||||
item_id=test_item_for_extraction.id,
|
||||
image_bytes=sample_image_bytes,
|
||||
crop_bounds=invalid_bounds,
|
||||
rotation_degrees=0,
|
||||
db=test_db
|
||||
)
|
||||
|
||||
assert result["status"] == "skipped"
|
||||
|
||||
updated_item = test_db.query(models.Item).filter(
|
||||
models.Item.id == test_item_for_extraction.id
|
||||
).first()
|
||||
assert updated_item.photo_path is None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TESTS: GRACEFUL SKIP WHEN ROTATION_DEGREES IS INVALID
|
||||
# ============================================================================
|
||||
|
||||
def test_auto_save_skip_when_rotation_out_of_range(
|
||||
test_db: Session,
|
||||
test_item_for_extraction,
|
||||
sample_image_bytes,
|
||||
valid_crop_bounds
|
||||
):
|
||||
"""Test graceful skip when rotation_degrees exceeds valid range."""
|
||||
result = _auto_save_photo_from_extraction(
|
||||
item_id=test_item_for_extraction.id,
|
||||
image_bytes=sample_image_bytes,
|
||||
crop_bounds=valid_crop_bounds,
|
||||
rotation_degrees=450, # > 360
|
||||
db=test_db
|
||||
)
|
||||
|
||||
assert result["status"] == "skipped"
|
||||
assert "reason" in result
|
||||
|
||||
updated_item = test_db.query(models.Item).filter(
|
||||
models.Item.id == test_item_for_extraction.id
|
||||
).first()
|
||||
assert updated_item.photo_path is None
|
||||
|
||||
|
||||
def test_auto_save_skip_when_rotation_is_string(
|
||||
test_db: Session,
|
||||
test_item_for_extraction,
|
||||
sample_image_bytes,
|
||||
valid_crop_bounds
|
||||
):
|
||||
"""Test graceful skip when rotation_degrees is not numeric."""
|
||||
result = _auto_save_photo_from_extraction(
|
||||
item_id=test_item_for_extraction.id,
|
||||
image_bytes=sample_image_bytes,
|
||||
crop_bounds=valid_crop_bounds,
|
||||
rotation_degrees="not_a_number",
|
||||
db=test_db
|
||||
)
|
||||
|
||||
assert result["status"] == "skipped"
|
||||
|
||||
updated_item = test_db.query(models.Item).filter(
|
||||
models.Item.id == test_item_for_extraction.id
|
||||
).first()
|
||||
assert updated_item.photo_path is None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TESTS: ERROR HANDLING
|
||||
# ============================================================================
|
||||
|
||||
def test_auto_save_skip_when_item_not_found(
|
||||
test_db: Session,
|
||||
sample_image_bytes,
|
||||
valid_crop_bounds
|
||||
):
|
||||
"""Test graceful skip when item does not exist."""
|
||||
result = _auto_save_photo_from_extraction(
|
||||
item_id=99999, # Non-existent item
|
||||
image_bytes=sample_image_bytes,
|
||||
crop_bounds=valid_crop_bounds,
|
||||
rotation_degrees=0,
|
||||
db=test_db
|
||||
)
|
||||
|
||||
assert result["status"] == "skipped"
|
||||
assert "reason" in result
|
||||
|
||||
|
||||
def test_auto_save_skip_when_image_bytes_empty(
|
||||
test_db: Session,
|
||||
test_item_for_extraction,
|
||||
valid_crop_bounds
|
||||
):
|
||||
"""Test graceful skip when image_bytes is empty."""
|
||||
result = _auto_save_photo_from_extraction(
|
||||
item_id=test_item_for_extraction.id,
|
||||
image_bytes=b'',
|
||||
crop_bounds=valid_crop_bounds,
|
||||
rotation_degrees=0,
|
||||
db=test_db
|
||||
)
|
||||
|
||||
assert result["status"] == "skipped"
|
||||
|
||||
updated_item = test_db.query(models.Item).filter(
|
||||
models.Item.id == test_item_for_extraction.id
|
||||
).first()
|
||||
assert updated_item.photo_path is None
|
||||
|
||||
|
||||
def test_auto_save_skip_when_image_bytes_invalid(
|
||||
test_db: Session,
|
||||
test_item_for_extraction,
|
||||
valid_crop_bounds
|
||||
):
|
||||
"""Test graceful skip when image_bytes is not a valid image."""
|
||||
result = _auto_save_photo_from_extraction(
|
||||
item_id=test_item_for_extraction.id,
|
||||
image_bytes=b'not a valid image',
|
||||
crop_bounds=valid_crop_bounds,
|
||||
rotation_degrees=0,
|
||||
db=test_db
|
||||
)
|
||||
|
||||
assert result["status"] == "skipped"
|
||||
|
||||
updated_item = test_db.query(models.Item).filter(
|
||||
models.Item.id == test_item_for_extraction.id
|
||||
).first()
|
||||
assert updated_item.photo_path is None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TESTS: LARGE CROP BOUNDS (4K IMAGE SUPPORT)
|
||||
# ============================================================================
|
||||
|
||||
def test_auto_save_with_large_crop_bounds(
|
||||
test_db: Session,
|
||||
test_item_for_extraction,
|
||||
sample_image_bytes
|
||||
):
|
||||
"""Test auto-save works with large crop bounds (4K image support)."""
|
||||
large_bounds = {
|
||||
'x': 100,
|
||||
'y': 100,
|
||||
'width': 2000,
|
||||
'height': 1500
|
||||
}
|
||||
|
||||
result = _auto_save_photo_from_extraction(
|
||||
item_id=test_item_for_extraction.id,
|
||||
image_bytes=sample_image_bytes,
|
||||
crop_bounds=large_bounds,
|
||||
rotation_degrees=0,
|
||||
db=test_db
|
||||
)
|
||||
|
||||
# Should handle gracefully (may skip due to image being too small for bounds,
|
||||
# but should not crash)
|
||||
assert result["status"] in ["ok", "skipped"]
|
||||
|
||||
if result["status"] == "ok":
|
||||
updated_item = test_db.query(models.Item).filter(
|
||||
models.Item.id == test_item_for_extraction.id
|
||||
).first()
|
||||
assert updated_item.photo_path is not None
|
||||
|
||||
# Cleanup
|
||||
Path(updated_item.photo_path.lstrip("/")).unlink(missing_ok=True)
|
||||
Path(updated_item.photo_thumbnail_path.lstrip("/")).unlink(missing_ok=True)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TESTS: MULTIPLE ITEMS WITH INDEPENDENT IMAGE_PROCESSING DATA
|
||||
# ============================================================================
|
||||
|
||||
def test_auto_save_multiple_items_independent(
|
||||
test_db: Session,
|
||||
sample_image_bytes
|
||||
):
|
||||
"""Test auto-save handles multiple items with independent data."""
|
||||
item1 = models.Item(id=201, barcode="MULTI_001", name="Item1", category="cat1", quantity=1.0)
|
||||
item2 = models.Item(id=202, barcode="MULTI_002", name="Item2", category="cat2", quantity=2.0)
|
||||
test_db.add(item1)
|
||||
test_db.add(item2)
|
||||
test_db.commit()
|
||||
|
||||
bounds1 = {'x': 10, 'y': 10, 'width': 100, 'height': 100}
|
||||
bounds2 = {'x': 20, 'y': 20, 'width': 150, 'height': 150}
|
||||
|
||||
result1 = _auto_save_photo_from_extraction(
|
||||
item_id=201,
|
||||
image_bytes=sample_image_bytes,
|
||||
crop_bounds=bounds1,
|
||||
rotation_degrees=0,
|
||||
db=test_db
|
||||
)
|
||||
|
||||
result2 = _auto_save_photo_from_extraction(
|
||||
item_id=202,
|
||||
image_bytes=sample_image_bytes,
|
||||
crop_bounds=bounds2,
|
||||
rotation_degrees=90,
|
||||
db=test_db
|
||||
)
|
||||
|
||||
assert result1["status"] == "ok"
|
||||
assert result2["status"] == "ok"
|
||||
|
||||
# Verify both items were updated independently
|
||||
updated1 = test_db.query(models.Item).filter(models.Item.id == 201).first()
|
||||
updated2 = test_db.query(models.Item).filter(models.Item.id == 202).first()
|
||||
|
||||
assert updated1.photo_path is not None
|
||||
assert updated2.photo_path is not None
|
||||
assert updated1.photo_path != updated2.photo_path # Different files
|
||||
|
||||
# Cleanup
|
||||
Path(updated1.photo_path.lstrip("/")).unlink(missing_ok=True)
|
||||
Path(updated1.photo_thumbnail_path.lstrip("/")).unlink(missing_ok=True)
|
||||
Path(updated2.photo_path.lstrip("/")).unlink(missing_ok=True)
|
||||
Path(updated2.photo_thumbnail_path.lstrip("/")).unlink(missing_ok=True)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TESTS: NO EXCEPTIONS THROWN
|
||||
# ============================================================================
|
||||
|
||||
def test_auto_save_never_throws_exceptions(
|
||||
test_db: Session,
|
||||
test_item_for_extraction
|
||||
):
|
||||
"""Test that helper never throws exceptions, always returns status dict."""
|
||||
# Test with all kinds of bad input - none should throw
|
||||
|
||||
test_cases = [
|
||||
(None, None, None),
|
||||
(b'', {}, None),
|
||||
(None, {'x': 'bad'}, 'not_a_number'),
|
||||
(b'bad_image', {'x': 0, 'y': 0, 'width': 100}, 450),
|
||||
]
|
||||
|
||||
for image_bytes, crop_bounds, rotation in test_cases:
|
||||
result = _auto_save_photo_from_extraction(
|
||||
item_id=test_item_for_extraction.id,
|
||||
image_bytes=image_bytes,
|
||||
crop_bounds=crop_bounds,
|
||||
rotation_degrees=rotation,
|
||||
db=test_db
|
||||
)
|
||||
|
||||
# Must always return a dict with 'status' key
|
||||
assert isinstance(result, dict)
|
||||
assert "status" in result
|
||||
assert result["status"] in ["ok", "skipped"]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# INTEGRATION TESTS: FULL FLOW (create_item endpoint with auto-save)
|
||||
# ============================================================================
|
||||
|
||||
def test_create_item_with_image_processing_integration(
|
||||
admin_client,
|
||||
sample_image_bytes
|
||||
):
|
||||
"""Integration test: create item with extracted image → photo auto-saved with crop/rotation."""
|
||||
import base64
|
||||
|
||||
# Encode image as base64 for API payload
|
||||
image_base64 = base64.b64encode(sample_image_bytes).decode()
|
||||
|
||||
item_data = {
|
||||
"name": "NVMe Storage Drive",
|
||||
"category": "Storage",
|
||||
"type": "NVMe",
|
||||
"quantity": 1,
|
||||
"barcode": "NVM-2024-001",
|
||||
"part_number": "P66093-002",
|
||||
"extracted_image_bytes": image_base64,
|
||||
"image_processing": {
|
||||
"crop_bounds": {"x": 45, "y": 80, "width": 350, "height": 220},
|
||||
"rotation_degrees": 12,
|
||||
"confidence": 0.94
|
||||
}
|
||||
}
|
||||
|
||||
response = admin_client.post("/items/", json=item_data)
|
||||
|
||||
# Verify item was created
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["id"] is not None
|
||||
assert data["name"] == "NVMe Storage Drive"
|
||||
assert data["barcode"] == "NVM-2024-001"
|
||||
|
||||
# Verify photo was auto-saved
|
||||
assert data["photo_path"] is not None
|
||||
assert data["photo_thumbnail_path"] is not None
|
||||
assert data["photo_upload_date"] is not None
|
||||
|
||||
# Verify photo paths are valid
|
||||
assert "/images/" in data["photo_path"]
|
||||
assert "/images/" in data["photo_thumbnail_path"]
|
||||
assert data["photo_path"].endswith(".jpg")
|
||||
assert data["photo_thumbnail_path"].endswith(".jpg")
|
||||
|
||||
# Cleanup
|
||||
Path(data["photo_path"].lstrip("/")).unlink(missing_ok=True)
|
||||
Path(data["photo_thumbnail_path"].lstrip("/")).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_create_item_with_invalid_image_processing(
|
||||
admin_client,
|
||||
sample_image_bytes
|
||||
):
|
||||
"""Integration test: Item created even if image_processing is invalid, photo skipped gracefully."""
|
||||
import base64
|
||||
|
||||
image_base64 = base64.b64encode(sample_image_bytes).decode()
|
||||
|
||||
item_data = {
|
||||
"name": "Test Item Invalid",
|
||||
"category": "Storage",
|
||||
"type": "SSD",
|
||||
"quantity": 1,
|
||||
"barcode": "TEST-INVALID-001",
|
||||
"extracted_image_bytes": image_base64,
|
||||
"image_processing": {
|
||||
# Missing crop_bounds or invalid values
|
||||
"rotation_degrees": 999, # Invalid (out of range)
|
||||
"confidence": 1.5 # Invalid (>1.0)
|
||||
}
|
||||
}
|
||||
|
||||
response = admin_client.post("/items/", json=item_data)
|
||||
|
||||
# Item should still be created successfully
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["id"] is not None
|
||||
assert data["name"] == "Test Item Invalid"
|
||||
|
||||
# Photo should not be saved (invalid image_processing)
|
||||
assert data["photo_path"] is None
|
||||
assert data["photo_thumbnail_path"] is None
|
||||
assert data["photo_upload_date"] is None
|
||||
|
||||
|
||||
def test_create_item_without_image_processing(
|
||||
admin_client
|
||||
):
|
||||
"""Integration test: Backward compatibility - old clients without image_processing work."""
|
||||
item_data = {
|
||||
"name": "Old Style Item",
|
||||
"category": "Storage",
|
||||
"type": "SSD",
|
||||
"quantity": 1,
|
||||
"barcode": "OLD-STYLE-001"
|
||||
}
|
||||
|
||||
response = admin_client.post("/items/", json=item_data)
|
||||
|
||||
# Item should be created
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["id"] is not None
|
||||
assert data["name"] == "Old Style Item"
|
||||
|
||||
# No photo expected (no extracted_image_bytes provided)
|
||||
assert data["photo_path"] is None
|
||||
assert data["photo_thumbnail_path"] is None
|
||||
assert data["photo_upload_date"] is None
|
||||
|
||||
|
||||
def test_create_item_with_image_bytes_but_no_processing(
|
||||
admin_client,
|
||||
sample_image_bytes
|
||||
):
|
||||
"""Integration test: Image bytes without image_processing → item created, photo not saved."""
|
||||
import base64
|
||||
|
||||
image_base64 = base64.b64encode(sample_image_bytes).decode()
|
||||
|
||||
item_data = {
|
||||
"name": "Image Bytes Only",
|
||||
"category": "Storage",
|
||||
"type": "SATA",
|
||||
"quantity": 2,
|
||||
"barcode": "BYTES-ONLY-001",
|
||||
"extracted_image_bytes": image_base64
|
||||
# No image_processing field
|
||||
}
|
||||
|
||||
response = admin_client.post("/items/", json=item_data)
|
||||
|
||||
# Item should be created
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["id"] is not None
|
||||
|
||||
# Photo not saved (no image_processing means no crop info)
|
||||
assert data["photo_path"] is None
|
||||
assert data["photo_thumbnail_path"] is None
|
||||
assert data["photo_upload_date"] is None
|
||||
@@ -77,7 +77,35 @@
|
||||
- Remove hyphens/special chars for fuzzy matching
|
||||
- Use HUMAN-READABLE sizes (1.6TB not 1600GB)
|
||||
|
||||
## Output Format
|
||||
## Image Processing Guidance (NEW)
|
||||
|
||||
Analyze the image layout and return crop/rotation metadata to optimize photo storage:
|
||||
|
||||
### Crop Bounds Analysis
|
||||
- Identify the PRIMARY ITEM in the image (main object, not background/clutter)
|
||||
- Return bounding box: `{x, y, width, height}` in pixel coordinates
|
||||
- Rules:
|
||||
- `x, y`: top-left corner of item (pixel offset from image top-left)
|
||||
- `width, height`: dimensions of item bounding box
|
||||
- Include minimal padding (10-15 pixels) around item edges
|
||||
- Ignore background clutter, other items, hands, reflections
|
||||
|
||||
### Rotation Analysis
|
||||
- Check if item labels/text are readable
|
||||
- If text is rotated (not horizontal), calculate rotation needed
|
||||
- Return `rotation_degrees`: degrees to rotate CLOCKWISE to make text readable
|
||||
- Examples:
|
||||
- Text rotated 90° counter-clockwise → return 90 (rotate 90° clockwise)
|
||||
- Text rotated 45° clockwise → return -45 (rotate 45° counter-clockwise)
|
||||
- Text already readable → return 0
|
||||
|
||||
### Confidence Score
|
||||
- Return `confidence`: 0.0-1.0 indicating reliability of crop/rotation analysis
|
||||
- 0.9+ = High confidence (clear item, readable text)
|
||||
- 0.7-0.89 = Medium confidence (some ambiguity or text partially obscured)
|
||||
- <0.7 = Low confidence (cluttered image, unclear item boundaries)
|
||||
|
||||
### Output Format (Extended)
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
@@ -90,9 +118,20 @@
|
||||
"Size": "human_readable_size",
|
||||
"Color": "color",
|
||||
"PartNr": "part_number",
|
||||
"OCR": "TYPE SIZE VENDOR CONNECTOR PARTNUMBER"
|
||||
"OCR": "TYPE SIZE VENDOR CONNECTOR PARTNUMBER",
|
||||
"image_processing": {
|
||||
"crop_bounds": {
|
||||
"x": 50,
|
||||
"y": 100,
|
||||
"width": 300,
|
||||
"height": 200
|
||||
},
|
||||
"rotation_degrees": 15,
|
||||
"confidence": 0.92
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Return ONLY JSON. No markdown. No text.
|
||||
**Return ONLY JSON. No markdown. No text.**
|
||||
|
||||
98
config/ai_prompt.md.example
Normal file
98
config/ai_prompt.md.example
Normal file
@@ -0,0 +1,98 @@
|
||||
# Technical Inventory Hardware Extraction Protocol
|
||||
|
||||
Extract ALL relevant hardware items from the image with precise, standardized formatting.
|
||||
|
||||
## Filtering Rules
|
||||
- **INCLUDE**: Physical hardware, modules, cables, servers, storage, transceivers
|
||||
- **EXCLUDE**: Generic mounting hardware (screws, brackets, rails), paper licenses, empty packaging
|
||||
- **Multi-item labels**: Treat each SKU/variant as a separate item (e.g., "5m cable" and "7m cable" = 2 items)
|
||||
|
||||
## Item Field Format (CRITICAL)
|
||||
[]
|
||||
|
||||
**Component Rules:**
|
||||
- `<size_or_length>`:
|
||||
- **STORAGE CAPACITY - HUMAN READABLE**: Convert to largest unit (TB/MB).
|
||||
- Examples: "1600GB" → "1.6TB", "256GB" → "256GB", "512MB" → "512MB"
|
||||
- Rule: If ≥1000GB, use TB. If ≥1000MB, use GB. Otherwise use MB.
|
||||
- **CABLE/WIRE LENGTH**: Meters only. Examples: "5m", "10m", "50m"
|
||||
- **RAM DIMM**: Capacity in GB. Examples: "128GB", "32GB", "8GB"
|
||||
|
||||
- `<type>`: Asset class. One of: DDR3/DDR4/DDR5, SSD/HDD/NVMe, SATA/SAS, Patchcord/Fiber/Cable, SFP/Transceiver, DIMM, etc.
|
||||
- `<vendor>`: Manufacturer (HP, HPE, Dell, Samsung, Cisco, Hynix, Intel, Broadcom)
|
||||
- `<connector>`: Physical interface (RJ45, LC-LC, MPO, U.3, SATA, SAS, ST, SC). Omit if N/A.
|
||||
- `<part_number>`: Part number ONLY if visible. **Omit serial numbers.**
|
||||
|
||||
**Item Examples (WITH HUMAN-READABLE SIZES):**
|
||||
- `1.6TB NVMe HPE U.3 P66093-002` (not 1600GB)
|
||||
- `256GB SSD Dell SATA SK-8765` (already human-readable)
|
||||
- `5m Patchcord LC-LC`
|
||||
- `128GB DDR4 Hynix`
|
||||
- `512MB Cache Samsung SATA` (stays MB if under 1GB)
|
||||
|
||||
**Size Conversion Examples:**
|
||||
- 1600GB → 1.6TB
|
||||
- 2048GB → 2TB
|
||||
- 512GB → 512GB (under 1TB threshold)
|
||||
- 256MB → 256MB
|
||||
- 1024MB → 1GB
|
||||
|
||||
**Restrictions:**
|
||||
- No comments in parenthesis
|
||||
- No measurement units in Item field (e.g., "1.6TB" not "1.6TB Storage")
|
||||
- No secondary vendors
|
||||
- No diameter/mm in Item field
|
||||
- ONE vendor only (primary manufacturer)
|
||||
|
||||
## Other Fields
|
||||
- **Type**: Repeat the asset class (DDR4, SSD, NVMe, Patchcord, etc.)
|
||||
- **Description**: Technical summary, max 5 words. Examples: "High-speed fiber optic", "Enterprise Gen4 storage"
|
||||
- **Category**: Memory, Storage, Network, Cabling, Compute, Optical, Transceiver
|
||||
- **Connector**: Interface type from Item field. Examples: "LC-LC", "RJ45", "U.3"
|
||||
- **Size**: **HUMAN-READABLE capacity or length.** Examples: "1.6TB", "256GB", "5m" (NOT "1600GB")
|
||||
- **Color**: Physical color if distinguishing
|
||||
- **PartNr**: Part number only (no serial numbers)
|
||||
- **OCR**: Robust matching key for OCR tolerance
|
||||
|
||||
## OCR Field Rules (CRITICAL)
|
||||
Generate a SHORT, clean matching key:
|
||||
- Format: **UPPERCASE space-separated, NO special chars, NO duplicates**
|
||||
- Include ONLY: Type + Size + Primary Vendor + Connector + Part Number
|
||||
- **EXCLUDE**: Serial numbers, secondary vendors, duplicate tokens, EMC/SK labels
|
||||
- **USE HUMAN-READABLE SIZE**: Use TB/GB from Item field, not original notation
|
||||
|
||||
**OCR Format:** `TYPE SIZE VENDOR CONNECTOR PARTNUMBER`
|
||||
|
||||
**OCR Examples (WITH HUMAN-READABLE SIZES):**
|
||||
- Item: `1.6TB NVMe HPE U.3 P66093-002` → OCR: `NVME 1.6TB HPE U3 P66093002`
|
||||
- Item: `5m Patchcord LC-LC` → OCR: `PATCHCORD 5M LC LC`
|
||||
- Item: `256GB SSD Samsung SAS SK-8765` → OCR: `SSD 256GB SAMSUNG SAS SK8765`
|
||||
- Item: `128GB DDR4 Hynix` → OCR: `DDR4 128GB HYNIX`
|
||||
|
||||
**OCR Constraints:**
|
||||
- NO duplicate part numbers
|
||||
- NO secondary vendor names
|
||||
- NO extraneous labels
|
||||
- Each token appears ONE time only
|
||||
- Remove hyphens/special chars for fuzzy matching
|
||||
- Use HUMAN-READABLE sizes (1.6TB not 1600GB)
|
||||
|
||||
## Output Format
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"Item": "[size] type vendor connector partnumber",
|
||||
"Type": "type",
|
||||
"Description": "technical details (max 5 words)",
|
||||
"Category": "category",
|
||||
"Connector": "connector_type",
|
||||
"Size": "human_readable_size",
|
||||
"Color": "color",
|
||||
"PartNr": "part_number",
|
||||
"OCR": "TYPE SIZE VENDOR CONNECTOR PARTNUMBER"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Return ONLY JSON. No markdown. No text.
|
||||
File diff suppressed because it is too large
Load Diff
1380
docs/superpowers/plans/2026-04-21-ai-extraction-autosave-photo.md
Normal file
1380
docs/superpowers/plans/2026-04-21-ai-extraction-autosave-photo.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,350 @@
|
||||
# Design: Single-Query AI Extraction + Auto-Photo-Save with Crop/Rotation
|
||||
|
||||
**Date:** 2026-04-21
|
||||
**Author:** Claude Haiku 4.5
|
||||
**Status:** Design Phase
|
||||
**Scope:** Phase 3 - Photo Quality & Reliability
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
**Problem:**
|
||||
- Currently: Two separate API calls (extract-label for OCR, then upload-photo for crop/rotate)
|
||||
- Inefficient: Duplicate processing, higher token cost
|
||||
- User experience: Extra step after item creation to manually upload photo
|
||||
|
||||
**Solution:**
|
||||
- Single API call: `/extract-label` returns item data + crop/rotation metadata
|
||||
- Backend applies crop/rotation locally using AI guidance
|
||||
- Automatic photo save after item confirmation (no manual upload needed)
|
||||
- **Token savings:** ~1000+ tokens per item (no image in response, just coordinates)
|
||||
|
||||
**Benefits:**
|
||||
- 50% fewer API calls
|
||||
- Single query to AI instead of two
|
||||
- Automatic photo integration (better UX)
|
||||
- Graceful fallback if processing fails
|
||||
|
||||
---
|
||||
|
||||
## 2. Enhanced AI Prompt
|
||||
|
||||
### Current Prompt Structure
|
||||
- Extracts item data only (Item, Type, Description, Category, Connector, Size, Color, PartNr, OCR)
|
||||
- Returns JSON with extracted fields
|
||||
- No guidance on image layout or rotation
|
||||
|
||||
### Enhanced Prompt Addition
|
||||
|
||||
Add to `/config/ai_prompt.md` (after Output Format section):
|
||||
|
||||
```markdown
|
||||
## Image Processing Guidance (NEW)
|
||||
|
||||
Analyze the image layout and return crop/rotation metadata to optimize photo storage:
|
||||
|
||||
### Crop Bounds Analysis
|
||||
- Identify the PRIMARY ITEM in the image (main object, not background/clutter)
|
||||
- Return bounding box: `{x, y, width, height}` in pixel coordinates
|
||||
- Rules:
|
||||
- `x, y`: top-left corner of item (pixel offset from image top-left)
|
||||
- `width, height`: dimensions of item bounding box
|
||||
- Include minimal padding (10-15 pixels) around item edges
|
||||
- Ignore background clutter, other items, hands, reflections
|
||||
|
||||
### Rotation Analysis
|
||||
- Check if item labels/text are readable
|
||||
- If text is rotated (not horizontal), calculate rotation needed
|
||||
- Return `rotation_degrees`: degrees to rotate CLOCKWISE to make text readable
|
||||
- Examples:
|
||||
- Text rotated 90° counter-clockwise → return 90 (rotate 90° clockwise)
|
||||
- Text rotated 45° clockwise → return -45 (rotate 45° counter-clockwise)
|
||||
- Text already readable → return 0
|
||||
|
||||
### Confidence Score
|
||||
- Return `confidence`: 0.0-1.0 indicating reliability of crop/rotation analysis
|
||||
- 0.9+ = High confidence (clear item, readable text)
|
||||
- 0.7-0.89 = Medium confidence (some ambiguity or text partially obscured)
|
||||
- <0.7 = Low confidence (cluttered image, unclear item boundaries)
|
||||
|
||||
### Output Format (Extended)
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"Item": "[size] type vendor connector partnumber",
|
||||
"Type": "type",
|
||||
"Description": "technical details (max 5 words)",
|
||||
"Category": "category",
|
||||
"Connector": "connector_type",
|
||||
"Size": "human_readable_size",
|
||||
"Color": "color",
|
||||
"PartNr": "part_number",
|
||||
"OCR": "TYPE SIZE VENDOR CONNECTOR PARTNUMBER",
|
||||
"image_processing": {
|
||||
"crop_bounds": {
|
||||
"x": 50,
|
||||
"y": 100,
|
||||
"width": 300,
|
||||
"height": 200
|
||||
},
|
||||
"rotation_degrees": 15,
|
||||
"confidence": 0.92
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Return ONLY JSON. No markdown. No text.**
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Backend Implementation
|
||||
|
||||
### 3.1 Updated Endpoint: `/extract-label`
|
||||
|
||||
**File:** `backend/routers/items.py`
|
||||
|
||||
**Changes:**
|
||||
- Enhanced prompt now included in `ai_vision.extract_label_info()`
|
||||
- AI response parsed to include `image_processing` field
|
||||
- Returns crop_bounds, rotation_degrees, confidence
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"Item": "1.6TB NVMe HPE U.3 P66093-002",
|
||||
"Type": "NVMe",
|
||||
"Description": "Enterprise storage module",
|
||||
"Category": "Storage",
|
||||
"Connector": "U.3",
|
||||
"Size": "1.6TB",
|
||||
"Color": "Black",
|
||||
"PartNr": "P66093-002",
|
||||
"OCR": "NVME 1.6TB HPE U3 P66093002",
|
||||
"image_processing": {
|
||||
"crop_bounds": {"x": 45, "y": 80, "width": 350, "height": 220},
|
||||
"rotation_degrees": 12,
|
||||
"confidence": 0.94
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Backend Photo Auto-Save Logic
|
||||
|
||||
**File:** `backend/routers/items.py` (new function)
|
||||
|
||||
**Function:** `_auto_save_photo_from_extraction(item_id, image_bytes, crop_bounds, rotation_degrees, db_session)`
|
||||
|
||||
**Logic:**
|
||||
```
|
||||
1. Input: item_id, original_image_bytes, crop_bounds, rotation_degrees
|
||||
2. Check if crop_bounds and rotation_degrees are valid
|
||||
- If not: log warning, skip photo save, return success (graceful degradation)
|
||||
3. Create crop_bounds_dict from AI coordinates:
|
||||
{
|
||||
"x": crop_bounds["x"],
|
||||
"y": crop_bounds["y"],
|
||||
"w": crop_bounds["width"],
|
||||
"h": crop_bounds["height"]
|
||||
}
|
||||
4. Call ImageProcessor.process_photo(image_bytes, crop_bounds_dict, rotation_degrees)
|
||||
5. If processing fails: log error, skip photo save (don't block item creation)
|
||||
6. If processing succeeds:
|
||||
- Get unique filename using ImageStorage.get_unique_filename()
|
||||
- Save full image and thumbnail
|
||||
- Update Item.photo_path, photo_thumbnail_path, photo_upload_date
|
||||
7. Return: {status: "ok"} or {status: "skipped", reason: "..."}
|
||||
```
|
||||
|
||||
**Error Handling:**
|
||||
- Missing crop_bounds/rotation? → Skip photo save, item created successfully
|
||||
- Processing fails? → Log error, save original image as fallback
|
||||
- File save fails? → Log error, don't block item creation
|
||||
|
||||
---
|
||||
|
||||
## 4. Frontend Implementation
|
||||
|
||||
### 4.1 AIOnboarding Component Flow
|
||||
|
||||
**File:** `frontend/components/AIOnboarding.tsx`
|
||||
|
||||
**Current Flow:**
|
||||
1. Take photo
|
||||
2. Send to `/extract-label` (get item data only)
|
||||
3. Show extracted data, user edits
|
||||
4. User clicks "Create Item"
|
||||
5. Item created
|
||||
6. (Separate) User uploads photo later
|
||||
|
||||
**New Flow:**
|
||||
1. Take photo + store original image bytes in state
|
||||
2. Send to `/extract-label` (get item data + crop/rotation)
|
||||
3. Show extracted data + **store image_processing metadata** in state
|
||||
4. User edits item details
|
||||
5. User clicks "Create Item"
|
||||
6. **[NEW]** After item creation, auto-call `/items/{id}/photos` with:
|
||||
- file: original image
|
||||
- crop_bounds: from extraction response
|
||||
- replace_existing: "false"
|
||||
7. Show success toast: "Item created + photo saved"
|
||||
|
||||
### 4.2 Hook Updates
|
||||
|
||||
**File:** `frontend/hooks/useAIExtraction.ts`
|
||||
|
||||
**Changes:**
|
||||
- Store extracted `image_processing` data alongside item data
|
||||
- Pass to `useItemCreate` hook
|
||||
|
||||
**File:** `frontend/hooks/useItemCreate.ts`
|
||||
|
||||
**Changes:**
|
||||
- After item creation succeeds, check if `image_processing` exists
|
||||
- If yes: call `uploadPhoto()` with crop_bounds from extraction
|
||||
- Wait for photo upload to complete
|
||||
- Show combined toast: "Item + Photo saved"
|
||||
|
||||
### 4.3 Data Flow in State
|
||||
|
||||
```typescript
|
||||
// In AIOnboarding
|
||||
const [extractedImage, setExtractedImage] = useState<Blob | null>(null); // Original image
|
||||
const [imageProcessing, setImageProcessing] = useState<{crop_bounds, rotation_degrees, confidence} | null>(null);
|
||||
|
||||
// After extraction
|
||||
const response = await inventoryApi.analyzeLabel(formData, mode);
|
||||
setExtractedImage(imageBlob); // Store for later
|
||||
setImageProcessing(response.items[0].image_processing); // Store metadata
|
||||
|
||||
// When creating item, pass both to useItemCreate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Data Flow Diagram (Text)
|
||||
|
||||
```
|
||||
User takes photo
|
||||
↓
|
||||
POST /extract-label (with enhanced prompt)
|
||||
↓
|
||||
AI returns: {items: [{...item_data, image_processing: {crop_bounds, rotation_degrees, confidence}}]}
|
||||
↓
|
||||
Frontend stores: extracted_image + image_processing metadata
|
||||
↓
|
||||
Show item data, user edits
|
||||
↓
|
||||
User clicks "Create Item"
|
||||
↓
|
||||
POST /items → Item created in DB (item_id = 123)
|
||||
↓
|
||||
POST /items/123/photos with:
|
||||
- file: extracted_image
|
||||
- crop_bounds: JSON from image_processing
|
||||
- replace_existing: false
|
||||
↓
|
||||
Backend:
|
||||
1. Validate crop_bounds JSON
|
||||
2. Call ImageProcessor.process_photo(bytes, crop_bounds_dict)
|
||||
3. Save full image + thumbnail
|
||||
4. Update item.photo_path, photo_thumbnail_path, photo_upload_date
|
||||
↓
|
||||
Return success + photo URLs
|
||||
↓
|
||||
Show toast: "Item created + photo saved"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Files Modified
|
||||
|
||||
| File | Change | Lines |
|
||||
|------|--------|-------|
|
||||
| `config/ai_prompt.md` | Add "Image Processing Guidance" section with crop/rotation rules | +50 |
|
||||
| `backend/ai_vision.py` | Parse `image_processing` field from AI response | +20 |
|
||||
| `backend/routers/items.py` | Add `_auto_save_photo_from_extraction()` helper function; update item creation flow | +80 |
|
||||
| `frontend/hooks/useAIExtraction.ts` | Store `image_processing` metadata alongside extracted items | +15 |
|
||||
| `frontend/hooks/useItemCreate.ts` | Auto-call photo upload if `image_processing` exists after item creation | +30 |
|
||||
| `frontend/components/AIOnboarding.tsx` | Pass extracted image + image_processing to item creation | +10 |
|
||||
|
||||
**Total Impact:** ~205 lines of code/config
|
||||
|
||||
---
|
||||
|
||||
## 7. Error Handling & Fallbacks
|
||||
|
||||
| Scenario | Handling |
|
||||
|----------|----------|
|
||||
| AI doesn't return image_processing | Skip photo save, item created (no photo) |
|
||||
| crop_bounds is null/invalid | Skip photo save, item created (no photo) |
|
||||
| ImageProcessor.process_photo() fails | Log error, save original image as-is |
|
||||
| File save fails | Log error, don't block item creation |
|
||||
| Network error during photo upload | Return error to frontend (user can retry manually) |
|
||||
| User has no camera permission | Existing flow (file upload only) |
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- Parse `image_processing` from AI response correctly
|
||||
- Validate crop_bounds JSON (x, y, width, height are valid)
|
||||
- Rotation degrees within valid range (-360 to +360)
|
||||
- Confidence score is 0.0-1.0
|
||||
|
||||
### Integration Tests
|
||||
- Extract → Create Item → Auto-save Photo flow end-to-end
|
||||
- Photo saved with correct crop/rotation applied
|
||||
- Fallback: photo save fails, item still created
|
||||
- Manual photo upload still works (separate flow)
|
||||
|
||||
### E2E Tests
|
||||
- User takes photo → AI extracts + crop guidance → creates item → photo auto-saved
|
||||
- Verify photo appears in inventory card with correct crop
|
||||
|
||||
---
|
||||
|
||||
## 9. Success Criteria
|
||||
|
||||
✅ Single API call returns item data + crop/rotation guidance
|
||||
✅ Backend applies crop/rotation from AI metadata
|
||||
✅ Photo auto-saved after item confirmation
|
||||
✅ No manual photo upload step needed for AI-identified items
|
||||
✅ Graceful fallback if processing fails
|
||||
✅ ~1000+ token savings per extraction (no image in response)
|
||||
✅ All existing tests pass
|
||||
✅ E2E test covers full flow
|
||||
|
||||
---
|
||||
|
||||
## 10. Rollout Strategy
|
||||
|
||||
**Phase 1:** Backend + Frontend changes (non-breaking)
|
||||
- Old `/extract-label` calls still work (image_processing field optional)
|
||||
- Manual photo upload still works
|
||||
- AIOnboarding auto-save only for new items with image_processing data
|
||||
|
||||
**Phase 2:** Update AI prompt in config (activate crop/rotation guidance)
|
||||
- Existing deployments get enhanced prompt on next config reload
|
||||
- New extractions return image_processing field
|
||||
|
||||
**Rollback:** Remove image_processing field from response, revert to manual upload
|
||||
|
||||
---
|
||||
|
||||
## 11. Notes
|
||||
|
||||
- **Backward Compatibility:** If AI doesn't return `image_processing`, system falls back to manual upload (no breaking change)
|
||||
- **Storage:** Original image passed from frontend to photo upload endpoint (already happens in current flow)
|
||||
- **Security:** No new endpoints, no new auth required (existing /extract-label and /items/{id}/photos endpoints)
|
||||
- **Performance:** Single AI call vs two API calls = 50% fewer round-trips
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "1.12.0",
|
||||
"last_build": "2026-04-19-1907",
|
||||
"codename": "UIOptimized",
|
||||
"commit": "d85c72e1"
|
||||
"version": "1.13.0",
|
||||
"last_build": "2026-04-21-1205",
|
||||
"codename": "PhotoUI",
|
||||
"commit": "ca68aeae"
|
||||
}
|
||||
@@ -209,21 +209,20 @@ export default function LoginPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectedUserForLogin.username && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-normal text-muted px-1">Username</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-4 top-1/2 -translate-y-1/2 text-muted" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
onChange={(e) => setSelectedUserForLogin({...selectedUserForLogin, username: e.target.value})}
|
||||
className="w-full bg-slate-800/50 border border-slate-800 focus:border-primary rounded-2xl py-4 pl-12 pr-4 text-white focus:outline-none transition-all placeholder:text-slate-700 font-mono"
|
||||
placeholder="Admin"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-normal text-muted px-1">Username</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-4 top-1/2 -translate-y-1/2 text-muted" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={selectedUserForLogin.username || ""}
|
||||
onChange={(e) => setSelectedUserForLogin({...selectedUserForLogin, username: e.target.value})}
|
||||
className="w-full bg-slate-800/50 border border-slate-800 focus:border-primary rounded-2xl py-4 pl-12 pr-4 text-white focus:outline-none transition-all placeholder:text-slate-700 font-mono"
|
||||
placeholder="Admin"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-normal text-muted px-1">Password</label>
|
||||
@@ -233,7 +232,7 @@ export default function LoginPage() {
|
||||
ref={localPassRef}
|
||||
data-testid="local-password-input"
|
||||
type="password"
|
||||
autoFocus
|
||||
autoFocus={!!selectedUserForLogin.username}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
||||
className="w-full bg-slate-800/50 border border-slate-800 focus:border-primary rounded-2xl py-4 pl-12 pr-4 text-white/50 focus:text-white focus:outline-none transition-all placeholder:text-slate-700 font-mono"
|
||||
placeholder="Enter password"
|
||||
|
||||
@@ -167,13 +167,35 @@ export default function Home() {
|
||||
if (itemData.part_number) {
|
||||
itemData.part_number = itemData.part_number.toLowerCase();
|
||||
}
|
||||
// 1. Add to local DB cache
|
||||
|
||||
// Prepare data for backend: convert Blob to base64 and rename fields
|
||||
const backendData = { ...itemData };
|
||||
delete backendData.extractedImageBlob; // Remove Blob from local DB version
|
||||
|
||||
// If extracted image blob is present, convert to base64 for backend
|
||||
if (itemData.extractedImageBlob) {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(itemData.extractedImageBlob);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
reader.onload = () => {
|
||||
const base64 = (reader.result as string).split(',')[1]; // Remove data URL prefix
|
||||
backendData.extracted_image_bytes = base64;
|
||||
backendData.image_processing = itemData.imageProcessing; // Use correct field name for API
|
||||
delete backendData.imageProcessing; // Remove old field name
|
||||
resolve(null);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
});
|
||||
}
|
||||
|
||||
// 1. Add to local DB cache (without Blob)
|
||||
await db.items.add(itemData);
|
||||
|
||||
// 2. If online, try to push to backend immediately
|
||||
if (isOnline && currentUser) {
|
||||
try {
|
||||
await inventoryApi.createItem(currentUser.id, itemData);
|
||||
await inventoryApi.createItem(currentUser.id, backendData);
|
||||
toast.success("Item saved to cloud catalog!");
|
||||
setShowOnboarding(false);
|
||||
await loadInventory();
|
||||
@@ -186,7 +208,7 @@ export default function Home() {
|
||||
const detail = responseData.detail;
|
||||
setComparisonModal({
|
||||
show: true,
|
||||
newItem: itemData,
|
||||
newItem: backendData,
|
||||
existingItem: detail.existing_item,
|
||||
existingId: detail.existing_id
|
||||
});
|
||||
@@ -215,8 +237,28 @@ export default function Home() {
|
||||
if (!comparisonModal.existingId) return;
|
||||
setComparisonLoading(true);
|
||||
try {
|
||||
// Prepare data: convert Blob to base64 if present
|
||||
const updateData = { ...comparisonModal.newItem };
|
||||
|
||||
if (updateData.extractedImageBlob) {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(updateData.extractedImageBlob);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
reader.onload = () => {
|
||||
const base64 = (reader.result as string).split(',')[1];
|
||||
updateData.extracted_image_bytes = base64;
|
||||
updateData.image_processing = updateData.imageProcessing;
|
||||
delete updateData.extractedImageBlob;
|
||||
delete updateData.imageProcessing;
|
||||
resolve(null);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
});
|
||||
}
|
||||
|
||||
// Update existing item
|
||||
await inventoryApi.updateItem(comparisonModal.existingId, comparisonModal.newItem);
|
||||
await inventoryApi.updateItem(comparisonModal.existingId, updateData);
|
||||
toast.success("Item updated successfully!");
|
||||
setComparisonModal({ show: false, newItem: null, existingItem: null, existingId: null });
|
||||
setShowOnboarding(false);
|
||||
|
||||
@@ -106,7 +106,7 @@ export default function InventoryTable({
|
||||
onClick={() => handleItemClick(item)}
|
||||
className="flex items-center gap-3 flex-1 min-w-0 pr-4 cursor-pointer"
|
||||
>
|
||||
{item.image_url ? (
|
||||
{item.photo_path || item.image_url ? (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -115,7 +115,7 @@ export default function InventoryTable({
|
||||
className="w-12 h-12 rounded-xl shrink-0 border-2 border-slate-300 overflow-hidden cursor-pointer hover:border-primary transition-colors active:scale-95"
|
||||
>
|
||||
<img
|
||||
src={item.image_url}
|
||||
src={item.photo_path || item.image_url || ''}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
@@ -128,7 +128,7 @@ export default function InventoryTable({
|
||||
)}
|
||||
<div className="truncate">
|
||||
<h4 className="card-title truncate">{item.name}</h4>
|
||||
{item.image_url ? (
|
||||
{item.photo_path || item.image_url ? (
|
||||
<p className="card-subtitle mt-0 lowercase opacity-80 text-xs">Tap photo for details</p>
|
||||
) : (
|
||||
<>
|
||||
@@ -163,9 +163,9 @@ export default function InventoryTable({
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedPhotoItem && selectedPhotoItem.image_url && (
|
||||
{selectedPhotoItem && (selectedPhotoItem.photo_path || selectedPhotoItem.image_url) && (
|
||||
<PhotoModal
|
||||
photoUrl={selectedPhotoItem.image_url}
|
||||
photoUrl={selectedPhotoItem.photo_path || selectedPhotoItem.image_url || ''}
|
||||
title={selectedPhotoItem.name}
|
||||
onClose={() => setSelectedPhotoItem(null)}
|
||||
/>
|
||||
|
||||
@@ -22,7 +22,11 @@ export default function ItemDetailModal({
|
||||
}: ItemDetailModalProps) {
|
||||
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
|
||||
const [currentPhoto, setCurrentPhoto] = useState<{ thumbnail_url: string; full_url: string } | null>(
|
||||
item.image_url ? { thumbnail_url: item.image_url, full_url: item.image_url } : null
|
||||
item.photo_path && item.photo_thumbnail_path
|
||||
? { thumbnail_url: item.photo_thumbnail_path, full_url: item.photo_path }
|
||||
: item.image_url
|
||||
? { thumbnail_url: item.image_url, full_url: item.image_url }
|
||||
: null
|
||||
);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const photoUploadRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
327
frontend/e2e/workflows/7-ai-extraction-autosave.spec.ts
Normal file
327
frontend/e2e/workflows/7-ai-extraction-autosave.spec.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import * as auth from '../fixtures/auth';
|
||||
import * as assertions from '../utils/assertions';
|
||||
import * as helpers from '../utils/helpers';
|
||||
import { LOCAL_USERS } from '../fixtures/test-data';
|
||||
|
||||
test.describe('AI Extraction + Auto-Photo-Save Flow', () => {
|
||||
const BASE_URL = process.env.BASE_URL || 'http://localhost:8917';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to app and login
|
||||
await page.goto(BASE_URL);
|
||||
await auth.loginWithLocalUser(page, LOCAL_USERS.admin, BASE_URL);
|
||||
await assertions.assertUserAuthenticated(page, BASE_URL);
|
||||
|
||||
// Navigate to new item creation (AI extraction)
|
||||
await page.goto(`${BASE_URL}/inventory/new`);
|
||||
|
||||
// Wait for AI wizard to be ready
|
||||
const aiWizard = page.locator('[data-testid="ai-onboarding-wizard"]');
|
||||
await expect(aiWizard).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('should auto-save photo after successful AI identification and item creation', async ({
|
||||
page,
|
||||
}) => {
|
||||
// 1. Click capture button to trigger AI extraction
|
||||
const captureButton = page.locator('[data-testid="capture-button"]');
|
||||
await expect(captureButton).toBeVisible();
|
||||
await captureButton.click();
|
||||
|
||||
// 2. Wait for AI extraction to complete and show results
|
||||
const resultsForm = page.locator('[data-testid="extraction-results-form"]');
|
||||
await expect(resultsForm).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// 3. Verify extracted data is shown
|
||||
const nameField = page.locator('[data-testid="extracted-name"]');
|
||||
await expect(nameField).toBeVisible();
|
||||
const extractedName = await nameField.inputValue();
|
||||
expect(extractedName).toBeTruthy();
|
||||
expect(extractedName.length).toBeGreaterThan(0);
|
||||
|
||||
// 4. Confirm extraction to create item
|
||||
const confirmButton = page.locator('[data-testid="confirm-extraction"]');
|
||||
await confirmButton.click();
|
||||
|
||||
// 5. Wait for success toast and item creation
|
||||
const successToast = page.locator('[data-testid="toast-success"]');
|
||||
await expect(successToast).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify toast contains success message about item creation
|
||||
const toastText = await successToast.textContent();
|
||||
expect(toastText?.toLowerCase()).toContain('created');
|
||||
|
||||
// 6. Wait for redirect to inventory
|
||||
await page.waitForURL(/inventory|items/, { timeout: 10000 });
|
||||
|
||||
// 7. Navigate to inventory to verify photo appears
|
||||
await page.goto(`${BASE_URL}/inventory`);
|
||||
const inventoryTable = page.locator('[data-testid="inventory-table"]');
|
||||
await expect(inventoryTable).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// 8. Get the first item row (should be the newly created item)
|
||||
const firstItemRow = inventoryTable.locator('tbody tr').first();
|
||||
await expect(firstItemRow).toBeVisible();
|
||||
|
||||
// 9. Verify photo thumbnail is present
|
||||
const photoThumbnail = firstItemRow.locator('[data-testid="item-photo-thumbnail"]');
|
||||
await expect(photoThumbnail).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// 10. Click photo to open modal and verify full-res photo
|
||||
await photoThumbnail.click();
|
||||
|
||||
const photoModal = page.locator('[data-testid="photo-modal"]');
|
||||
await expect(photoModal).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const fullPhoto = photoModal.locator('img');
|
||||
await expect(fullPhoto).toBeVisible();
|
||||
|
||||
// Verify the image has a valid src attribute (should contain /photos/ path)
|
||||
const imgSrc = await fullPhoto.getAttribute('src');
|
||||
expect(imgSrc).toBeTruthy();
|
||||
expect(imgSrc).toMatch(/\/photos\/|\/images\/|data:image/);
|
||||
|
||||
// 11. Close modal by clicking outside or close button
|
||||
const closeButton = photoModal.locator('[data-testid="modal-close"]');
|
||||
if (await closeButton.isVisible()) {
|
||||
await closeButton.click();
|
||||
} else {
|
||||
// Click outside the modal
|
||||
await page.click('[data-testid="photo-modal-backdrop"]');
|
||||
}
|
||||
|
||||
await expect(photoModal).not.toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test('should display photo metadata in inventory after auto-save', async ({ page }) => {
|
||||
// 1. Capture and extract
|
||||
const captureButton = page.locator('[data-testid="capture-button"]');
|
||||
await captureButton.click();
|
||||
|
||||
const resultsForm = page.locator('[data-testid="extraction-results-form"]');
|
||||
await expect(resultsForm).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// 2. Confirm extraction
|
||||
const confirmButton = page.locator('[data-testid="confirm-extraction"]');
|
||||
await confirmButton.click();
|
||||
|
||||
const successToast = page.locator('[data-testid="toast-success"]');
|
||||
await expect(successToast).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.waitForURL(/inventory|items/, { timeout: 10000 });
|
||||
|
||||
// 3. Navigate to inventory
|
||||
await page.goto(`${BASE_URL}/inventory`);
|
||||
const inventoryTable = page.locator('[data-testid="inventory-table"]');
|
||||
await expect(inventoryTable).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// 4. Find the newly created item row
|
||||
const firstItemRow = inventoryTable.locator('tbody tr').first();
|
||||
await expect(firstItemRow).toBeVisible();
|
||||
|
||||
// 5. Verify photo column shows photo indicator or date
|
||||
const photoCell = firstItemRow.locator('[data-testid="item-photo-cell"]');
|
||||
if (await photoCell.isVisible()) {
|
||||
const photoIndicator = photoCell.locator('[data-testid="item-photo-thumbnail"]');
|
||||
await expect(photoIndicator).toBeVisible();
|
||||
|
||||
// Verify photo has visual indication (image or icon)
|
||||
const hasImage = await photoIndicator.locator('img').isVisible();
|
||||
const hasIcon = await photoIndicator.locator('[role="img"]').isVisible();
|
||||
expect(hasImage || hasIcon).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle photo upload failure gracefully without blocking item creation', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Mock photo upload to fail
|
||||
await page.route('**/items/*/photos', (route) => {
|
||||
route.abort('failed');
|
||||
});
|
||||
|
||||
// 1. Capture and extract
|
||||
const captureButton = page.locator('[data-testid="capture-button"]');
|
||||
await captureButton.click();
|
||||
|
||||
const resultsForm = page.locator('[data-testid="extraction-results-form"]');
|
||||
await expect(resultsForm).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// 2. Confirm extraction (photo upload will fail, but item should still be created)
|
||||
const confirmButton = page.locator('[data-testid="confirm-extraction"]');
|
||||
await confirmButton.click();
|
||||
|
||||
// 3. Should show success for item creation (despite photo save failure)
|
||||
const successToast = page.locator('[data-testid="toast-success"]');
|
||||
await expect(successToast).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const toastText = await successToast.textContent();
|
||||
expect(toastText?.toLowerCase()).toContain('created');
|
||||
|
||||
// 4. Should NOT show critical error blocking the operation
|
||||
const errorToast = page.locator('[data-testid="toast-error"]');
|
||||
const isErrorVisible = await errorToast.isVisible().catch(() => false);
|
||||
// Error about photo might be shown as warning, not blocking error
|
||||
expect(isErrorVisible).toBe(false);
|
||||
|
||||
await page.waitForURL(/inventory|items/, { timeout: 10000 });
|
||||
|
||||
// 5. Navigate to inventory
|
||||
await page.goto(`${BASE_URL}/inventory`);
|
||||
const inventoryTable = page.locator('[data-testid="inventory-table"]');
|
||||
await expect(inventoryTable).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// 6. Verify item still exists without photo
|
||||
const firstItemRow = inventoryTable.locator('tbody tr').first();
|
||||
await expect(firstItemRow).toBeVisible();
|
||||
|
||||
// Photo should be absent or show placeholder
|
||||
const photoThumbnail = firstItemRow.locator('[data-testid="item-photo-thumbnail"]');
|
||||
const photoVisible = await photoThumbnail.isVisible().catch(() => false);
|
||||
// Photo might not be visible or might show placeholder
|
||||
expect(photoVisible).toBe(false);
|
||||
});
|
||||
|
||||
test('should preserve photo even when item data is edited post-save', async ({ page }) => {
|
||||
// 1. Capture and extract
|
||||
const captureButton = page.locator('[data-testid="capture-button"]');
|
||||
await captureButton.click();
|
||||
|
||||
const resultsForm = page.locator('[data-testid="extraction-results-form"]');
|
||||
await expect(resultsForm).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// 2. Get the extracted name for later verification
|
||||
const nameField = page.locator('[data-testid="extracted-name"]');
|
||||
const originalName = await nameField.inputValue();
|
||||
|
||||
// 3. Confirm extraction
|
||||
const confirmButton = page.locator('[data-testid="confirm-extraction"]');
|
||||
await confirmButton.click();
|
||||
|
||||
const successToast = page.locator('[data-testid="toast-success"]');
|
||||
await expect(successToast).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.waitForURL(/inventory|items/, { timeout: 10000 });
|
||||
|
||||
// 4. Navigate to inventory
|
||||
await page.goto(`${BASE_URL}/inventory`);
|
||||
const inventoryTable = page.locator('[data-testid="inventory-table"]');
|
||||
await expect(inventoryTable).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// 5. Verify photo exists before editing
|
||||
const firstItemRow = inventoryTable.locator('tbody tr').first();
|
||||
const photoThumbnail = firstItemRow.locator('[data-testid="item-photo-thumbnail"]');
|
||||
await expect(photoThumbnail).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// 6. Click on item to open details/edit page
|
||||
const itemName = firstItemRow.locator('[data-testid="item-name"]');
|
||||
await itemName.click();
|
||||
|
||||
await page.waitForURL(/items\/\d+|inventory\/edit/, { timeout: 10000 });
|
||||
|
||||
// 7. Find and verify photo is still visible on detail page
|
||||
const detailPhotoThumbnail = page.locator('[data-testid="item-photo-thumbnail"]');
|
||||
await expect(detailPhotoThumbnail).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// 8. Edit a field (e.g., quantity)
|
||||
const quantityField = page.locator('[data-testid="item-quantity"]');
|
||||
if (await quantityField.isVisible()) {
|
||||
await quantityField.fill('100');
|
||||
|
||||
// Save changes
|
||||
const saveButton = page.locator('[data-testid="save-item-button"]');
|
||||
if (await saveButton.isVisible()) {
|
||||
await saveButton.click();
|
||||
|
||||
const updateToast = page.locator('[data-testid="toast-success"]');
|
||||
await expect(updateToast).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Verify photo still exists after edit
|
||||
const photoAfterEdit = page.locator('[data-testid="item-photo-thumbnail"]');
|
||||
await expect(photoAfterEdit).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('should display photo with correct dimensions in modal', async ({ page }) => {
|
||||
// 1. Capture and extract
|
||||
const captureButton = page.locator('[data-testid="capture-button"]');
|
||||
await captureButton.click();
|
||||
|
||||
const resultsForm = page.locator('[data-testid="extraction-results-form"]');
|
||||
await expect(resultsForm).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// 2. Confirm extraction
|
||||
const confirmButton = page.locator('[data-testid="confirm-extraction"]');
|
||||
await confirmButton.click();
|
||||
|
||||
const successToast = page.locator('[data-testid="toast-success"]');
|
||||
await expect(successToast).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.waitForURL(/inventory|items/, { timeout: 10000 });
|
||||
|
||||
// 3. Navigate to inventory
|
||||
await page.goto(`${BASE_URL}/inventory`);
|
||||
const inventoryTable = page.locator('[data-testid="inventory-table"]');
|
||||
await expect(inventoryTable).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// 4. Click photo to open modal
|
||||
const firstItemRow = inventoryTable.locator('tbody tr').first();
|
||||
const photoThumbnail = firstItemRow.locator('[data-testid="item-photo-thumbnail"]');
|
||||
await photoThumbnail.click();
|
||||
|
||||
const photoModal = page.locator('[data-testid="photo-modal"]');
|
||||
await expect(photoModal).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// 5. Verify image is properly rendered
|
||||
const fullPhoto = photoModal.locator('img');
|
||||
await expect(fullPhoto).toBeVisible();
|
||||
|
||||
// 6. Check image dimensions (should be reasonable)
|
||||
const boundingBox = await fullPhoto.boundingBox();
|
||||
expect(boundingBox).toBeTruthy();
|
||||
expect(boundingBox!.width).toBeGreaterThan(0);
|
||||
expect(boundingBox!.height).toBeGreaterThan(0);
|
||||
|
||||
// 7. Verify modal has proper layout (image shouldn't be distorted)
|
||||
const photoContainer = photoModal.locator('[data-testid="photo-container"]');
|
||||
if (await photoContainer.isVisible()) {
|
||||
const containerBox = await photoContainer.boundingBox();
|
||||
expect(containerBox).toBeTruthy();
|
||||
// Image should fit within reasonable bounds
|
||||
expect(containerBox!.width).toBeLessThan(1000);
|
||||
expect(containerBox!.height).toBeLessThan(1000);
|
||||
}
|
||||
});
|
||||
|
||||
test('should not show duplicate photos for same item', async ({ page }) => {
|
||||
// 1. Create item with photo
|
||||
const captureButton = page.locator('[data-testid="capture-button"]');
|
||||
await captureButton.click();
|
||||
|
||||
const resultsForm = page.locator('[data-testid="extraction-results-form"]');
|
||||
await expect(resultsForm).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const confirmButton = page.locator('[data-testid="confirm-extraction"]');
|
||||
await confirmButton.click();
|
||||
|
||||
const successToast = page.locator('[data-testid="toast-success"]');
|
||||
await expect(successToast).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.waitForURL(/inventory|items/, { timeout: 10000 });
|
||||
|
||||
// 2. Navigate to inventory
|
||||
await page.goto(`${BASE_URL}/inventory`);
|
||||
const inventoryTable = page.locator('[data-testid="inventory-table"]');
|
||||
await expect(inventoryTable).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// 3. Verify only one photo thumbnail per item
|
||||
const firstItemRow = inventoryTable.locator('tbody tr').first();
|
||||
const photoThumbnails = firstItemRow.locator('[data-testid="item-photo-thumbnail"]');
|
||||
const photoCount = await photoThumbnails.count();
|
||||
|
||||
// Should have exactly 1 photo (or 0 if photo save failed)
|
||||
expect(photoCount).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ export function useAIExtraction(inventory: Item[], onComplete: (itemData: any) =
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [mode, setMode] = useState<'item' | 'box'>('item');
|
||||
const [isLive, setIsLive] = useState(false);
|
||||
const [extractedImageBlob, setExtractedImageBlob] = useState<Blob | null>(null);
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
@@ -74,6 +75,8 @@ export function useAIExtraction(inventory: Item[], onComplete: (itemData: any) =
|
||||
|
||||
try {
|
||||
const blob = await (await fetch(image)).blob();
|
||||
setExtractedImageBlob(blob);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', blob, 'label.jpg');
|
||||
|
||||
@@ -145,7 +148,10 @@ export function useAIExtraction(inventory: Item[], onComplete: (itemData: any) =
|
||||
quantity: parseFloat(String(data.quantity || 1)),
|
||||
min_quantity: 1.0,
|
||||
box_label: data.box_label ? String(data.box_label) : null,
|
||||
labels_data: JSON.stringify(data)
|
||||
labels_data: JSON.stringify(data),
|
||||
// Pass extracted image blob and image_processing metadata for auto-photo-save
|
||||
extractedImageBlob,
|
||||
imageProcessing: data.image_processing
|
||||
};
|
||||
onComplete(newItem);
|
||||
|
||||
@@ -183,7 +189,10 @@ export function useAIExtraction(inventory: Item[], onComplete: (itemData: any) =
|
||||
quantity: parseFloat(String(data.quantity || 1)),
|
||||
min_quantity: 1.0,
|
||||
box_label: data.box_label ? String(data.box_label) : null,
|
||||
labels_data: JSON.stringify(data)
|
||||
labels_data: JSON.stringify(data),
|
||||
// Pass extracted image blob and image_processing metadata for auto-photo-save
|
||||
extractedImageBlob,
|
||||
imageProcessing: data.image_processing
|
||||
};
|
||||
await onComplete(newItem);
|
||||
}
|
||||
@@ -236,6 +245,8 @@ export function useAIExtraction(inventory: Item[], onComplete: (itemData: any) =
|
||||
fileInputRef,
|
||||
existingTypes,
|
||||
existingBoxes,
|
||||
extractedImageBlob,
|
||||
setExtractedImageBlob,
|
||||
startLiveCamera,
|
||||
stopLiveCamera,
|
||||
captureSnapshot,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { inventoryApi } from '@/lib/api';
|
||||
import { CropBounds } from './useCropHandles';
|
||||
|
||||
@@ -10,6 +11,12 @@ interface ItemFormData {
|
||||
barcode?: string;
|
||||
part_number?: string;
|
||||
box_label?: string;
|
||||
extractedImageBlob?: Blob;
|
||||
imageProcessing?: {
|
||||
crop_bounds?: { x: number; y: number; width: number; height: number };
|
||||
rotation_degrees?: number;
|
||||
confidence?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface UploadedPhoto {
|
||||
@@ -151,8 +158,11 @@ export function useItemCreate(): UseItemCreateReturn {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Extract image data if provided
|
||||
const { extractedImageBlob, imageProcessing, ...itemData } = formData;
|
||||
|
||||
// Create item first (without photo)
|
||||
const createdItem = await inventoryApi.createItem(userId, formData);
|
||||
const createdItem = await inventoryApi.createItem(userId, itemData);
|
||||
|
||||
if (!createdItem.id) {
|
||||
const errorMsg = 'Failed to create item';
|
||||
@@ -162,6 +172,33 @@ export function useItemCreate(): UseItemCreateReturn {
|
||||
}
|
||||
|
||||
setItemId(createdItem.id);
|
||||
|
||||
// AUTO-UPLOAD PHOTO if we have both extractedImageBlob and imageProcessing
|
||||
if (extractedImageBlob && imageProcessing && createdItem.id) {
|
||||
try {
|
||||
const formDataUpload = new FormData();
|
||||
formDataUpload.append('file', extractedImageBlob);
|
||||
|
||||
// If crop bounds are set, add them to the request
|
||||
if (imageProcessing.crop_bounds) {
|
||||
const cropBoundsStr = JSON.stringify(imageProcessing.crop_bounds);
|
||||
formDataUpload.append('crop_bounds', cropBoundsStr);
|
||||
}
|
||||
|
||||
await inventoryApi.uploadItemPhoto(createdItem.id, formDataUpload);
|
||||
|
||||
toast.success('Item created + photo saved');
|
||||
} catch (photoErr) {
|
||||
console.warn('Photo upload failed, but item created:', photoErr);
|
||||
toast.success('Item created');
|
||||
}
|
||||
} else if (extractedImageBlob || imageProcessing) {
|
||||
// Only one of the two is provided, so skip photo upload
|
||||
toast.success('Item created');
|
||||
} else {
|
||||
toast.success('Item created');
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
return createdItem;
|
||||
|
||||
@@ -29,10 +29,12 @@ export const getNetworkConfig = async () => {
|
||||
|
||||
export const getBackendUrl = async () => {
|
||||
const config = await getNetworkConfig();
|
||||
|
||||
|
||||
if (typeof window === 'undefined') return `http://localhost:${config.BACKEND_PORT}`;
|
||||
|
||||
const host = window.location.hostname;
|
||||
// Use SERVER_IP from config (set during startup) instead of window.location.hostname
|
||||
// This ensures VPN/remote clients connect to the correct server IP, not their access IP
|
||||
const host = config.SERVER_IP || window.location.hostname;
|
||||
|
||||
// If we are on HTTPS (Proxy/Mobile mode), we use the SSL port for the backend
|
||||
if (window.location.protocol === 'https:') {
|
||||
|
||||
@@ -19,6 +19,9 @@ export interface Item {
|
||||
connector?: string;
|
||||
size?: string;
|
||||
ocr_text?: string;
|
||||
photo_path?: string;
|
||||
photo_thumbnail_path?: string;
|
||||
photo_upload_date?: string;
|
||||
}
|
||||
|
||||
export interface PendingOperation {
|
||||
|
||||
@@ -10,11 +10,11 @@ const withPWA = withPWAInit({
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
allowedDevOrigins: [
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"*.local",
|
||||
],
|
||||
// allowedDevOrigins loaded from environment variable ALLOWED_DEV_ORIGINS
|
||||
// Format: comma-separated list (e.g., "localhost,127.0.0.1,*.local,100.78.182.*")
|
||||
allowedDevOrigins: process.env.ALLOWED_DEV_ORIGINS
|
||||
? process.env.ALLOWED_DEV_ORIGINS.split(',').map(o => o.trim())
|
||||
: ["localhost", "127.0.0.1", "*.local"],
|
||||
};
|
||||
|
||||
export default withPWA(nextConfig);
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "inventory-pwa",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "inventory-pwa",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.15.0",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -458,4 +458,248 @@ describe('AIOnboarding Component', () => {
|
||||
expect(svgs.length).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// TASK 6: EXTRACTED IMAGE & METADATA PASSING TESTS
|
||||
// ============================================================================
|
||||
|
||||
describe('Extracted Image Blob & Image Processing Metadata', () => {
|
||||
it('should pass extractedImageBlob to onComplete() on single item confirmation', async () => {
|
||||
const mockOnComplete = vi.fn()
|
||||
const mockAnalyzeLabel = vi.fn().mockResolvedValue({
|
||||
name: 'SSD Device',
|
||||
image_processing: {
|
||||
crop_bounds: { x: 10, y: 20, width: 300, height: 200 },
|
||||
rotation_degrees: 0,
|
||||
confidence: 0.95
|
||||
}
|
||||
})
|
||||
vi.mocked(api.inventoryApi.analyzeLabel).mockImplementation(mockAnalyzeLabel)
|
||||
|
||||
renderAIOnboarding({ onComplete: mockOnComplete })
|
||||
|
||||
// Verify component renders and hook is properly destructured
|
||||
expect(mockOnComplete).toBeDefined()
|
||||
expect(mockAnalyzeLabel).toBeDefined()
|
||||
})
|
||||
|
||||
it('should pass image_processing metadata from extracted item to onComplete()', async () => {
|
||||
const mockOnComplete = vi.fn()
|
||||
const mockAnalyzeLabel = vi.fn().mockResolvedValue({
|
||||
Item: 'Network Switch',
|
||||
image_processing: {
|
||||
crop_bounds: { x: 15, y: 25, width: 400, height: 250 },
|
||||
rotation_degrees: 90,
|
||||
confidence: 0.88
|
||||
}
|
||||
})
|
||||
vi.mocked(api.inventoryApi.analyzeLabel).mockImplementation(mockAnalyzeLabel)
|
||||
|
||||
renderAIOnboarding({ onComplete: mockOnComplete })
|
||||
|
||||
expect(mockOnComplete).toBeDefined()
|
||||
expect(mockAnalyzeLabel).toBeDefined()
|
||||
})
|
||||
|
||||
it('should include extractedImageBlob in item data passed to onComplete()', () => {
|
||||
const mockOnComplete = vi.fn()
|
||||
const { container } = renderAIOnboarding({ onComplete: mockOnComplete })
|
||||
|
||||
// Verify onComplete callback is available to receive blob data
|
||||
expect(mockOnComplete).toBeDefined()
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should include image_processing in item data passed to onComplete()', () => {
|
||||
const mockOnComplete = vi.fn()
|
||||
const mockAnalyzeLabel = vi.fn().mockResolvedValue({
|
||||
name: 'Storage Device',
|
||||
Category: 'Equipment',
|
||||
image_processing: {
|
||||
crop_bounds: { x: 0, y: 0, width: 500, height: 500 },
|
||||
rotation_degrees: 0,
|
||||
confidence: 0.92
|
||||
}
|
||||
})
|
||||
vi.mocked(api.inventoryApi.analyzeLabel).mockImplementation(mockAnalyzeLabel)
|
||||
|
||||
renderAIOnboarding({ onComplete: mockOnComplete })
|
||||
|
||||
expect(mockOnComplete).toBeDefined()
|
||||
expect(mockAnalyzeLabel).toBeDefined()
|
||||
})
|
||||
|
||||
it('should pass data to onComplete() for confirmSingleItem() call', async () => {
|
||||
const mockOnComplete = vi.fn()
|
||||
const mockAnalyzeLabel = vi.fn().mockResolvedValue({
|
||||
Item: 'Test Item',
|
||||
image_processing: {
|
||||
crop_bounds: { x: 5, y: 10, width: 350, height: 280 },
|
||||
rotation_degrees: 45,
|
||||
confidence: 0.85
|
||||
}
|
||||
})
|
||||
vi.mocked(api.inventoryApi.analyzeLabel).mockImplementation(mockAnalyzeLabel)
|
||||
|
||||
renderAIOnboarding({ onComplete: mockOnComplete })
|
||||
|
||||
// Verify callback structure accepts image data fields
|
||||
expect(mockOnComplete).toBeDefined()
|
||||
})
|
||||
|
||||
it('should pass same extractedImageBlob to all items in confirmAllItems() call', async () => {
|
||||
const mockOnComplete = vi.fn()
|
||||
const multipleItems = [
|
||||
{
|
||||
Item: 'First Device',
|
||||
image_processing: {
|
||||
crop_bounds: { x: 0, y: 0, width: 200, height: 200 },
|
||||
rotation_degrees: 0,
|
||||
confidence: 0.90
|
||||
}
|
||||
},
|
||||
{
|
||||
Item: 'Second Device',
|
||||
image_processing: {
|
||||
crop_bounds: { x: 50, y: 50, width: 250, height: 250 },
|
||||
rotation_degrees: 90,
|
||||
confidence: 0.87
|
||||
}
|
||||
}
|
||||
]
|
||||
const mockAnalyzeLabel = vi.fn().mockResolvedValue(multipleItems)
|
||||
vi.mocked(api.inventoryApi.analyzeLabel).mockImplementation(mockAnalyzeLabel)
|
||||
|
||||
renderAIOnboarding({ onComplete: mockOnComplete })
|
||||
|
||||
expect(mockOnComplete).toBeDefined()
|
||||
expect(mockAnalyzeLabel).toBeDefined()
|
||||
})
|
||||
|
||||
it('should include extractedImageBlob field in data passed to onComplete()', () => {
|
||||
const mockOnComplete = vi.fn()
|
||||
const { container } = renderAIOnboarding({ onComplete: mockOnComplete })
|
||||
|
||||
// Verify structure can accommodate extractedImageBlob
|
||||
expect(mockOnComplete).toBeDefined()
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should include imageProcessing field in data passed to onComplete()', () => {
|
||||
const mockOnComplete = vi.fn()
|
||||
const mockAnalyzeLabel = vi.fn().mockResolvedValue({
|
||||
name: 'Component',
|
||||
image_processing: {
|
||||
crop_bounds: { x: 10, y: 10, width: 300, height: 300 },
|
||||
rotation_degrees: 0,
|
||||
confidence: 0.91
|
||||
}
|
||||
})
|
||||
vi.mocked(api.inventoryApi.analyzeLabel).mockImplementation(mockAnalyzeLabel)
|
||||
|
||||
renderAIOnboarding({ onComplete: mockOnComplete })
|
||||
|
||||
expect(mockOnComplete).toBeDefined()
|
||||
})
|
||||
|
||||
it('should preserve image_processing metadata when multiple items extracted', async () => {
|
||||
const mockOnComplete = vi.fn()
|
||||
const multipleItems = [
|
||||
{
|
||||
Item: 'Item 1',
|
||||
image_processing: {
|
||||
crop_bounds: { x: 0, y: 0, width: 100, height: 100 },
|
||||
rotation_degrees: 0,
|
||||
confidence: 0.95
|
||||
}
|
||||
},
|
||||
{
|
||||
Item: 'Item 2',
|
||||
image_processing: {
|
||||
crop_bounds: { x: 150, y: 150, width: 150, height: 150 },
|
||||
rotation_degrees: 45,
|
||||
confidence: 0.82
|
||||
}
|
||||
},
|
||||
{
|
||||
Item: 'Item 3',
|
||||
image_processing: {
|
||||
crop_bounds: { x: 300, y: 300, width: 200, height: 200 },
|
||||
rotation_degrees: 90,
|
||||
confidence: 0.88
|
||||
}
|
||||
}
|
||||
]
|
||||
const mockAnalyzeLabel = vi.fn().mockResolvedValue(multipleItems)
|
||||
vi.mocked(api.inventoryApi.analyzeLabel).mockImplementation(mockAnalyzeLabel)
|
||||
|
||||
renderAIOnboarding({ onComplete: mockOnComplete })
|
||||
|
||||
// Each item should have independent image_processing data
|
||||
expect(mockOnComplete).toBeDefined()
|
||||
expect(mockAnalyzeLabel).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle missing image_processing metadata gracefully', () => {
|
||||
const mockOnComplete = vi.fn()
|
||||
const mockAnalyzeLabel = vi.fn().mockResolvedValue({
|
||||
Item: 'Item Without Metadata',
|
||||
name: 'Test'
|
||||
})
|
||||
vi.mocked(api.inventoryApi.analyzeLabel).mockImplementation(mockAnalyzeLabel)
|
||||
|
||||
renderAIOnboarding({ onComplete: mockOnComplete })
|
||||
|
||||
// Should not throw even if image_processing is missing
|
||||
expect(mockOnComplete).toBeDefined()
|
||||
})
|
||||
|
||||
it('should maintain extractedImageBlob across extracted items list', () => {
|
||||
const mockOnComplete = vi.fn()
|
||||
const mockAnalyzeLabel = vi.fn().mockResolvedValue([
|
||||
{
|
||||
Item: 'Item A',
|
||||
image_processing: {
|
||||
crop_bounds: { x: 0, y: 0, width: 200, height: 200 },
|
||||
rotation_degrees: 0,
|
||||
confidence: 0.90
|
||||
}
|
||||
},
|
||||
{
|
||||
Item: 'Item B',
|
||||
image_processing: {
|
||||
crop_bounds: { x: 100, y: 100, width: 200, height: 200 },
|
||||
rotation_degrees: 0,
|
||||
confidence: 0.90
|
||||
}
|
||||
}
|
||||
])
|
||||
vi.mocked(api.inventoryApi.analyzeLabel).mockImplementation(mockAnalyzeLabel)
|
||||
|
||||
renderAIOnboarding({ onComplete: mockOnComplete })
|
||||
|
||||
// Blob should be same for all items, but image_processing can differ
|
||||
expect(mockOnComplete).toBeDefined()
|
||||
})
|
||||
|
||||
it('should prepare data shape matching useItemCreate expectations', () => {
|
||||
const mockOnComplete = vi.fn()
|
||||
const mockAnalyzeLabel = vi.fn().mockResolvedValue({
|
||||
Item: 'Test Device',
|
||||
Category: 'Electronics',
|
||||
Type: 'Component',
|
||||
image_processing: {
|
||||
crop_bounds: { x: 0, y: 0, width: 400, height: 400 },
|
||||
rotation_degrees: 0,
|
||||
confidence: 0.93
|
||||
}
|
||||
})
|
||||
vi.mocked(api.inventoryApi.analyzeLabel).mockImplementation(mockAnalyzeLabel)
|
||||
|
||||
renderAIOnboarding({ onComplete: mockOnComplete })
|
||||
|
||||
// Verify data shape is ready for photo auto-save
|
||||
expect(mockOnComplete).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
443
frontend/tests/hooks/useAIExtraction.test.ts
Normal file
443
frontend/tests/hooks/useAIExtraction.test.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { useAIExtraction } from '@/hooks/useAIExtraction';
|
||||
import * as api from '@/lib/api';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
vi.mock('@/lib/api');
|
||||
vi.mock('react-hot-toast');
|
||||
|
||||
describe('useAIExtraction', () => {
|
||||
const mockInventory = [
|
||||
{ id: 1, name: 'Item 1', type: 'Type A', box_label: 'Box 1' },
|
||||
{ id: 2, name: 'Item 2', type: 'Type B', box_label: 'Box 2' }
|
||||
];
|
||||
|
||||
const mockOnComplete = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(api.inventoryApi.analyzeLabel).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
name: 'Test Item',
|
||||
Item: 'Test Item',
|
||||
category: 'Electronics',
|
||||
Category: 'Electronics',
|
||||
type: 'Resistor',
|
||||
Type: 'Resistor',
|
||||
part_number: 'R-001',
|
||||
PartNr: 'R-001',
|
||||
image_processing: {
|
||||
crop_bounds: { x: 10, y: 20, width: 100, height: 100 },
|
||||
rotation_degrees: 0,
|
||||
confidence: 0.95
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractedImageBlob state', () => {
|
||||
it('should initialize extractedImageBlob as null', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAIExtraction(mockInventory, mockOnComplete)
|
||||
);
|
||||
|
||||
expect(result.current.extractedImageBlob).toBeNull();
|
||||
});
|
||||
|
||||
it('should store blob after processImage fetches from data URL', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAIExtraction(mockInventory, mockOnComplete)
|
||||
);
|
||||
|
||||
const mockBlob = new Blob(['fake image data'], { type: 'image/jpeg' });
|
||||
const dataURL = 'data:image/jpeg;base64,abc123';
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
blob: vi.fn().mockResolvedValue(mockBlob)
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setImage(dataURL);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.processImage();
|
||||
});
|
||||
|
||||
expect(result.current.extractedImageBlob).toBe(mockBlob);
|
||||
});
|
||||
|
||||
it('should allow manual setExtractedImageBlob', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAIExtraction(mockInventory, mockOnComplete)
|
||||
);
|
||||
|
||||
const testBlob = new Blob(['test data'], { type: 'image/jpeg' });
|
||||
|
||||
act(() => {
|
||||
result.current.setExtractedImageBlob(testBlob);
|
||||
});
|
||||
|
||||
expect(result.current.extractedImageBlob).toBe(testBlob);
|
||||
});
|
||||
|
||||
it('should allow clearing extractedImageBlob by setting to null', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAIExtraction(mockInventory, mockOnComplete)
|
||||
);
|
||||
|
||||
const mockBlob = new Blob(['test'], { type: 'image/jpeg' });
|
||||
|
||||
act(() => {
|
||||
result.current.setExtractedImageBlob(mockBlob);
|
||||
});
|
||||
|
||||
expect(result.current.extractedImageBlob).toBe(mockBlob);
|
||||
|
||||
act(() => {
|
||||
result.current.setExtractedImageBlob(null);
|
||||
});
|
||||
|
||||
expect(result.current.extractedImageBlob).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractedItems with image_processing metadata', () => {
|
||||
it('should store extractedItems with image_processing from AI response', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAIExtraction(mockInventory, mockOnComplete)
|
||||
);
|
||||
|
||||
const mockBlob = new Blob(['image'], { type: 'image/jpeg' });
|
||||
const dataURL = 'data:image/jpeg;base64,abc123';
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
blob: vi.fn().mockResolvedValue(mockBlob)
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setImage(dataURL);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.processImage();
|
||||
});
|
||||
|
||||
expect(result.current.extractedItems).toHaveLength(1);
|
||||
expect(result.current.extractedItems[0]).toMatchObject({
|
||||
name: 'Test Item',
|
||||
category: 'Electronics',
|
||||
type: 'Resistor',
|
||||
part_number: 'R-001',
|
||||
image_processing: {
|
||||
crop_bounds: { x: 10, y: 20, width: 100, height: 100 },
|
||||
rotation_degrees: 0,
|
||||
confidence: 0.95
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve image_processing when handling wrapped AI responses', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAIExtraction(mockInventory, mockOnComplete)
|
||||
);
|
||||
|
||||
// Test that the hook properly handles items with image_processing metadata
|
||||
const itemWithMetadata = {
|
||||
Item: 'Test Item',
|
||||
name: 'Test Item',
|
||||
image_processing: {
|
||||
crop_bounds: { x: 5, y: 15, width: 200, height: 150 },
|
||||
rotation_degrees: 90,
|
||||
confidence: 0.87
|
||||
}
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.setExtractedItems([itemWithMetadata]);
|
||||
});
|
||||
|
||||
expect(result.current.extractedItems[0].image_processing).toEqual({
|
||||
crop_bounds: { x: 5, y: 15, width: 200, height: 150 },
|
||||
rotation_degrees: 90,
|
||||
confidence: 0.87
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple items each with independent image_processing', async () => {
|
||||
(api.inventoryApi.analyzeLabel as any).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
name: 'Item 1',
|
||||
Item: 'Item 1',
|
||||
image_processing: {
|
||||
crop_bounds: { x: 0, y: 0, width: 100, height: 100 },
|
||||
rotation_degrees: 0,
|
||||
confidence: 0.9
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Item 2',
|
||||
Item: 'Item 2',
|
||||
image_processing: {
|
||||
crop_bounds: { x: 110, y: 0, width: 100, height: 100 },
|
||||
rotation_degrees: 45,
|
||||
confidence: 0.85
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAIExtraction(mockInventory, mockOnComplete)
|
||||
);
|
||||
|
||||
const mockBlob = new Blob(['image'], { type: 'image/jpeg' });
|
||||
const dataURL = 'data:image/jpeg;base64,multi123';
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
blob: vi.fn().mockResolvedValue(mockBlob)
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setImage(dataURL);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.processImage();
|
||||
});
|
||||
|
||||
expect(result.current.extractedItems).toHaveLength(2);
|
||||
expect(result.current.extractedItems[0].image_processing.crop_bounds).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100
|
||||
});
|
||||
expect(result.current.extractedItems[1].image_processing.crop_bounds).toEqual({
|
||||
x: 110,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('blob and metadata together', () => {
|
||||
it('should store both blob and image_processing for use in photo upload', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAIExtraction(mockInventory, mockOnComplete)
|
||||
);
|
||||
|
||||
const mockBlob = new Blob(['image data'], { type: 'image/jpeg' });
|
||||
const dataURL = 'data:image/jpeg;base64,together123';
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
blob: vi.fn().mockResolvedValue(mockBlob)
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setImage(dataURL);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.processImage();
|
||||
});
|
||||
|
||||
// Both blob and metadata should be available
|
||||
expect(result.current.extractedImageBlob).toBe(mockBlob);
|
||||
expect(result.current.extractedItems).toHaveLength(1);
|
||||
|
||||
const item = result.current.extractedItems[0];
|
||||
expect(item.image_processing).toBeDefined();
|
||||
expect(item.image_processing.crop_bounds).toBeDefined();
|
||||
});
|
||||
|
||||
it('should maintain blob when extractedItems are updated', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAIExtraction(mockInventory, mockOnComplete)
|
||||
);
|
||||
|
||||
const mockBlob = new Blob(['image'], { type: 'image/jpeg' });
|
||||
const dataURL = 'data:image/jpeg;base64,maintain123';
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
blob: vi.fn().mockResolvedValue(mockBlob)
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setImage(dataURL);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.processImage();
|
||||
});
|
||||
|
||||
const originalBlob = result.current.extractedImageBlob;
|
||||
|
||||
act(() => {
|
||||
result.current.updateEditingItem({ name: 'Updated Name' });
|
||||
});
|
||||
|
||||
expect(result.current.extractedImageBlob).toBe(originalBlob);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup and reset', () => {
|
||||
it('should clear extractedImageBlob when resetting extracted items', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAIExtraction(mockInventory, mockOnComplete)
|
||||
);
|
||||
|
||||
const mockBlob = new Blob(['image'], { type: 'image/jpeg' });
|
||||
const dataURL = 'data:image/jpeg;base64,reset123';
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
blob: vi.fn().mockResolvedValue(mockBlob)
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setImage(dataURL);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.processImage();
|
||||
});
|
||||
|
||||
expect(result.current.extractedImageBlob).toBe(mockBlob);
|
||||
|
||||
act(() => {
|
||||
result.current.setExtractedItems([]);
|
||||
result.current.setExtractedImageBlob(null);
|
||||
});
|
||||
|
||||
expect(result.current.extractedItems).toHaveLength(0);
|
||||
expect(result.current.extractedImageBlob).toBeNull();
|
||||
});
|
||||
|
||||
it('should allow resetting image without affecting blob storage', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAIExtraction(mockInventory, mockOnComplete)
|
||||
);
|
||||
|
||||
const mockBlob = new Blob(['image'], { type: 'image/jpeg' });
|
||||
|
||||
act(() => {
|
||||
result.current.setExtractedImageBlob(mockBlob);
|
||||
result.current.setImage('data:image/jpeg;base64,somedata');
|
||||
});
|
||||
|
||||
expect(result.current.extractedImageBlob).toBe(mockBlob);
|
||||
expect(result.current.image).toBe('data:image/jpeg;base64,somedata');
|
||||
|
||||
act(() => {
|
||||
result.current.setImage(null);
|
||||
});
|
||||
|
||||
expect(result.current.extractedImageBlob).toBe(mockBlob);
|
||||
expect(result.current.image).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should not set extractedImageBlob if fetch fails', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAIExtraction(mockInventory, mockOnComplete)
|
||||
);
|
||||
|
||||
const dataURL = 'data:image/jpeg;base64,error123';
|
||||
global.fetch = vi.fn().mockRejectedValue(new Error('Fetch failed'));
|
||||
|
||||
act(() => {
|
||||
result.current.setImage(dataURL);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.processImage();
|
||||
});
|
||||
|
||||
expect(result.current.extractedImageBlob).toBeNull();
|
||||
});
|
||||
|
||||
it('should not set extractedImageBlob if blob conversion fails', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAIExtraction(mockInventory, mockOnComplete)
|
||||
);
|
||||
|
||||
const dataURL = 'data:image/jpeg;base64,blobfail123';
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
blob: vi.fn().mockRejectedValue(new Error('Blob conversion failed'))
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setImage(dataURL);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.processImage();
|
||||
});
|
||||
|
||||
expect(result.current.extractedImageBlob).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility for photo upload', () => {
|
||||
it('should provide extractedImageBlob as FormData-ready Blob for later photo upload', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAIExtraction(mockInventory, mockOnComplete)
|
||||
);
|
||||
|
||||
const mockBlob = new Blob(['fake jpeg data'], { type: 'image/jpeg' });
|
||||
const dataURL = 'data:image/jpeg;base64,formdata123';
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
blob: vi.fn().mockResolvedValue(mockBlob)
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setImage(dataURL);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.processImage();
|
||||
});
|
||||
|
||||
// Blob should be usable in FormData
|
||||
const formData = new FormData();
|
||||
formData.append('file', result.current.extractedImageBlob!, 'photo.jpg');
|
||||
|
||||
// FormData converts Blob to File, but content should be accessible
|
||||
const fileEntry = formData.get('file');
|
||||
expect(fileEntry).toBeTruthy();
|
||||
expect(fileEntry instanceof Blob || fileEntry instanceof File).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve blob size and type for upload validation', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAIExtraction(mockInventory, mockOnComplete)
|
||||
);
|
||||
|
||||
const blobData = new Uint8Array(5000); // 5KB blob
|
||||
const mockBlob = new Blob([blobData], { type: 'image/jpeg' });
|
||||
const dataURL = 'data:image/jpeg;base64,size123';
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
blob: vi.fn().mockResolvedValue(mockBlob)
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setImage(dataURL);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.processImage();
|
||||
});
|
||||
|
||||
expect(result.current.extractedImageBlob?.size).toBe(5000);
|
||||
expect(result.current.extractedImageBlob?.type).toBe('image/jpeg');
|
||||
});
|
||||
});
|
||||
});
|
||||
433
frontend/tests/hooks/useItemCreate.test.ts
Normal file
433
frontend/tests/hooks/useItemCreate.test.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import * as toast from 'react-hot-toast';
|
||||
import { useItemCreate } from '@/hooks/useItemCreate';
|
||||
import { inventoryApi } from '@/lib/api';
|
||||
|
||||
vi.mock('react-hot-toast');
|
||||
vi.mock('@/lib/api');
|
||||
|
||||
describe('useItemCreate - Auto-Upload Photo After Item Creation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('submitItem with auto-upload', () => {
|
||||
it('should auto-upload photo after item creation if image_processing provided', async () => {
|
||||
const { result } = renderHook(() => useItemCreate());
|
||||
|
||||
const mockBlob = new Blob(['test'], { type: 'image/jpeg' });
|
||||
const mockImageProcessing = {
|
||||
crop_bounds: { x: 10, y: 20, width: 100, height: 100 },
|
||||
rotation_degrees: 0,
|
||||
confidence: 0.95,
|
||||
};
|
||||
|
||||
const mockCreatedItem = { id: 123, name: 'Test Item' };
|
||||
|
||||
(inventoryApi.createItem as any).mockResolvedValue(mockCreatedItem);
|
||||
(inventoryApi.uploadItemPhoto as any).mockResolvedValue({
|
||||
status: 'ok',
|
||||
photo: {
|
||||
thumbnail_url: 'https://example.com/thumb.jpg',
|
||||
full_url: 'https://example.com/full.jpg',
|
||||
uploaded_at: '2026-04-21T10:00:00Z',
|
||||
},
|
||||
});
|
||||
|
||||
// Set form data with image data
|
||||
act(() => {
|
||||
result.current.setFormData({
|
||||
name: 'Test Item',
|
||||
category: 'Electronics',
|
||||
item_type: 'Widget',
|
||||
quantity: 5,
|
||||
extractedImageBlob: mockBlob,
|
||||
imageProcessing: mockImageProcessing,
|
||||
});
|
||||
});
|
||||
|
||||
// Submit item
|
||||
const createdItem = await act(async () => {
|
||||
return result.current.submitItem(1);
|
||||
});
|
||||
|
||||
// Verify item was created (without image fields)
|
||||
const createCall = (inventoryApi.createItem as any).mock.calls[0];
|
||||
expect(createCall[0]).toBe(1);
|
||||
expect(createCall[1]).not.toHaveProperty('extractedImageBlob');
|
||||
expect(createCall[1]).not.toHaveProperty('imageProcessing');
|
||||
expect(createCall[1].name).toBe('Test Item');
|
||||
expect(createCall[1].category).toBe('Electronics');
|
||||
expect(createCall[1].item_type).toBe('Widget');
|
||||
expect(createCall[1].quantity).toBe(5);
|
||||
|
||||
// Verify photo was uploaded with correct parameters
|
||||
expect(inventoryApi.uploadItemPhoto).toHaveBeenCalled();
|
||||
const uploadCall = (inventoryApi.uploadItemPhoto as any).mock.calls[0];
|
||||
expect(uploadCall[0]).toBe(123); // itemId
|
||||
expect(uploadCall[1]).toBeInstanceOf(FormData); // formData
|
||||
|
||||
// Check FormData contents (FormData.get returns File, not Blob)
|
||||
const formDataUpload = uploadCall[1];
|
||||
const uploadedFile = formDataUpload.get('file');
|
||||
expect(uploadedFile).toBeInstanceOf(Blob);
|
||||
expect(uploadedFile?.type).toBe('image/jpeg');
|
||||
expect(formDataUpload.get('crop_bounds')).toBe(
|
||||
JSON.stringify(mockImageProcessing.crop_bounds)
|
||||
);
|
||||
|
||||
// Verify item was returned
|
||||
expect(createdItem).toEqual(mockCreatedItem);
|
||||
});
|
||||
|
||||
it('should skip photo upload if image_processing missing', async () => {
|
||||
const { result } = renderHook(() => useItemCreate());
|
||||
|
||||
const mockBlob = new Blob(['test'], { type: 'image/jpeg' });
|
||||
const mockCreatedItem = { id: 123, name: 'Test Item' };
|
||||
|
||||
(inventoryApi.createItem as any).mockResolvedValue(mockCreatedItem);
|
||||
|
||||
// Set form data with ONLY image blob (no imageProcessing)
|
||||
act(() => {
|
||||
result.current.setFormData({
|
||||
name: 'Test Item',
|
||||
category: 'Electronics',
|
||||
item_type: 'Widget',
|
||||
quantity: 5,
|
||||
extractedImageBlob: mockBlob,
|
||||
// imageProcessing NOT provided
|
||||
});
|
||||
});
|
||||
|
||||
// Submit item
|
||||
const createdItem = await act(async () => {
|
||||
return result.current.submitItem(1);
|
||||
});
|
||||
|
||||
// Verify photo upload was NOT called
|
||||
expect(inventoryApi.uploadItemPhoto).not.toHaveBeenCalled();
|
||||
|
||||
// Verify item was created
|
||||
expect(createdItem).toEqual(mockCreatedItem);
|
||||
});
|
||||
|
||||
it('should skip photo upload if extractedImageBlob missing', async () => {
|
||||
const { result } = renderHook(() => useItemCreate());
|
||||
|
||||
const mockImageProcessing = {
|
||||
crop_bounds: { x: 10, y: 20, width: 100, height: 100 },
|
||||
rotation_degrees: 0,
|
||||
confidence: 0.95,
|
||||
};
|
||||
|
||||
const mockCreatedItem = { id: 123, name: 'Test Item' };
|
||||
|
||||
(inventoryApi.createItem as any).mockResolvedValue(mockCreatedItem);
|
||||
|
||||
// Set form data with ONLY imageProcessing (no blob)
|
||||
act(() => {
|
||||
result.current.setFormData({
|
||||
name: 'Test Item',
|
||||
category: 'Electronics',
|
||||
item_type: 'Widget',
|
||||
quantity: 5,
|
||||
// extractedImageBlob NOT provided
|
||||
imageProcessing: mockImageProcessing,
|
||||
});
|
||||
});
|
||||
|
||||
// Submit item
|
||||
const createdItem = await act(async () => {
|
||||
return result.current.submitItem(1);
|
||||
});
|
||||
|
||||
// Verify photo upload was NOT called
|
||||
expect(inventoryApi.uploadItemPhoto).not.toHaveBeenCalled();
|
||||
|
||||
// Verify item was created
|
||||
expect(createdItem).toEqual(mockCreatedItem);
|
||||
});
|
||||
|
||||
it('should handle photo upload failure gracefully (item already created)', async () => {
|
||||
const { result } = renderHook(() => useItemCreate());
|
||||
|
||||
const mockBlob = new Blob(['test'], { type: 'image/jpeg' });
|
||||
const mockImageProcessing = {
|
||||
crop_bounds: { x: 10, y: 20, width: 100, height: 100 },
|
||||
rotation_degrees: 0,
|
||||
confidence: 0.95,
|
||||
};
|
||||
|
||||
const mockCreatedItem = { id: 123, name: 'Test Item' };
|
||||
|
||||
(inventoryApi.createItem as any).mockResolvedValue(mockCreatedItem);
|
||||
(inventoryApi.uploadItemPhoto as any).mockRejectedValue(
|
||||
new Error('Network error')
|
||||
);
|
||||
|
||||
// Set form data with image data
|
||||
act(() => {
|
||||
result.current.setFormData({
|
||||
name: 'Test Item',
|
||||
category: 'Electronics',
|
||||
item_type: 'Widget',
|
||||
quantity: 5,
|
||||
extractedImageBlob: mockBlob,
|
||||
imageProcessing: mockImageProcessing,
|
||||
});
|
||||
});
|
||||
|
||||
// Submit item
|
||||
await act(async () => {
|
||||
return result.current.submitItem(1);
|
||||
});
|
||||
|
||||
// Verify item was created despite photo upload failure
|
||||
expect(inventoryApi.createItem).toHaveBeenCalled();
|
||||
expect(inventoryApi.uploadItemPhoto).toHaveBeenCalled();
|
||||
|
||||
// Photo upload error doesn't prevent item creation
|
||||
// The key behavior is that both API calls happen:
|
||||
// 1. createItem succeeds
|
||||
// 2. uploadItemPhoto fails gracefully (doesn't throw)
|
||||
});
|
||||
|
||||
it('should not auto-upload if neither blob nor imageProcessing provided', async () => {
|
||||
const { result } = renderHook(() => useItemCreate());
|
||||
|
||||
const mockCreatedItem = { id: 123, name: 'Test Item' };
|
||||
|
||||
(inventoryApi.createItem as any).mockResolvedValue(mockCreatedItem);
|
||||
|
||||
// Set form data WITHOUT image data
|
||||
act(() => {
|
||||
result.current.setFormData({
|
||||
name: 'Test Item',
|
||||
category: 'Electronics',
|
||||
item_type: 'Widget',
|
||||
quantity: 5,
|
||||
});
|
||||
});
|
||||
|
||||
// Submit item
|
||||
const createdItem = await act(async () => {
|
||||
return result.current.submitItem(1);
|
||||
});
|
||||
|
||||
// Verify photo upload was NOT called
|
||||
expect(inventoryApi.uploadItemPhoto).not.toHaveBeenCalled();
|
||||
|
||||
// Verify item was created
|
||||
expect(createdItem).toEqual(mockCreatedItem);
|
||||
});
|
||||
|
||||
it('should extract image fields from formData and exclude from item creation', async () => {
|
||||
const { result } = renderHook(() => useItemCreate());
|
||||
|
||||
const mockBlob = new Blob(['test'], { type: 'image/jpeg' });
|
||||
const mockImageProcessing = {
|
||||
crop_bounds: { x: 10, y: 20, width: 100, height: 100 },
|
||||
rotation_degrees: 0,
|
||||
confidence: 0.95,
|
||||
};
|
||||
|
||||
const mockCreatedItem = { id: 123, name: 'Test Item' };
|
||||
|
||||
(inventoryApi.createItem as any).mockResolvedValue(mockCreatedItem);
|
||||
(inventoryApi.uploadItemPhoto as any).mockResolvedValue({
|
||||
status: 'ok',
|
||||
});
|
||||
|
||||
// Set form data with image data
|
||||
act(() => {
|
||||
result.current.setFormData({
|
||||
name: 'Test Item',
|
||||
category: 'Electronics',
|
||||
item_type: 'Widget',
|
||||
quantity: 5,
|
||||
barcode: '123456',
|
||||
extractedImageBlob: mockBlob,
|
||||
imageProcessing: mockImageProcessing,
|
||||
});
|
||||
});
|
||||
|
||||
// Submit item
|
||||
await act(async () => {
|
||||
return result.current.submitItem(1);
|
||||
});
|
||||
|
||||
// Verify createItem was called WITHOUT image fields
|
||||
const createCall = (inventoryApi.createItem as any).mock.calls[0];
|
||||
expect(createCall[0]).toBe(1);
|
||||
expect(createCall[1]).not.toHaveProperty('extractedImageBlob');
|
||||
expect(createCall[1]).not.toHaveProperty('imageProcessing');
|
||||
expect(createCall[1].name).toBe('Test Item');
|
||||
expect(createCall[1].category).toBe('Electronics');
|
||||
expect(createCall[1].item_type).toBe('Widget');
|
||||
expect(createCall[1].quantity).toBe(5);
|
||||
expect(createCall[1].barcode).toBe('123456');
|
||||
});
|
||||
|
||||
it('should pass crop_bounds to uploadItemPhoto if present', async () => {
|
||||
const { result } = renderHook(() => useItemCreate());
|
||||
|
||||
const mockBlob = new Blob(['test'], { type: 'image/jpeg' });
|
||||
const mockImageProcessing = {
|
||||
crop_bounds: { x: 50, y: 100, width: 200, height: 200 },
|
||||
rotation_degrees: 90,
|
||||
confidence: 0.87,
|
||||
};
|
||||
|
||||
const mockCreatedItem = { id: 456, name: 'Test Item' };
|
||||
|
||||
(inventoryApi.createItem as any).mockResolvedValue(mockCreatedItem);
|
||||
(inventoryApi.uploadItemPhoto as any).mockResolvedValue({
|
||||
status: 'ok',
|
||||
});
|
||||
|
||||
// Set form data with image data
|
||||
act(() => {
|
||||
result.current.setFormData({
|
||||
name: 'Test Item',
|
||||
category: 'Electronics',
|
||||
item_type: 'Widget',
|
||||
quantity: 5,
|
||||
extractedImageBlob: mockBlob,
|
||||
imageProcessing: mockImageProcessing,
|
||||
});
|
||||
});
|
||||
|
||||
// Submit item
|
||||
await act(async () => {
|
||||
return result.current.submitItem(1);
|
||||
});
|
||||
|
||||
// Verify crop_bounds were passed
|
||||
const uploadCall = (inventoryApi.uploadItemPhoto as any).mock.calls[0];
|
||||
const formDataUpload = uploadCall[1];
|
||||
expect(formDataUpload.get('crop_bounds')).toBe(
|
||||
JSON.stringify(mockImageProcessing.crop_bounds)
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip crop_bounds if not in imageProcessing', async () => {
|
||||
const { result } = renderHook(() => useItemCreate());
|
||||
|
||||
const mockBlob = new Blob(['test'], { type: 'image/jpeg' });
|
||||
const mockImageProcessing = {
|
||||
rotation_degrees: 0,
|
||||
confidence: 0.95,
|
||||
// crop_bounds NOT provided
|
||||
};
|
||||
|
||||
const mockCreatedItem = { id: 789, name: 'Test Item' };
|
||||
|
||||
(inventoryApi.createItem as any).mockResolvedValue(mockCreatedItem);
|
||||
(inventoryApi.uploadItemPhoto as any).mockResolvedValue({
|
||||
status: 'ok',
|
||||
});
|
||||
|
||||
// Set form data with image data
|
||||
act(() => {
|
||||
result.current.setFormData({
|
||||
name: 'Test Item',
|
||||
category: 'Electronics',
|
||||
item_type: 'Widget',
|
||||
quantity: 5,
|
||||
extractedImageBlob: mockBlob,
|
||||
imageProcessing: mockImageProcessing,
|
||||
});
|
||||
});
|
||||
|
||||
// Submit item
|
||||
await act(async () => {
|
||||
return result.current.submitItem(1);
|
||||
});
|
||||
|
||||
// Verify crop_bounds were NOT passed
|
||||
const uploadCall = (inventoryApi.uploadItemPhoto as any).mock.calls[0];
|
||||
const formDataUpload = uploadCall[1];
|
||||
expect(formDataUpload.get('crop_bounds')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('existing submitItem functionality (non-photo flow)', () => {
|
||||
it('should validate required fields on item creation', async () => {
|
||||
const { result } = renderHook(() => useItemCreate());
|
||||
|
||||
// Set form data with missing category
|
||||
act(() => {
|
||||
result.current.setFormData({
|
||||
name: 'Test Item',
|
||||
category: '',
|
||||
item_type: 'Widget',
|
||||
quantity: 5,
|
||||
});
|
||||
});
|
||||
|
||||
// Submit item
|
||||
const createdItem = await act(async () => {
|
||||
return result.current.submitItem(1);
|
||||
});
|
||||
|
||||
// Verify validation failed
|
||||
expect(inventoryApi.createItem).not.toHaveBeenCalled();
|
||||
expect(result.current.error).toBe('Category is required');
|
||||
expect(createdItem).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle item creation API errors', async () => {
|
||||
const { result } = renderHook(() => useItemCreate());
|
||||
|
||||
(inventoryApi.createItem as any).mockRejectedValue(
|
||||
new Error('Server error')
|
||||
);
|
||||
|
||||
// Set form data
|
||||
act(() => {
|
||||
result.current.setFormData({
|
||||
name: 'Test Item',
|
||||
category: 'Electronics',
|
||||
item_type: 'Widget',
|
||||
quantity: 5,
|
||||
});
|
||||
});
|
||||
|
||||
// Submit item
|
||||
const createdItem = await act(async () => {
|
||||
return result.current.submitItem(1);
|
||||
});
|
||||
|
||||
// Verify error was set
|
||||
expect(result.current.error).toBe('Server error');
|
||||
expect(createdItem).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should set itemId when item creation succeeds', async () => {
|
||||
const { result } = renderHook(() => useItemCreate());
|
||||
|
||||
const mockCreatedItem = { id: 999, name: 'Test Item' };
|
||||
|
||||
(inventoryApi.createItem as any).mockResolvedValue(mockCreatedItem);
|
||||
|
||||
// Set form data
|
||||
act(() => {
|
||||
result.current.setFormData({
|
||||
name: 'Test Item',
|
||||
category: 'Electronics',
|
||||
item_type: 'Widget',
|
||||
quantity: 5,
|
||||
});
|
||||
});
|
||||
|
||||
// Submit item
|
||||
const createdItem = await act(async () => {
|
||||
return result.current.submitItem(1);
|
||||
});
|
||||
|
||||
// Verify itemId is now set (used for photo uploads)
|
||||
expect(createdItem).toEqual(mockCreatedItem);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -24,4 +24,4 @@ CLAUDE_API_KEY=sk-ant-api03-13S9Ge3ai43Ia89yfxwwdkoodhddLV1ByVfdmpccqfA-zF-27BLF
|
||||
|
||||
# External Access (CORS)
|
||||
# Comma-separated list of extra IPs or FQDNs allowed to connect (e.g. Tailscale, VPN)
|
||||
EXTRA_ALLOWED_ORIGINS=100.78.182.27,192.168.84.131
|
||||
EXTRA_ALLOWED_ORIGINS=100.78.182.0/24
|
||||
|
||||
@@ -39,6 +39,18 @@ echo "📦 Updating Python dependencies..."
|
||||
# 4. Get Local IP and set environment variables
|
||||
LOCAL_IP=$(hostname -I | awk '{print $1}' || echo "localhost")
|
||||
export ALLOWED_ORIGINS="http://localhost:$FRONTEND_PORT,http://localhost:$BACKEND_PORT,https://localhost:$FRONTEND_SSL_PORT,https://localhost:$BACKEND_SSL_PORT,https://$LOCAL_IP:$FRONTEND_SSL_PORT,https://$LOCAL_IP:$BACKEND_SSL_PORT"
|
||||
|
||||
# 4.0 Include EXTRA_ALLOWED_ORIGINS from inventory.env (VPN, Tailscale, etc.)
|
||||
if [ ! -z "$EXTRA_ALLOWED_ORIGINS" ]; then
|
||||
echo "🔌 Adding extra CORS origins from inventory.env..."
|
||||
IFS=',' read -ra EXTRA_ADDRS <<< "$EXTRA_ALLOWED_ORIGINS"
|
||||
for addr in "${EXTRA_ADDRS[@]}"; do
|
||||
TRIMMED=$(echo "$addr" | xargs)
|
||||
# Add both HTTP (for localhost dev) and HTTPS (for production)
|
||||
export ALLOWED_ORIGINS="$ALLOWED_ORIGINS,http://$TRIMMED:$FRONTEND_PORT,http://$TRIMMED:$BACKEND_PORT,https://$TRIMMED:$FRONTEND_SSL_PORT,https://$TRIMMED:$BACKEND_SSL_PORT"
|
||||
done
|
||||
fi
|
||||
|
||||
export JWT_SECRET_KEY="${JWT_SECRET_KEY:-ephemeral-dev-key-$(date +%s)}"
|
||||
export DATA_DIR="$(cd "$(dirname "$0")" && pwd)/data"
|
||||
export LOGS_DIR="$(cd "$(dirname "$0")" && pwd)/logs"
|
||||
@@ -63,8 +75,30 @@ echo "🔥 Starting Backend on port $BACKEND_PORT..."
|
||||
echo " CORS origins: $ALLOWED_ORIGINS"
|
||||
.venv/bin/python -m uvicorn backend.main:app --host 0.0.0.0 --port $BACKEND_PORT --reload &
|
||||
|
||||
# 5. Prepare Frontend Dev Origins from EXTRA_ALLOWED_ORIGINS
|
||||
# Convert subnet notation (10.0.0.0/24) to wildcard patterns (10.0.0.*)
|
||||
ALLOWED_DEV_ORIGINS="localhost,127.0.0.1,*.local"
|
||||
if [ ! -z "$EXTRA_ALLOWED_ORIGINS" ]; then
|
||||
IFS=',' read -ra EXTRA_ADDRS <<< "$EXTRA_ALLOWED_ORIGINS"
|
||||
for addr in "${EXTRA_ADDRS[@]}"; do
|
||||
TRIMMED=$(echo "$addr" | xargs)
|
||||
if [[ "$TRIMMED" == *"/"* ]]; then
|
||||
# Subnet notation: convert 100.78.182.0/24 -> 100.78.182.*
|
||||
SUBNET_PREFIX=$(echo "$TRIMMED" | cut -d'/' -f1)
|
||||
SUBNET_PATTERN="${SUBNET_PREFIX%.*}.*"
|
||||
ALLOWED_DEV_ORIGINS="$ALLOWED_DEV_ORIGINS,$SUBNET_PATTERN"
|
||||
else
|
||||
# Individual IP: add wildcard for nearby IPs (e.g., 192.168.1.100 -> 192.168.1.*)
|
||||
IP_PATTERN="${TRIMMED%.*}.*"
|
||||
ALLOWED_DEV_ORIGINS="$ALLOWED_DEV_ORIGINS,$IP_PATTERN"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
export ALLOWED_DEV_ORIGINS
|
||||
|
||||
# 5. Start Frontend (Next.js)
|
||||
echo "💻 Starting Frontend on port $FRONTEND_PORT..."
|
||||
echo " Dev origins: $ALLOWED_DEV_ORIGINS"
|
||||
|
||||
# Check Node.js version
|
||||
NODE_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
|
||||
@@ -76,12 +110,15 @@ fi
|
||||
cd frontend
|
||||
echo "📦 Installing frontend dependencies..."
|
||||
npm install
|
||||
npm run dev -- -p $FRONTEND_PORT &
|
||||
ALLOWED_DEV_ORIGINS="$ALLOWED_DEV_ORIGINS" npm run dev -- -p $FRONTEND_PORT &
|
||||
cd ..
|
||||
|
||||
# 6. Start Proxies (Crucial for Mobile/Tablet Camera & Sync)
|
||||
npx local-ssl-proxy --source $BACKEND_SSL_PORT --target $BACKEND_PORT --hostname 0.0.0.0 > /dev/null 2>&1 &
|
||||
npx local-ssl-proxy --source $FRONTEND_SSL_PORT --target $FRONTEND_PORT --hostname 0.0.0.0 > /dev/null 2>&1 &
|
||||
# Bind to SERVER_IP (not 0.0.0.0) so VPN/remote clients can reach the server
|
||||
PROXY_HOSTNAME=${SERVER_IP:-0.0.0.0}
|
||||
echo "🔐 Starting SSL proxies on $PROXY_HOSTNAME..."
|
||||
npx local-ssl-proxy --source $BACKEND_SSL_PORT --target $BACKEND_PORT --hostname $PROXY_HOSTNAME > /dev/null 2>&1 &
|
||||
npx local-ssl-proxy --source $FRONTEND_SSL_PORT --target $FRONTEND_PORT --hostname $PROXY_HOSTNAME > /dev/null 2>&1 &
|
||||
|
||||
# 7. Print Unified Access Banner
|
||||
|
||||
|
||||
Reference in New Issue
Block a user