backend.tests.api.test_verification_controller

  1import pytest
  2import os
  3import io
  4import numpy as np
  5import cv2
  6import face_recognition
  7from unittest.mock import patch, MagicMock, mock_open
  8from datetime import datetime, timedelta
  9
 10from backend.app import create_app, db
 11from backend.components.workers import workerService
 12
 13
 14@pytest.fixture
 15def app_context():
 16    """Create app context with in-memory SQLite database for testing."""
 17    app = create_app()
 18    app.config.update({
 19        "TESTING": True,
 20        "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:"
 21    })
 22
 23    with app.app_context():
 24        db.create_all()
 25        yield app
 26
 27
 28@pytest.fixture
 29def test_worker(app_context):
 30    """Create a test worker with face embedding from testimg.jpg."""
 31    current_dir = os.path.dirname(os.path.abspath(__file__))
 32    image_path = os.path.normpath(os.path.join(current_dir, "../assets/testimg.jpg"))
 33
 34
 35
 36    if not os.path.exists(image_path):
 37        pytest.skip(f"Test image not found at {image_path}")
 38
 39    with app_context.app_context():
 40        # Load and create embedding from test image
 41        test_image = face_recognition.load_image_file(image_path)
 42
 43        # Use workerService.create_worker to create worker properly
 44        worker = workerService.create_worker(
 45            name="Jacob Czajka",
 46            face_image=test_image,
 47            expiration_date=datetime(2029, 1, 1)
 48        )
 49
 50        yield worker
 51
 52
 53@pytest.fixture
 54def client(app_context):
 55    """Create Flask test client."""
 56    test_client = app_context.test_client()
 57    yield test_client
 58    test_client.delete()
 59
 60
 61@pytest.fixture
 62def test_image_with_qrcode(app_context, client, test_worker):
 63    """
 64    Create a composite test image by joining testimg.jpg with the worker's QR code.
 65    Places QR code beside the face image without distortion.
 66    Returns the composite image as bytes.
 67    """
 68    current_dir = os.path.dirname(os.path.abspath(__file__))
 69    testimg_path = os.path.normpath(os.path.join(current_dir, "../assets/testimg.jpg"))
 70
 71    if not os.path.exists(testimg_path):
 72        pytest.skip(f"Test image not found at {testimg_path}")
 73
 74    with app_context.app_context():
 75        # Get the QR code from the API endpoint
 76        response = client.get(f'/api/workers/entrypass/{test_worker.id}')
 77
 78        if response.status_code != 200:
 79            pytest.skip("Failed to generate QR code from API")
 80
 81        # Read QR code from response
 82        qr_code_bytes = response.data
 83        qr_code_array = np.frombuffer(qr_code_bytes, np.uint8)
 84        qr_code_image = cv2.imdecode(qr_code_array, cv2.IMREAD_COLOR)
 85
 86        if qr_code_image is None:
 87            pytest.skip("Failed to decode QR code image")
 88
 89        # Load the face image
 90        face_image = cv2.imread(testimg_path)
 91
 92        if face_image is None:
 93            pytest.skip("Failed to load test image")
 94
 95        # Make QR code 70% of face image height (much larger)
 96        qr_height = int(face_image.shape[0] * 0.7)
 97        qr_width = int(qr_height)  # QR codes are square - maintain aspect ratio
 98
 99        qr_resized = cv2.resize(qr_code_image, (qr_width, qr_height), interpolation=cv2.INTER_AREA)
