Add photo management for models and employees; fix stale detail navigation
Model photos: upload/replace/delete on /api/models/<id>/image (admin), stored under instance/modelimages/ with a public serve route; thumbnail plus Upload/Replace/Remove controls in the Models settings modal; the URL field remains as a manual alternative. Employee photos, mode-aware: self-hosted directory employees get upload/replace/delete (photo-<sso> under instance/employeephotos/, employees plugin migration 0002); external directory mode passes the HR-supplied picture URL through read-only (writes 409). One resolver feeds both consumers - the shopfloor recognition/recert kiosk cards and the employee detail hero - in either mode. Navigation fix: router-view is keyed on route path, so following a relationship link between two assets of the same type (machine -> dualpath machine) reloads the page instead of showing stale content; query-only URL changes still avoid a remount. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
149
tests/test_core/test_model_image.py
Normal file
149
tests/test_core/test_model_image.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""Tests for vendor-model photo upload, replace, delete, and serve.
|
||||
|
||||
Covers the core model-image endpoints: admin-gated upload/delete, the public
|
||||
serve route, one-image-per-model replace semantics, and the guarantee that
|
||||
deleting a model whose imageurl is an external URL clears the field without
|
||||
touching the filesystem.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
|
||||
from shopdb.core.models import Model
|
||||
from shopdb.core.api.models import MODEL_IMAGE_URL_PREFIX
|
||||
|
||||
|
||||
def _make_model(db, imageurl=None):
|
||||
m = Model(modelnumber='TESTMODEL-1', imageurl=imageurl)
|
||||
db.session.add(m)
|
||||
db.session.commit()
|
||||
return m
|
||||
|
||||
|
||||
def test_upload_forbidden_for_non_admin(client, db, member_headers):
|
||||
"""A role-less authenticated user cannot upload a model image."""
|
||||
m = _make_model(db)
|
||||
data = {'file': (io.BytesIO(b'<svg/>'), 'photo.svg')}
|
||||
resp = client.post(f'/api/models/{m.modelnumberid}/image', data=data,
|
||||
content_type='multipart/form-data', headers=member_headers)
|
||||
assert resp.status_code == 403
|
||||
assert resp.get_json()['data']['error']['code'] == 'FORBIDDEN'
|
||||
|
||||
|
||||
def test_upload_sets_imageurl_and_file_exists(client, db, auth_headers, app):
|
||||
"""Admin upload writes the served URL and the file lands in the instance dir."""
|
||||
m = _make_model(db)
|
||||
payload = b'\x89PNG\r\n\x1a\nfake-png-bytes'
|
||||
data = {'file': (io.BytesIO(payload), 'photo.png')}
|
||||
resp = client.post(f'/api/models/{m.modelnumberid}/image', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
|
||||
expected_url = f'{MODEL_IMAGE_URL_PREFIX}model-{m.modelnumberid}.png'
|
||||
assert resp.get_json()['data']['imageurl'] == expected_url
|
||||
|
||||
refreshed = db.session.get(Model, m.modelnumberid)
|
||||
assert refreshed.imageurl == expected_url
|
||||
|
||||
path = os.path.join(app.instance_path, 'modelimages', f'model-{m.modelnumberid}.png')
|
||||
assert os.path.exists(path)
|
||||
with open(path, 'rb') as handle:
|
||||
assert handle.read() == payload
|
||||
|
||||
|
||||
def test_upload_rejects_invalid_extension(client, db, auth_headers):
|
||||
"""A disallowed file extension is rejected."""
|
||||
m = _make_model(db)
|
||||
data = {'file': (io.BytesIO(b'MZ...'), 'photo.exe')}
|
||||
resp = client.post(f'/api/models/{m.modelnumberid}/image', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 400
|
||||
assert resp.get_json()['data']['error']['code'] == 'VALIDATION_ERROR'
|
||||
|
||||
|
||||
def test_upload_missing_model_is_404(client, db, auth_headers):
|
||||
"""Uploading to a nonexistent model id is a 404."""
|
||||
data = {'file': (io.BytesIO(b'<svg/>'), 'photo.svg')}
|
||||
resp = client.post('/api/models/999999/image', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_reupload_replaces_and_removes_old_extension(client, db, auth_headers, app):
|
||||
"""Re-upload with a different extension deletes the prior file."""
|
||||
m = _make_model(db)
|
||||
first = {'file': (io.BytesIO(b'first'), 'photo.png')}
|
||||
resp = client.post(f'/api/models/{m.modelnumberid}/image', data=first,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
oldpath = os.path.join(app.instance_path, 'modelimages', f'model-{m.modelnumberid}.png')
|
||||
assert os.path.exists(oldpath)
|
||||
|
||||
second = {'file': (io.BytesIO(b'second'), 'photo.jpg')}
|
||||
resp = client.post(f'/api/models/{m.modelnumberid}/image', data=second,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Old .png is gone; new .jpg exists and imageurl points at it.
|
||||
assert not os.path.exists(oldpath)
|
||||
newpath = os.path.join(app.instance_path, 'modelimages', f'model-{m.modelnumberid}.jpg')
|
||||
assert os.path.exists(newpath)
|
||||
refreshed = db.session.get(Model, m.modelnumberid)
|
||||
assert refreshed.imageurl == f'{MODEL_IMAGE_URL_PREFIX}model-{m.modelnumberid}.jpg'
|
||||
|
||||
|
||||
def test_serve_returns_bytes(client, db, auth_headers):
|
||||
"""The public serve route returns the uploaded bytes without auth."""
|
||||
m = _make_model(db)
|
||||
payload = b'\x89PNG\r\n\x1a\nserved-bytes'
|
||||
data = {'file': (io.BytesIO(payload), 'photo.png')}
|
||||
client.post(f'/api/models/{m.modelnumberid}/image', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
|
||||
served = client.get(f'{MODEL_IMAGE_URL_PREFIX}model-{m.modelnumberid}.png')
|
||||
assert served.status_code == 200
|
||||
assert served.get_data() == payload
|
||||
|
||||
|
||||
def test_delete_clears_field_and_removes_file(client, db, auth_headers, app):
|
||||
"""Delete clears imageurl and removes the owned file."""
|
||||
m = _make_model(db)
|
||||
data = {'file': (io.BytesIO(b'bytes'), 'photo.png')}
|
||||
client.post(f'/api/models/{m.modelnumberid}/image', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
path = os.path.join(app.instance_path, 'modelimages', f'model-{m.modelnumberid}.png')
|
||||
assert os.path.exists(path)
|
||||
|
||||
resp = client.delete(f'/api/models/{m.modelnumberid}/image', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['imageurl'] is None
|
||||
assert not os.path.exists(path)
|
||||
|
||||
refreshed = db.session.get(Model, m.modelnumberid)
|
||||
assert refreshed.imageurl is None
|
||||
|
||||
|
||||
def test_delete_external_url_clears_field_without_filesystem_error(client, db, auth_headers):
|
||||
"""Delete on a model whose imageurl is an external URL just clears the field."""
|
||||
m = _make_model(db, imageurl='https://example.com/product.png')
|
||||
resp = client.delete(f'/api/models/{m.modelnumberid}/image', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['imageurl'] is None
|
||||
|
||||
refreshed = db.session.get(Model, m.modelnumberid)
|
||||
assert refreshed.imageurl is None
|
||||
|
||||
|
||||
def test_delete_legacy_path_clears_field_without_filesystem_error(client, db, auth_headers):
|
||||
"""Delete on a shipped /images/models/* path clears the field, touches no disk."""
|
||||
m = _make_model(db, imageurl='/images/models/machines/legacy.png')
|
||||
resp = client.delete(f'/api/models/{m.modelnumberid}/image', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['imageurl'] is None
|
||||
|
||||
|
||||
def test_delete_forbidden_for_non_admin(client, db, member_headers):
|
||||
"""A role-less authenticated user cannot delete a model image."""
|
||||
m = _make_model(db, imageurl='https://example.com/product.png')
|
||||
resp = client.delete(f'/api/models/{m.modelnumberid}/image', headers=member_headers)
|
||||
assert resp.status_code == 403
|
||||
Reference in New Issue
Block a user