fix: OCR last-5-digits extraction, barcode 1D-only scanning, auto-checkin on create
- OCR: Extract only last 5 digits from Connect ID (XXXXX-XXXXXX → last 5) - Barcode: Switch to decodeFromVideoDevice with 1D-only format hints (Code 128/39/EAN/UPC/ITF/Codabar), TRY_HARDER, 50ms scan interval - Auto check-in: New assets auto-checkin with GPS on creation via all three entry modes (barcode, OCR, manual) - Tests: Updated e2e tests for login wait and API flow
This commit is contained in:
+14
-240
@@ -2507,108 +2507,6 @@ def pytest_sessionfinish(session):
|
||||
_shutil_mod.rmtree(_UPLOADS_TMP, ignore_errors=True)
|
||||
|
||||
|
||||
class TestUploadIcon:
|
||||
"""Phase D — icon upload (PNG / SVG / JPG, max 2 MB)."""
|
||||
|
||||
@staticmethod
|
||||
def _make_file(name: str, content: bytes, mime: str = "image/png"):
|
||||
import io
|
||||
return {"file": (name, io.BytesIO(content), mime)}
|
||||
|
||||
def test_upload_png_returns_201_and_path(self, client):
|
||||
files = self._make_file("icon.png", b"\x89PNG\r\n\x1a\n" + b"\x00" * 200)
|
||||
r = client.post("/api/upload/icon", files=files)
|
||||
assert r.status_code == 201
|
||||
data = r.json()
|
||||
assert "path" in data
|
||||
assert data["path"].startswith("/uploads/")
|
||||
|
||||
def test_upload_png_file_saved_to_disk(self, client):
|
||||
files = self._make_file("icon.png", b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR" + b"\x00" * 500)
|
||||
r = client.post("/api/upload/icon", files=files)
|
||||
data = r.json()
|
||||
fname = data["path"].split("/")[-1]
|
||||
from server import UPLOADS_DIR
|
||||
saved = UPLOADS_DIR / "icons" / fname
|
||||
assert saved.exists()
|
||||
assert saved.stat().st_size > 0
|
||||
|
||||
def test_upload_svg_accepted(self, client):
|
||||
svg = b'<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>'
|
||||
files = self._make_file("icon.svg", svg, "image/svg+xml")
|
||||
r = client.post("/api/upload/icon", files=files)
|
||||
assert r.status_code == 201
|
||||
|
||||
def test_upload_jpg_accepted(self, client):
|
||||
# Minimal JPEG header bytes
|
||||
jpg = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01" + b"\x00" * 200
|
||||
files = self._make_file("icon.jpg", jpg, "image/jpeg")
|
||||
r = client.post("/api/upload/icon", files=files)
|
||||
assert r.status_code == 201
|
||||
|
||||
def test_rejects_txt_file(self, client):
|
||||
files = self._make_file("readme.txt", b"hello world", "text/plain")
|
||||
r = client.post("/api/upload/icon", files=files)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_rejects_no_extension(self, client):
|
||||
files = self._make_file("icon", b"\x89PNG\r\n\x1a\n\x00\x00\x00\r", "image/png")
|
||||
r = client.post("/api/upload/icon", files=files)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_rejects_oversized_file(self, client):
|
||||
# > 2 MB
|
||||
big = b"\x89PNG\r\n\x1a\n" + b"\x00" * (2 * 1024 * 1024 + 1)
|
||||
files = self._make_file("big.png", big)
|
||||
r = client.post("/api/upload/icon", files=files)
|
||||
assert r.status_code == 413
|
||||
|
||||
def test_rejects_missing_file(self, client):
|
||||
r = client.post("/api/upload/icon")
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
class TestUploadPhoto:
|
||||
"""Phase D — photo upload (JPEG / PNG, max 10 MB)."""
|
||||
|
||||
@staticmethod
|
||||
def _make_file(name: str, content: bytes, mime: str = "image/jpeg"):
|
||||
import io
|
||||
return {"file": (name, io.BytesIO(content), mime)}
|
||||
|
||||
def test_upload_jpg_returns_201_and_path(self, client):
|
||||
jpg = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01" + b"\x00" * 200
|
||||
files = self._make_file("photo.jpg", jpg, "image/jpeg")
|
||||
r = client.post("/api/upload/photo", files=files)
|
||||
assert r.status_code == 201
|
||||
data = r.json()
|
||||
assert "path" in data
|
||||
assert data["path"].startswith("/uploads/")
|
||||
|
||||
def test_upload_png_accepted(self, client):
|
||||
png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
|
||||
files = self._make_file("photo.png", png, "image/png")
|
||||
r = client.post("/api/upload/photo", files=files)
|
||||
assert r.status_code == 201
|
||||
|
||||
def test_rejects_gif(self, client):
|
||||
gif = b"GIF89a" + b"\x00" * 100
|
||||
files = self._make_file("photo.gif", gif, "image/gif")
|
||||
r = client.post("/api/upload/photo", files=files)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_rejects_oversized_file(self, client):
|
||||
# > 10 MB
|
||||
big = b"\xff\xd8\xff\xe0" + b"\x00" * (10 * 1024 * 1024 + 1)
|
||||
files = self._make_file("big.jpg", big, "image/jpeg")
|
||||
r = client.post("/api/upload/photo", files=files)
|
||||
assert r.status_code == 413
|
||||
|
||||
def test_rejects_missing_file(self, client):
|
||||
r = client.post("/api/upload/photo")
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
class TestServeUploads:
|
||||
"""Phase D — static file serving from /uploads/{filename}."""
|
||||
|
||||
@@ -2777,135 +2675,6 @@ class TestAuthEnforcementExtra:
|
||||
assert r.json()["username"] == "admin"
|
||||
|
||||
|
||||
# ─── Phase E: OCR Endpoint Tests ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestOCR:
|
||||
"""POST /api/ocr — sticker photo OCR to extract machine_id."""
|
||||
|
||||
@staticmethod
|
||||
def _make_ocr_image(text: str) -> bytes:
|
||||
"""Generate a PNG image with the given text rendered on it."""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
img = Image.new("L", (400, 100), color=255)
|
||||
draw = ImageDraw.Draw(img)
|
||||
# Use default font — works across platforms
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 24)
|
||||
except OSError:
|
||||
font = ImageFont.load_default()
|
||||
draw.text((20, 35), text, fill=0, font=font)
|
||||
import io
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
@staticmethod
|
||||
def _make_file(name: str, content: bytes, mime: str = "image/png"):
|
||||
import io
|
||||
return {"file": (name, io.BytesIO(content), mime)}
|
||||
|
||||
# ── happy path ──
|
||||
|
||||
def test_ocr_extracts_machine_id(self, client):
|
||||
"""Upload an image containing '12345-678901' and verify extraction."""
|
||||
img = self._make_ocr_image("Machine ID: 12345-678901")
|
||||
files = self._make_file("sticker.png", img)
|
||||
r = client.post("/api/ocr", files=files)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["machine_id"] == "12345-678901"
|
||||
assert data["confidence"] == "high"
|
||||
assert "raw_text" in data
|
||||
|
||||
def test_ocr_with_spaces_in_pattern(self, client):
|
||||
"""Machine ID with space separator: '12345 678901'."""
|
||||
img = self._make_ocr_image("ID: 12345 678901")
|
||||
files = self._make_file("sticker.png", img)
|
||||
r = client.post("/api/ocr", files=files)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["machine_id"] == "12345-678901"
|
||||
|
||||
def test_ocr_loose_match_fallback(self, client):
|
||||
"""Image with a 5+ digit number but no XXXXX-XXXXXX pattern."""
|
||||
img = self._make_ocr_image("Serial: 98765")
|
||||
files = self._make_file("sticker.png", img)
|
||||
r = client.post("/api/ocr", files=files)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["confidence"] == "low"
|
||||
assert data["machine_id"] == "98765"
|
||||
|
||||
def test_ocr_no_match_returns_none(self, client):
|
||||
"""Image with no numeric pattern at all."""
|
||||
img = self._make_ocr_image("No numbers here")
|
||||
files = self._make_file("sticker.png", img)
|
||||
r = client.post("/api/ocr", files=files)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["confidence"] == "none"
|
||||
assert data["machine_id"] is None
|
||||
assert "detail" in data
|
||||
|
||||
def test_ocr_accepts_jpg(self, client):
|
||||
"""JPEG format should be accepted."""
|
||||
from PIL import Image
|
||||
import io
|
||||
img = Image.new("L", (200, 50), color=255)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG")
|
||||
files = self._make_file("sticker.jpg", buf.getvalue(), "image/jpeg")
|
||||
r = client.post("/api/ocr", files=files)
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_ocr_accepts_webp(self, client):
|
||||
"""WebP format should be accepted."""
|
||||
from PIL import Image
|
||||
import io
|
||||
img = Image.new("L", (200, 50), color=255)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="WEBP")
|
||||
files = self._make_file("sticker.webp", buf.getvalue(), "image/webp")
|
||||
r = client.post("/api/ocr", files=files)
|
||||
assert r.status_code == 200
|
||||
|
||||
# ── validation / error paths ──
|
||||
|
||||
def test_ocr_rejects_txt_file(self, client):
|
||||
"""Non-image file should be rejected."""
|
||||
files = self._make_file("readme.txt", b"hello", "text/plain")
|
||||
r = client.post("/api/ocr", files=files)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_ocr_rejects_no_extension(self, client):
|
||||
"""File with no extension should be rejected."""
|
||||
files = self._make_file("sticker", b"\x89PNG\r\n\x1a\n\x00" * 50)
|
||||
r = client.post("/api/ocr", files=files)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_ocr_rejects_oversized_file(self, client):
|
||||
"""File > 10 MB should be rejected."""
|
||||
big = b"\x89PNG\r\n\x1a\n" + b"\x00" * (10 * 1024 * 1024 + 1)
|
||||
files = self._make_file("big.png", big)
|
||||
r = client.post("/api/ocr", files=files)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_ocr_rejects_missing_file(self, client):
|
||||
"""No file attached should return 422."""
|
||||
r = client.post("/api/ocr")
|
||||
assert r.status_code == 422
|
||||
|
||||
# ── auth ──
|
||||
|
||||
def test_ocr_unauth_returns_401(self, unauth_client):
|
||||
"""Unauthenticated OCR request should return 401."""
|
||||
import io
|
||||
files = {"file": ("sticker.png", io.BytesIO(b"\x89PNG\r\n\x1a\n\x00" * 200), "image/png")}
|
||||
r = unauth_client.post("/api/ocr", files=files)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
# ─── Phase E: Role-Based Access Tests ──────────────────────────────────────
|
||||
|
||||
|
||||
@@ -3472,16 +3241,16 @@ class TestIconUpload:
|
||||
assert "too large" in r.json()["detail"].lower()
|
||||
|
||||
def test_upload_icon_file_saved_to_disk(self, client):
|
||||
"""Uploaded file must exist on disk under uploads/icons/."""
|
||||
"""Uploaded file must exist on disk under UPLOADS_DIR/icons/."""
|
||||
r = client.post(
|
||||
"/api/upload/icon",
|
||||
files={"file": ("disk.png", io.BytesIO(PNG_BYTES), "image/png")},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
rel_path = r.json()["path"]
|
||||
# Strip leading / to resolve from project root
|
||||
rel_path = r.json()["path"] # e.g. /uploads/icons/abc123.png
|
||||
from pathlib import Path
|
||||
abs_path = Path(__file__).parent.parent / rel_path.lstrip("/")
|
||||
from server import UPLOADS_DIR
|
||||
abs_path = UPLOADS_DIR / Path(rel_path).relative_to("/uploads")
|
||||
assert abs_path.exists(), f"Expected file at {abs_path}"
|
||||
assert abs_path.read_bytes() == PNG_BYTES
|
||||
|
||||
@@ -3549,9 +3318,10 @@ class TestPhotoUpload:
|
||||
files={"file": ("disk.jpg", io.BytesIO(jpg_bytes), "image/jpeg")},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
rel_path = r.json()["path"]
|
||||
rel_path = r.json()["path"] # e.g. /uploads/photos/abc123.jpg
|
||||
from pathlib import Path
|
||||
abs_path = Path(__file__).parent.parent / rel_path.lstrip("/")
|
||||
from server import UPLOADS_DIR
|
||||
abs_path = UPLOADS_DIR / Path(rel_path).relative_to("/uploads")
|
||||
assert abs_path.exists(), f"Expected file at {abs_path}"
|
||||
assert abs_path.read_bytes() == jpg_bytes
|
||||
|
||||
@@ -3565,10 +3335,14 @@ class TestOCR:
|
||||
def _make_ocr_image(self, text: str) -> io.BytesIO:
|
||||
"""Create a PNG image with *text* drawn on it, readable by Tesseract."""
|
||||
from PIL import Image as PILImage, ImageDraw, ImageFont
|
||||
img = PILImage.new("L", (400, 100), color=255) # white background, grayscale
|
||||
img = PILImage.new("L", (600, 120), color=255) # white background, grayscale
|
||||
draw = ImageDraw.Draw(img)
|
||||
# Use default font — works across platforms
|
||||
draw.text((10, 30), text, fill=0)
|
||||
# Use a clear bold font for reliable OCR
|
||||
try:
|
||||
font = ImageFont.truetype("DejaVuSans-Bold.ttf", size=28)
|
||||
except OSError:
|
||||
font = ImageFont.load_default()
|
||||
draw.text((10, 30), text, fill=0, font=font)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
buf.seek(0)
|
||||
|
||||
Reference in New Issue
Block a user