100
101        # Create composite image by placing QR code beside face image (not on top)
102        # Total width = face_width + qr_width + small padding
103        padding = 20
104        total_width = face_image.shape[1] + qr_width + padding * 2
105
106        # Use the taller of the two images as height
107        total_height = max(face_image.shape[0], qr_height) + padding * 2
108
109        composite_image = np.ones((total_height, total_width, 3), dtype=np.uint8) * 255
110
111        # Place face image on the left
112        face_y_start = (total_height - face_image.shape[0]) // 2
113        face_x_start = padding
114        composite_image[
115        face_y_start:face_y_start + face_image.shape[0],
116        face_x_start:face_x_start + face_image.shape[1]
117        ] = face_image
118
119        # Place QR code on the right
120        qr_y_start = (total_height - qr_height) // 2
121        qr_x_start = face_x_start + face_image.shape[1] + padding
122        composite_image[
123        qr_y_start:qr_y_start + qr_height,
124        qr_x_start:qr_x_start + qr_width
125        ] = qr_resized
126
127        # Encode composite image to bytes
128        success, buffer = cv2.imencode('.jpg', composite_image)
129        if not success:
130            pytest.skip("Failed to encode composite image")
131
132        return buffer.tobytes()
133
134
135# ============================================================================
136# Test POST /api/skan - Success Cases
137# ============================================================================
138
139def test_post_camera_scan_success(client, app_context, test_worker, test_image_with_qrcode):
140    """
141    Test successful verification with valid QR code and matching face.
142    QR code detection is iffy so it's attempted 10 times before we assume the endpoint is broken.
143    """
144    with app_context.app_context():
145        # Send request with composite image containing QR code and face
146        i = 0
147        response = None
148        while i != 10:
149            response = client.post(
150                '/api/skan',
151                data={'file': (io.BytesIO(test_image_with_qrcode), 'composite.jpg')},
152                content_type='multipart/form-data'
153            )
154            i += 1
155
156            if response.status_code == 200:
157                break
158
159        assert response.status_code == 200
160
161
162# ============================================================================
163# Test POST /api/skan - Error Cases
164# ============================================================================
165
166def test_post_camera_scan_missing_file(client):
167    """Test endpoint returns 400 when file is missing."""
168    response = client.post(
169        '/api/skan',
170        data={},
171        content_type='multipart/form-data'
172    )
173
174    assert response.status_code == 400
175    assert 'error' in response.json
176    assert 'Brak pliku w żądaniu' in response.json['error']
177
178
179def test_post_camera_scan_invalid_image(client):
180    """Test endpoint returns 500 when image file is corrupted."""
181    invalid_image_bytes = b"This is not a valid image"
182
183    response = client.post(
184        '/api/skan',
185        data={'file': (io.BytesIO(invalid_image_bytes), 'invalid.jpg')},
186        content_type='multipart/form-data'
187    )
188
189    assert response.status_code == 500
190
191
192def test_post_camera_scan_empty_file(client):
193    """Test endpoint returns 500 when file is empty."""
194    response = client.post(
195        '/api/skan',
196        data={'file': (io.BytesIO(b''), 'empty.jpg')},
197        content_type='multipart/form-data'
198    )
199
200    assert response.status_code == 500
201
202
203def test_post_camera_scan_no_qr_code(client, app_context):
204    """Test endpoint returns 400 when no QR code is found in image."""
205    current_dir = os.path.dirname(os.path.abspath(__file__))
206    testimg_path = os.path.normpath(os.path.join(current_dir, "../assets/testimg.jpg"))
207
208    if not os.path.exists(testimg_path):
209        pytest.skip(f"Test image not found at {testimg_path}")
210
211    with app_context.app_context():
212        # Use plain face image without QR code
213        with open(testimg_path, 'rb') as f:
214            image_bytes = f.read()
215            response = client.post(
216                '/api/skan',
217                data={'file': (io.BytesIO(image_bytes), 'testimg.jpg')},
218                content_type='multipart/form-data'
219            )
220
221            assert response.status_code == 400
222
223
224# ============================================================================
225# Test POST /api/skan - Error Cases - Expired QR Code
226# ============================================================================
227def test_post_camera_scan_expired_qr_code(client, app_context, test_worker, test_image_with_qrcode):
228    """
229    Test endpoint returns 403 when QR code is expired.
230    Repeat at most 10 times if fails, QR code detection often fails.
231    """
232    with app_context.app_context():
233        with patch('backend.components.workers.workerService.get_worker_from_qr_code') as mock_get_worker:
234            mock_get_worker.return_value = test_worker
235
236            # Store original expiration date
237            original_expiration = test_worker.expiration_date
238
239            # Set worker expiration to the past
240            workerService.extend_worker_expiration(workerService.get_worker_by_id(test_worker.id),
241                                                   datetime.now() - timedelta(days=5))
242
243            i = 0
244            response = 10
245            while i != 10:
246                # Send request with expired worker's QR code
247                response = client.post(
248                    '/api/skan',
249                    data={'file': (io.BytesIO(test_image_with_qrcode), 'composite.jpg')},
250                    content_type='multipart/form-data'
251                )
252                if response.status_code == 403:
253                    break
254                i += 1
255
256            # Should return 403 because the QR code is expired
257            assert response.status_code == 403
258
259            # Restore original expiration date
260            workerService.extend_worker_expiration(workerService.get_worker_by_id(test_worker.id), original_expiration)
261
262
263# ============================================================================
264# Test POST /api/skan - Response Structure
265# ============================================================================
266
267def test_post_camera_scan_response_structure(client, app_context, test_worker, test_image_with_qrcode):
268    """Test that response always contains 'code' and 'messages' field."""
269    with app_context.app_context():
270        with patch('backend.components.workers.workerService.get_worker_from_qr_code') as mock_get_worker:
271            with patch('backend.components.camera_verification.faceid.faceidService.verify_worker_face') as mock_verify:
272                mock_get_worker.return_value = test_worker
273                mock_verify.return_value = [True]
274
275                response = client.post(
276                    '/api/skan',
277                    data={'file': (io.BytesIO(test_image_with_qrcode), 'composite.jpg')},
278                    content_type='multipart/form-data'
279                )
280
281                assert 'code' in response.json
282                assert 'message' in response.json
@pytest.fixture
def app_context():
15@pytest.fixture
16def app_context():
17    """Create app context with in-memory SQLite database for testing."""
18    app = create_app()
19    app.config.update({
20        "TESTING": True,
21        "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:"
22    })
23
24    with app.app_context():
25        db.create_all()
26        yield app

