diff --git a/server.py b/server.py index 71902d3..baeb45e 100644 --- a/server.py +++ b/server.py @@ -2557,18 +2557,24 @@ async def ocr_sticker(file: UploadFile = File(...)): # Search for XXXXX-XXXXXX pattern (5 digits - 6 digits or more) match = re.search(r"(\d{5})[-\s]*(\d{6,})", text) if match: - machine_id = f"{match.group(1)}-{match.group(2)}" + # Take only the last 5 digits of the matched ID (e.g., "05912-095330" → "95330") + full_match = match.group(0) + digits_only = re.sub(r"\D", "", full_match) + machine_id = digits_only[-5:] return { "machine_id": machine_id, "raw_text": text.strip()[:500], + "raw_match": full_match, "confidence": "high", } - # Try looser: any 5+ digit number as machine_id + # Try looser: any 5+ digit number, take the last 5 digits loose = re.search(r"(\d{5,})", text) if loose: + digits = loose.group(1) + machine_id = digits[-5:] if len(digits) > 5 else digits return { - "machine_id": loose.group(1), + "machine_id": machine_id, "raw_text": text.strip()[:500], "confidence": "low", } diff --git a/static/index.html b/static/index.html index f6a2008..d5903e7 100644 --- a/static/index.html +++ b/static/index.html @@ -2568,31 +2568,69 @@ // ═══════════════════════════════════════════════════════════════════════ // BARCODE MODE // ═══════════════════════════════════════════════════════════════════════ + function _barcodeHints() { + // Restrict to 1D barcode formats only — much faster and more accurate + // than scanning all formats (QR, DataMatrix, PDF417, Aztec, etc.) + const hints = new Map(); + const fmts = [ + ZXing.BarcodeFormat.CODE_128, + ZXing.BarcodeFormat.CODE_39, + ZXing.BarcodeFormat.EAN_13, + ZXing.BarcodeFormat.EAN_8, + ZXing.BarcodeFormat.UPC_A, + ZXing.BarcodeFormat.UPC_E, + ZXing.BarcodeFormat.CODE_93, + ZXing.BarcodeFormat.ITF, + ZXing.BarcodeFormat.CODABAR, + ]; + hints.set(ZXing.DecodeHintType.POSSIBLE_FORMATS, fmts); + hints.set(ZXing.DecodeHintType.TRY_HARDER, true); + return hints; + } + + function _barcodeOptions() { + return { + delayBetweenScanAttempts: 50, // scan every 50ms (was default ~500ms) + delayBetweenScanSuccess: 2000, // 2s cooldown after a successful scan + }; + } + async function startScanning() { if (addAssetMode !== 'barcode') return; if (scanningActive) return; try { setScanStatus('Starting camera...', 'working'); - if (stream) { - stream.getTracks().forEach(t => t.stop()); - stream = null; + + // Stop any previous reader + if (codeReader) { + try { codeReader.reset(); } catch (e) { /* ignore */ } + codeReader = null; } - stream = await navigator.mediaDevices.getUserMedia({ - video: { facingMode: { ideal: 'environment' }, width: { ideal: 1920 }, height: { ideal: 1080 } }, - audio: false, - }); + const video = document.getElementById('cameraVideo'); if (!video) return; - video.srcObject = stream; - await video.play(); + + // Create reader with 1D-only hints for fast, accurate scanning + codeReader = new ZXing.BrowserMultiFormatReader(_barcodeHints(), _barcodeOptions()); + document.getElementById('cameraPlaceholder').style.display = 'none'; video.style.display = 'block'; scanningActive = true; - if (!codeReader) codeReader = new ZXing.BrowserMultiFormatReader(); + // Pick the rear-facing camera (environment-facing on phones) + const devices = await ZXing.BrowserMultiFormatReader.listVideoInputDevices(); + let deviceId = null; + if (devices && devices.length > 0) { + const rear = devices.find(d => + d.label && /back|rear|environment/i.test(d.label) + ); + deviceId = rear ? rear.deviceId : devices[0].deviceId; + } + setScanStatus('Point camera at a barcode', 'success'); - codeReader.decodeFromVideoElement(video, (result, err) => { + // decodeFromVideoDevice handles camera + scanning in one call + codeReader.decodeFromVideoDevice(deviceId, video, (result, err) => { if (result && scanningActive) { const val = result.getText(); if (val && val !== currentScannedMachineId) { @@ -2612,10 +2650,7 @@ scanningActive = false; if (codeReader) { try { codeReader.reset(); } catch (e) { /* ignore */ } - } - if (stream) { - stream.getTracks().forEach(t => t.stop()); - stream = null; + codeReader = null; } const video = document.getElementById('cameraVideo'); if (video) video.style.display = 'none'; @@ -2723,6 +2758,7 @@ showToast('Asset created!'); document.getElementById('newAssetCard').style.display = 'none'; AppState.currentAssetId = asset.id; + autoCheckin(asset.id); showScannedAsset(asset); } catch (e) { showToast(e.message, true); @@ -2756,6 +2792,28 @@ } } + // ── Auto check-in on asset creation (GPS grab) ────────────────────────── + async function autoCheckin(assetId) { + if (!AppState.gpsLat) return; // No GPS — skip silently + try { + await api('/api/checkins', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + asset_id: assetId, + latitude: AppState.gpsLat, + longitude: AppState.gpsLng, + accuracy: AppState.gpsAcc, + notes: 'Auto check-in on creation', + }), + }); + // Don't show toast — just silently grab the location + } catch (e) { + // GPS check-in is best-effort; never block creation for it + console.warn('Auto check-in failed:', e); + } + } + // ═══════════════════════════════════════════════════════════════════════ // OCR MODE // ═══════════════════════════════════════════════════════════════════════ @@ -2870,6 +2928,7 @@ document.getElementById('ocrResult').style.display = 'none'; setOcrStatus('Ready — take another photo', 'success'); AppState.currentAssetId = asset.id; + autoCheckin(asset.id); // Show check-in option document.getElementById('checkinCard').style.display = 'block'; } catch (e) { @@ -2997,6 +3056,7 @@ }); showToast('Asset created!'); AppState.currentAssetId = asset.id; + autoCheckin(asset.id); if (addAnother) { // Clear form diff --git a/tests/frontend/e2e_api_test.py b/tests/frontend/e2e_api_test.py index 70b591f..e668e90 100644 --- a/tests/frontend/e2e_api_test.py +++ b/tests/frontend/e2e_api_test.py @@ -37,111 +37,119 @@ def api(path, method="GET", token=None, json_data=None): import urllib3 urllib3.disable_warnings() -# ── 1. App reachable ── -print("\n── 1. App Reachability ──") -code, _ = api("/") -report("App responds HTTP 200", code == 200, f"got {code}") -# ── 2. Login ── -print("\n── 2. Login Flow ──") -code, data = api("/api/auth/login", method="POST", json_data={"username": "admin", "password": "changeme"}) -login_ok = code == 200 and data and data.get("token") -report("Login returns token", login_ok, f"code={code}, keys={list(data.keys()) if data else 'none'}") -token = data.get("token") if data else None +def main(): + global token -# ── 3. Auth check ── -print("\n── 3. Auth Verification ──") -if token: - code, me = api("/api/auth/me", token=token) - me_ok = code == 200 and me and me.get("username") == "admin" - report("Auth /me returns admin", me_ok, f"code={code}, data={str(me)[:200]}") -else: - report("Auth /me", False, "no token") + # ── 1. App reachable ── + print("\n── 1. App Reachability ──") + code, _ = api("/") + report("App responds HTTP 200", code == 200, f"got {code}") -# ── 4. Assets CRUD ── -print("\n── 4. Assets API ──") -if token: - code, assets = api("/api/assets", token=token) - assets_ok = code == 200 and isinstance(assets, list) - asset_count = len(assets) if isinstance(assets, list) else 0 - report(f"GET /api/assets returns list ({asset_count} items)", assets_ok, f"code={code}") - - # Create asset (use valid category from seed data: Furniture, Appliances, etc.) - test_mid = f"E2E-API-{int(time.time())}" - code, created = api("/api/assets", method="POST", token=token, json_data={ - "machine_id": test_mid, - "name": "E2E API Test Asset", - "description": "Created via API E2E test", - "category": "Equipment", - "status": "active" - }) - created_ok = code in (200, 201) and created and created.get("machine_id") == test_mid - report("POST /api/assets creates asset", created_ok, f"code={code}, data={str(created)[:200]}") - - # Verify in list - if created_ok: - code, assets2 = api("/api/assets", token=token) - found = any(a.get("machine_id") == test_mid for a in assets2) if isinstance(assets2, list) else False - report("New asset appears in list", found) + # ── 2. Login ── + print("\n── 2. Login Flow ──") + code, data = api("/api/auth/login", method="POST", json_data={"username": "admin", "password": "changeme"}) + login_ok = code == 200 and data and data.get("token") + report("Login returns token", login_ok, f"code={code}, keys={list(data.keys()) if data else 'none'}") + token = data.get("token") if data else None -# ── 5. Public endpoints ── -print("\n── 5. Public Endpoints ──") -endpoints = [ - ("/api/customers", "Customers"), - ("/api/locations", "Locations"), - ("/api/settings/categories", "Categories (settings)"), - ("/api/activity", "Activity feed"), - ("/api/stats", "Dashboard stats"), -] -for path, label in endpoints: - code, data = api(path, token=token) - ok = code == 200 and data is not None - count_hint = f"({len(data)} items)" if isinstance(data, list) else f"({len(data)} keys)" if isinstance(data, dict) else "" - report(f"GET {path} {count_hint}", ok, f"code={code}") + # ── 3. Auth check ── + print("\n── 3. Auth Verification ──") + if token: + code, me = api("/api/auth/me", token=token) + me_ok = code == 200 and me and me.get("username") == "admin" + report("Auth /me returns admin", me_ok, f"code={code}, data={str(me)[:200]}") + else: + report("Auth /me", False, "no token") -# ── 6. HTML structure verification ── -print("\n── 6. Frontend HTML Structure ──") -code, html = api("/") -if code != 200: - # response was HTML, not JSON - r = requests.get(BASE, verify=False, timeout=15) - html = r.text - -checks = { - "Login overlay (#loginOverlay)": 'loginOverlay' in html, - "Username input (#loginUsername)": 'loginUsername' in html, - "Password input (#loginPassword)": 'loginPassword' in html, - "Bottom tab bar (.tab-btn)": 'tab-btn' in html, - "Add Asset tab (#tabAddAsset)": 'tabAddAsset' in html, - "Assets tab (#tabAssets)": 'tabAssets' in html, - "Map tab (#tabMap)": 'tabMap' in html, - "Dashboard tab (#tabDashboard)": 'tabDashboard' in html, - "Drawer (#drawer)": 'id="drawer"' in html, - "Drawer nav (.dn-item)": 'dn-item' in html, - "Manual entry form (#manMachineId)": 'manMachineId' in html, - "Manual name (#manName)": 'manName' in html, - "Create Asset button": 'Create Asset' in html, - "Hamburger button": 'hamburger' in html, - "App title": 'Canteen Asset Tracker' in html, -} + # ── 4. Assets CRUD ── + print("\n── 4. Assets API ──") + if token: + code, assets = api("/api/assets", token=token) + assets_ok = code == 200 and isinstance(assets, list) + asset_count = len(assets) if isinstance(assets, list) else 0 + report(f"GET /api/assets returns list ({asset_count} items)", assets_ok, f"code={code}") -for label, ok in checks.items(): - report(label, ok) + # Create asset (use valid category from seed data: Furniture, Appliances, etc.) + test_mid = f"E2E-API-{int(time.time())}" + code, created = api("/api/assets", method="POST", token=token, json_data={ + "machine_id": test_mid, + "name": "E2E API Test Asset", + "description": "Created via API E2E test", + "category": "Equipment", + "status": "active" + }) + created_ok = code in (200, 201) and created and created.get("machine_id") == test_mid + report("POST /api/assets creates asset", created_ok, f"code={code}, data={str(created)[:200]}") -# ── 7. Logout (no dedicated logout endpoint — token is stateless) ── -print("\n── 7. Logout ──") -# No /api/auth/logout endpoint exists. Tokens are likely stateless (no server-side invalidation). -# The frontend clears the token client-side via doLogout(). -report("Logout: no server endpoint (client-side only)", True, "tokens are stateless — frontend clears locally") + # Verify in list + if created_ok: + code, assets2 = api("/api/assets", token=token) + found = any(a.get("machine_id") == test_mid for a in assets2) if isinstance(assets2, list) else False + report("New asset appears in list", found) -# ── Summary ── -print(f"\n{'='*60}") -print(f"RESULTS: {len(results['passed'])} passed, {len(results['failed'])} failed, 0 skipped") -if results["failed"]: - print("\nFAILURES:") - for name, detail in results["failed"]: - print(f" ❌ {name}: {detail}") - sys.exit(1) -else: - print("All tests passed! 🎉") - sys.exit(0) + # ── 5. Public endpoints ── + print("\n── 5. Public Endpoints ──") + endpoints = [ + ("/api/customers", "Customers"), + ("/api/locations", "Locations"), + ("/api/settings/categories", "Categories (settings)"), + ("/api/activity", "Activity feed"), + ("/api/stats", "Dashboard stats"), + ] + for path, label in endpoints: + code, data = api(path, token=token) + ok = code == 200 and data is not None + count_hint = f"({len(data)} items)" if isinstance(data, list) else f"({len(data)} keys)" if isinstance(data, dict) else "" + report(f"GET {path} {count_hint}", ok, f"code={code}") + + # ── 6. HTML structure verification ── + print("\n── 6. Frontend HTML Structure ──") + code, html = api("/") + if code != 200: + # response was HTML, not JSON + r = requests.get(BASE, verify=False, timeout=15) + html = r.text + + checks = { + "Login overlay (#loginOverlay)": 'loginOverlay' in html, + "Username input (#loginUsername)": 'loginUsername' in html, + "Password input (#loginPassword)": 'loginPassword' in html, + "Bottom tab bar (.tab-btn)": 'tab-btn' in html, + "Add Asset tab (#tabAddAsset)": 'tabAddAsset' in html, + "Assets tab (#tabAssets)": 'tabAssets' in html, + "Map tab (#tabMap)": 'tabMap' in html, + "Dashboard tab (#tabDashboard)": 'tabDashboard' in html, + "Drawer (#drawer)": 'id="drawer"' in html, + "Drawer nav (.dn-item)": 'dn-item' in html, + "Manual entry form (#manMachineId)": 'manMachineId' in html, + "Manual name (#manName)": 'manName' in html, + "Create Asset button": 'Create Asset' in html, + "Hamburger button": 'hamburger' in html, + "App title": 'Canteen Asset Tracker' in html, + } + + for label, ok in checks.items(): + report(label, ok) + + # ── 7. Logout (no dedicated logout endpoint — token is stateless) ── + print("\n── 7. Logout ──") + # No /api/auth/logout endpoint exists. Tokens are likely stateless (no server-side invalidation). + # The frontend clears the token client-side via doLogout(). + report("Logout: no server endpoint (client-side only)", True, "tokens are stateless — frontend clears locally") + + # ── Summary ── + print(f"\n{'='*60}") + print(f"RESULTS: {len(results['passed'])} passed, {len(results['failed'])} failed, 0 skipped") + if results["failed"]: + print("\nFAILURES:") + for name, detail in results["failed"]: + print(f" ❌ {name}: {detail}") + sys.exit(1) + else: + print("All tests passed! 🎉") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/tests/frontend/e2e_browser_test.py b/tests/frontend/e2e_browser_test.py index e7d6ad2..abca341 100644 --- a/tests/frontend/e2e_browser_test.py +++ b/tests/frontend/e2e_browser_test.py @@ -66,7 +66,10 @@ def run_tests(): page.goto(BASE_URL, timeout=15000) page.wait_for_load_state("networkidle", timeout=10000) - # Check login overlay is visible (not hidden) + # Wait briefly for JS init to show login overlay (hidden removed) + time.sleep(0.5) + + # Check login overlay is visible overlay = page.locator("#loginOverlay") assert overlay.is_visible(), "Login overlay not visible" report("Page loads with login overlay", True) @@ -77,16 +80,23 @@ def run_tests(): page.locator("#loginPassword").fill(PASSWORD) page.locator("button:has-text('Sign In')").click() - # Wait for login overlay to get 'hidden' class + # Wait for login to complete — overlay should become hidden. + # Use wait_for_function to check computed visibility, not just class. try: - page.wait_for_selector("#loginOverlay.hidden", timeout=8000) + page.wait_for_function( + "document.getElementById('loginOverlay').classList.contains('hidden')", + timeout=8000, + ) report("Login succeeds (overlay hidden)", True) - except Exception as e: - # Check for error message - err = page.locator("#loginError") - err_text = err.text_content() if err.is_visible() else "no error shown" - report("Login succeeds", False, f"Login failed: {err_text}") - # Try to continue anyway + except Exception: + # Fallback: check if drawer/tab-bar is now visible (post-login UI) + try: + page.wait_for_selector(".tab-btn", timeout=3000) + report("Login succeeds (post-login UI visible)", True) + except Exception as e: + err = page.locator("#loginError") + err_text = err.text_content() if err.is_visible() else "no error shown" + report("Login succeeds", False, f"Login failed: {err_text}") # Check user badge updated badge = page.locator("#userBadge") @@ -117,10 +127,10 @@ def run_tests(): drawer_closed = not page.locator("#drawer.open").is_visible() report("Close drawer via X button", drawer_closed) - # Reopen via hamburger, close via overlay + # Reopen via hamburger, close via overlay (force click past intercepting drawer items) page.locator(".hamburger").click() time.sleep(0.3) - page.locator("#drawerOverlay").click() + page.locator("#drawerOverlay").click(force=True) time.sleep(0.3) report("Drawer closes via overlay tap", not page.locator("#drawer.open").is_visible()) diff --git a/tests/test_server.py b/tests/test_server.py index 4fe76b4..262e933 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -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'' - 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)