Files
shopdb-flask/tests/test_plugins/test_employee_photo.py
cproudlock 1d21bf0206
Some checks failed
CI / backend (push) Failing after 9s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
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>
2026-07-11 21:00:37 -04:00

213 lines
8.9 KiB
Python

"""Tests for self-hosted employee photo upload, replace, delete, serve, and the
shared photo-URL resolver used by both EmployeeDetail and the kiosk cards.
Covers: admin-gated upload/delete, the public serve route, one-photo-per-person
replace semantics, external-mode 409s, and resolve_employee_photo_url in both
directory modes. The wjf_employees HR DB is not available under test, so external
resolution is exercised by passing the Picture value directly (no query)."""
import io
import os
from shopdb.core.models import Setting
from plugins.employees.models import DirectoryEmployee
from plugins.employees.api.routes import (
resolve_employee_photo_url,
EMPLOYEE_PHOTO_URL_PREFIX,
EMPLOYEE_PHOTO_STATIC_PREFIX,
)
def _set_mode(db, mode):
row = Setting.query.filter_by(key='employee_directory_mode').first()
if row:
row.value = mode
else:
db.session.add(Setting(key='employee_directory_mode', value=mode,
valuetype='string', category='site'))
db.session.commit()
def _make_employee(db, sso=210000001):
emp = DirectoryEmployee(sso=sso, firstname='Test', lastname='Person')
db.session.add(emp)
db.session.commit()
return emp
# --- upload / replace / delete (self-hosted) --------------------------------
def test_upload_forbidden_for_non_admin(client, db, member_headers):
"""A role-less authenticated user cannot upload an employee photo."""
_set_mode(db, 'selfhosted')
emp = _make_employee(db)
data = {'file': (io.BytesIO(b'bytes'), 'p.png')}
resp = client.post(f'/api/employees/{emp.sso}/photo', 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_photofilename_and_file_exists(client, db, auth_headers, app):
"""Admin upload writes photofilename + photourl and lands the file on disk."""
_set_mode(db, 'selfhosted')
emp = _make_employee(db)
payload = b'\x89PNG\r\n\x1a\nfake-png'
data = {'file': (io.BytesIO(payload), 'p.png')}
resp = client.post(f'/api/employees/{emp.sso}/photo', data=data,
content_type='multipart/form-data', headers=auth_headers)
assert resp.status_code == 200, resp.get_json()
filename = f'photo-{emp.sso}.png'
body = resp.get_json()['data']
assert body['photofilename'] == filename
assert body['photourl'] == f'{EMPLOYEE_PHOTO_URL_PREFIX}{filename}'
refreshed = db.session.get(DirectoryEmployee, emp.sso)
assert refreshed.photofilename == filename
path = os.path.join(app.instance_path, 'employeephotos', filename)
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."""
_set_mode(db, 'selfhosted')
emp = _make_employee(db)
data = {'file': (io.BytesIO(b'MZ'), 'p.exe')}
resp = client.post(f'/api/employees/{emp.sso}/photo', 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_employee_is_404(client, db, auth_headers):
"""Uploading to a nonexistent SSO is a 404."""
_set_mode(db, 'selfhosted')
data = {'file': (io.BytesIO(b'x'), 'p.png')}
resp = client.post('/api/employees/999999/photo', 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."""
_set_mode(db, 'selfhosted')
emp = _make_employee(db)
first = {'file': (io.BytesIO(b'first'), 'p.png')}
client.post(f'/api/employees/{emp.sso}/photo', data=first,
content_type='multipart/form-data', headers=auth_headers)
oldpath = os.path.join(app.instance_path, 'employeephotos', f'photo-{emp.sso}.png')
assert os.path.exists(oldpath)
second = {'file': (io.BytesIO(b'second'), 'p.jpg')}
resp = client.post(f'/api/employees/{emp.sso}/photo', data=second,
content_type='multipart/form-data', headers=auth_headers)
assert resp.status_code == 200
assert not os.path.exists(oldpath)
newpath = os.path.join(app.instance_path, 'employeephotos', f'photo-{emp.sso}.jpg')
assert os.path.exists(newpath)
refreshed = db.session.get(DirectoryEmployee, emp.sso)
assert refreshed.photofilename == f'photo-{emp.sso}.jpg'
def test_serve_returns_bytes(client, db, auth_headers):
"""The public serve route returns the uploaded bytes without auth."""
_set_mode(db, 'selfhosted')
emp = _make_employee(db)
payload = b'\x89PNG\r\n\x1a\nserved'
data = {'file': (io.BytesIO(payload), 'p.png')}
client.post(f'/api/employees/{emp.sso}/photo', data=data,
content_type='multipart/form-data', headers=auth_headers)
served = client.get(f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.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 photofilename and removes the uploaded file."""
_set_mode(db, 'selfhosted')
emp = _make_employee(db)
data = {'file': (io.BytesIO(b'bytes'), 'p.png')}
client.post(f'/api/employees/{emp.sso}/photo', data=data,
content_type='multipart/form-data', headers=auth_headers)
path = os.path.join(app.instance_path, 'employeephotos', f'photo-{emp.sso}.png')
assert os.path.exists(path)
resp = client.delete(f'/api/employees/{emp.sso}/photo', headers=auth_headers)
assert resp.status_code == 200
assert resp.get_json()['data']['photourl'] is None
assert not os.path.exists(path)
refreshed = db.session.get(DirectoryEmployee, emp.sso)
assert refreshed.photofilename is None
def test_delete_forbidden_for_non_admin(client, db, member_headers):
"""A role-less authenticated user cannot delete an employee photo."""
_set_mode(db, 'selfhosted')
emp = _make_employee(db)
resp = client.delete(f'/api/employees/{emp.sso}/photo', headers=member_headers)
assert resp.status_code == 403
# --- external-mode 409 ------------------------------------------------------
def test_upload_conflict_in_external_mode(client, db, auth_headers):
"""Upload is a 409 when the directory is external (HR owns the photo)."""
_set_mode(db, 'external')
data = {'file': (io.BytesIO(b'x'), 'p.png')}
resp = client.post('/api/employees/210000001/photo', data=data,
content_type='multipart/form-data', headers=auth_headers)
assert resp.status_code == 409
assert resp.get_json()['data']['error']['code'] == 'CONFLICT'
def test_delete_conflict_in_external_mode(client, db, auth_headers):
"""Delete is a 409 when the directory is external."""
_set_mode(db, 'external')
resp = client.delete('/api/employees/210000001/photo', headers=auth_headers)
assert resp.status_code == 409
# --- resolver ---------------------------------------------------------------
def test_resolver_selfhosted_returns_upload_url_or_none(client, db, auth_headers):
"""Self-hosted: served upload URL when a photo exists, else None."""
_set_mode(db, 'selfhosted')
emp = _make_employee(db)
assert resolve_employee_photo_url(emp.sso) is None
data = {'file': (io.BytesIO(b'x'), 'p.png')}
client.post(f'/api/employees/{emp.sso}/photo', data=data,
content_type='multipart/form-data', headers=auth_headers)
assert resolve_employee_photo_url(emp.sso) == f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png'
def test_resolver_external_prefixes_relative_and_passes_urls(db):
"""External: relative HR paths get the static prefix; absolute URLs pass through."""
_set_mode(db, 'external')
assert resolve_employee_photo_url(210000001, 'Support/210000001.png') == \
f'{EMPLOYEE_PHOTO_STATIC_PREFIX}Support/210000001.png'
assert resolve_employee_photo_url(210000001, 'https://hr.example.net/p.png') == \
'https://hr.example.net/p.png'
assert resolve_employee_photo_url(210000001, None) is None
assert resolve_employee_photo_url(None) is None
def test_lookup_includes_photourl(client, db, auth_headers):
"""The lookup serializer carries the resolved photourl (self-hosted)."""
_set_mode(db, 'selfhosted')
emp = _make_employee(db)
data = {'file': (io.BytesIO(b'x'), 'p.png')}
client.post(f'/api/employees/{emp.sso}/photo', data=data,
content_type='multipart/form-data', headers=auth_headers)
resp = client.get(f'/api/employees/lookup/{emp.sso}')
assert resp.status_code == 200
assert resp.get_json()['data']['photourl'] == f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png'