Create app context with in-memory SQLite database for testing.

@pytest.fixture
def test_worker(app_context):
29@pytest.fixture
30def test_worker(app_context):
31    """Create a test worker with face embedding from testimg.jpg."""
32    current_dir = os.path.dirname(os.path.abspath(__file__))
33    image_path = os.path.normpath(os.path.join(current_dir, "../assets/testimg.jpg"))
34
35
36
37    if not os.path.exists(image_path):
38        pytest.skip(f"Test image not found at {image_path}")
39
40    with app_context.app_context():
41        # Load and create embedding from test image
42        test_image = face_recognition.load_image_file(image_path)
43
44        # Use workerService.create_worker to create worker properly
45        worker = workerService.create_worker(
46            name="Jacob Czajka",
47            face_image=test_image,
48            expiration_date=datetime(2029, 1, 1)
49        )
50
51        yield worker

Create a test worker with face embedding from testimg.jpg.

@pytest.fixture
def client(app_context):
54@pytest.fixture
55def client(app_context):
56    """Create Flask test client."""
57    test_client = app_context.test_client()
58    yield test_client
59    test_client.delete()

Create Flask test client.

@pytest.fixture
def test_image_with_qrcode(app_context, client, test_worker):
 62@pytest.fixture
 63def test_image_with_qrcode(app_context, client, test_worker):
 64    """
 65    Create a composite test image by joining testimg.jpg with the worker's QR code.
 66    Places QR code beside the face image without distortion.
 67    Returns the composite image as bytes.
 68    """
 69    current_dir = os.path.dirname(os.path.abspath(__file__))
 70    testimg_path = os.path.normpath(os.path.join(current_dir, "../assets/testimg.jpg"))
 71
 72    if not os.path.exists(testimg_path):
 73        pytest.skip(f"Test image not found at {testimg_path}")
 74
 75    with app_context.app_context():
 76        # Get the QR code from the API endpoint
 77        response = client.get(f'/api/workers/entrypass/{test_worker.id}')
 78
 79        if response.status_code != 200:
 80            pytest.skip("Failed to generate QR code from API")
 81
 82        # Read QR code from response
 83        qr_code_bytes = response.data
 84        qr_code_array = np.frombuffer(qr_code_bytes, np.uint8)
 85        qr_code_image = cv2.imdecode(qr_code_array, cv2.IMREAD_COLOR)
 86
 87        if qr_code_image is None:
 88            pytest.skip("Failed to decode QR code image")
 89
 90        # Load the face image
 91        face_image = cv2.imread(testimg_path)
 92
 93        if face_image is None:
 94            pytest.skip("Failed to load test image")
 95
 96        # Make QR code 70% of face image height (much larger)
 97        qr_height = int(face_image.shape[0] * 0.7)
 98        qr_width = int(qr_height)  # QR codes are square - maintain aspect ratio
 99
100        qr_resized = cv2.resize(qr_code_image, (qr_width, qr_height), interpolation=cv2.INTER_AREA)
101
102        # Create composite image by placing QR code beside face image (not on top)
103        # Total width = face_width + qr_width + small padding
104        padding = 20
105        total_width = face_image.shape[1] + qr_width + padding * 2
106
107        # Use the taller of the two images as height
108        total_height = max(face_image.shape[0], qr_height) + padding * 2
109
110        composite_image = np.ones((total_height, total_width, 3), dtype=np.uint8) * 255
111
112        # Place face image on the left
113        face_y_start = (total_height - face_image.shape[0]) // 2
114        face_x_start = padding
115        composite_image[
116        face_y_start:face_y_start + face_image.shape[0],
117        face_x_start:face_x_start + face_image.shape[1]
118        ] = face_image
119
120        # Place QR code on the right
121        qr_y_start = (total_height - qr_height) // 2
122        qr_x_start = face_x_start + face_image.shape[1] + padding
123        composite_image[
124        qr_y_start:qr_y_start + qr_height,
125        qr_x_start:qr_x_start + qr_width
126        ] = qr_resized
127
128        # Encode composite image to bytes
129        success, buffer = cv2.imencode('.jpg', composite_image)
130        if not success:
131            pytest.skip("Failed to encode composite image")
132
133        return buffer.tobytes()

Create a composite test image by joining testimg.jpg with the worker's QR code. Places QR code beside the face image without distortion. Returns the composite image as bytes.

def test_post_camera_scan_success(client, app_context, test_worker, test_image_with_qrcode):
140def test_post_camera_scan_success(client, app_context, test_worker, test_image_with_qrcode):
141    """
142    Test successful verification with valid QR code and matching face.
143    QR code detection is iffy so it's attempted 10 times before we assume the endpoint is broken.
144    """
145    with app_context.app_context():
146        # Send request with composite image containing QR code and face
147        i = 0
148        response = None
149        while i != 10:
150            response = client.post(
151                '/api/skan',
152                data={'file': (io.BytesIO(test_image_with_qrcode), 'composite.jpg')},
153                content_type='multipart/form-data'
154            )
155            i += 1
156
157            if response.status_code == 200:
158                break
159
160        assert response.status_code == 200

Test successful verification with valid QR code and matching face. QR code detection is iffy so it's attempted 10 times before we assume the endpoint is broken.

def test_post_camera_scan_missing_file(client):
167def test_post_camera_scan_missing_file(client):
168    """Test endpoint returns 400 when file is missing."""
169    response = client.post(
170        '/api/skan',
171        data={},
172        content_type='multipart/form-data'
173    )
174
175    assert response.status_code == 400
176    assert 'error' in response.json
177    assert 'Brak pliku w żądaniu' in response.json['error']

Test endpoint returns 400 when file is missing.

def test_post_camera_scan_invalid_image(client):
180def test_post_camera_scan_invalid_image(client):
181    """Test endpoint returns 500 when image file is corrupted."""
182    invalid_image_bytes = b"This is not a valid image"
183
184    response = client.post(
185        '/api/skan',
186        data={'file': (io.BytesIO(invalid_image_bytes), 'invalid.jpg')},
187        content_type='multipart/form-data'
188    )
189
190    assert response.status_code == 500

Test endpoint returns 500 when image file is corrupted.

def test_post_camera_scan_empty_file(client):
193def test_post_camera_scan_empty_file(client):
194    """Test endpoint returns 500 when file is empty."""
195    response = client.post(
196        '/api/skan',
197        data={'file': (io.BytesIO(b''), 'empty.jpg')},
198        content_type='multipart/form-data'
199    )
200
201    assert response.status_code == 500

Test endpoint returns 500 when file is empty.

def test_post_camera_scan_no_qr_code(client, app_context):
204def test_post_camera_scan_no_qr_code(client, app_context):
205    """Test endpoint returns 400 when no QR code is found in image."""
206    current_dir = os.path.dirname(os.path.abspath(__file__))
207    testimg_path = os.path.normpath(os.path.join(current_dir, "../assets/testimg.jpg"))
208
209    if not os.path.exists(testimg_path):
210        pytest.skip(f"Test image not found at {testimg_path}")
211
212    with app_context.app_context():
213        # Use plain face image without QR code
214        with open(testimg_path, 'rb') as f:
215            image_bytes = f.read()
216            response = client.post(
217                '/api/skan',
218                data={'file': (io.BytesIO(image_bytes), 'testimg.jpg')},
219                content_type='multipart/form-data'
220            )
221
222            assert response.status_code == 400

Test endpoint returns 400 when no QR code is found in image.

def test_post_camera_scan_expired_qr_code(client, app_context, test_worker, test_image_with_qrcode):
228def test_post_camera_scan_expired_qr_code(client, app_context, test_worker, test_image_with_qrcode):
229    """
230    Test endpoint returns 403 when QR code is expired.
231    Repeat at most 10 times if fails, QR code detection often fails.
232    """
233    with app_context.app_context():
234        with patch('backend.components.workers.workerService.get_worker_from_qr_code') as mock_get_worker:
235            mock_get_worker.return_value = test_worker
236
237            # Store original expiration date
238            original_expiration = test_worker.expiration_date
239
240            # Set worker expiration to the past
241            workerService.extend_worker_expiration(workerService.get_worker_by_id(test_worker.id),
242                                                   datetime.now() - timedelta(days=5))
243
244            i = 0
245            response = 10
246            while i != 10:
247                # Send request with expired worker's QR code
248                response = client.post(
249                    '/api/skan',
250                    data={'file': (io.BytesIO(test_image_with_qrcode), 'composite.jpg')},
251                    content_type='multipart/form-data'
252                )
253                if response.status_code == 403:
254                    break
255                i += 1
256
257            # Should return 403 because the QR code is expired
258            assert response.status_code == 403
259
260            # Restore original expiration date
261            workerService.extend_worker_expiration(workerService.get_worker_by_id(test_worker.id), original_expiration)

Test endpoint returns 403 when QR code is expired. Repeat at most 10 times if fails, QR code detection often fails.

def test_post_camera_scan_response_structure(client, app_context, test_worker, test_image_with_qrcode):
268def test_post_camera_scan_response_structure(client, app_context, test_worker, test_image_with_qrcode):
269    """Test that response always contains 'code' and 'messages' field."""
270    with app_context.app_context():
271        with patch('backend.components.workers.workerService.get_worker_from_qr_code') as mock_get_worker:
272            with patch('backend.components.camera_verification.faceid.faceidService.verify_worker_face') as mock_verify:
273                mock_get_worker.return_value = test_worker
274                mock_verify.return_value = [True]
275
276                response = client.post(
277                    '/api/skan',
278                    data={'file': (io.BytesIO(test_image_with_qrcode), 'composite.jpg')},
279                    content_type='multipart/form-data'
280                )
281
282                assert 'code' in response.json
283                assert 'message' in response.json

Test that response always contains 'code' and 'messages' field.