loko/streetup/panoramax/tests.py
2026-07-22 14:48:40 +02:00

397 lines
16 KiB
Python

from django.test import TestCase, Client
from django.contrib.auth.models import User
from django.urls import reverse
from django.conf import settings
from datetime import datetime, timezone, timedelta
import json
import xml.etree.ElementTree as ET
import tempfile
import os
from common.models import UserConfig, AppView
from .models import PanoramaxUpload
from .utils import parse_gpx, interpolate_gpx, haversine_distance, calculate_stable_heading
class PanoramaxAppViewTestCase(TestCase):
"""Test cases for the Panoramax app view access and configurations"""
def setUp(self):
# Create a test user
self.user = User.objects.create_user(
username='testuser',
email='test@example.com',
password='testpass123'
)
# Create user config
self.user_config = UserConfig.objects.create(user=self.user)
# Create Panoramax AppView
self.panoramax_view = AppView.objects.create(
code='panoramax',
name_fr='Photos 360',
name_nl="360° Foto's",
icon='bi-images',
url_name='panoramax:index',
is_default=False,
order=40
)
def test_appview_created(self):
"""Test that Panoramax AppView fields match expectations"""
self.assertEqual(self.panoramax_view.code, 'panoramax')
self.assertEqual(self.panoramax_view.name_fr, 'Photos 360')
self.assertEqual(self.panoramax_view.url_name, 'panoramax:index')
def test_user_without_permission(self):
"""Test that user without permission cannot access panoramax view"""
client = Client()
client.login(username='testuser', password='testpass123')
response = client.get(reverse('panoramax:index'))
self.assertEqual(response.status_code, 403)
def test_user_with_permission(self):
"""Test that user with permission can access panoramax view"""
self.user_config.accessible_views.add(self.panoramax_view)
client = Client()
client.login(username='testuser', password='testpass123')
response = client.get(reverse('panoramax:index'))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'panoramax/viewer.html')
class PanoramaxUploadTestCase(TestCase):
"""Test cases for Panoramax upload functionality, permissions, and status API"""
def setUp(self):
self.user = User.objects.create_user(
username='uploader',
email='uploader@example.com',
password='testpass123'
)
self.user_config = UserConfig.objects.create(user=self.user)
self.panoramax_view = AppView.objects.create(
code='panoramax',
name_fr='Photos 360',
name_nl="360° Foto's",
icon='bi-images',
url_name='panoramax:index',
is_default=False,
order=40
)
self.user_config.accessible_views.add(self.panoramax_view)
def test_upload_page_access_permission(self):
"""Test that only users with panoramax_can_upload can access upload page"""
client = Client()
client.login(username='uploader', password='testpass123')
# No permission initially
response = client.get(reverse('panoramax:upload'))
self.assertEqual(response.status_code, 403)
# Grant permission
self.user_config.panoramax_can_upload = True
self.user_config.save()
response = client.get(reverse('panoramax:upload'))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'panoramax/upload.html')
def test_upload_status_api(self):
"""Test the upload status JSON API view"""
self.user_config.panoramax_can_upload = True
self.user_config.save()
upload_task = PanoramaxUpload.objects.create(
user=self.user,
title='Test Upload Sequence',
upload_type='files',
status='processing',
progress=45,
error_message=None
)
client = Client()
client.login(username='uploader', password='testpass123')
response = client.get(reverse('panoramax:upload_status', args=[upload_task.id]))
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertEqual(data['status'], 'processing')
self.assertEqual(data['progress'], 45)
self.assertEqual(data['error_message'], '')
class PanoramaxUtilsTestCase(TestCase):
"""Test cases for helper utilities (GPX parsing, distance, interpolation)"""
def test_haversine_distance(self):
"""Test distance calculation between two points (Bruxelles Grand Place to Gare Centrale)"""
# Approx 400m
dist = haversine_distance(50.8467, 4.3524, 50.8455, 4.3575)
self.assertTrue(350 <= dist <= 450)
def test_interpolate_gpx(self):
"""Test GPX trackpoint time interpolation"""
t1 = datetime(2026, 7, 19, 10, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 7, 19, 10, 0, 10, tzinfo=timezone.utc)
gpx_points = [
{'lat': 50.0, 'lon': 4.0, 'ele': 10.0, 'time': t1},
{'lat': 50.1, 'lon': 4.2, 'ele': 20.0, 'time': t2}
]
# Interpolate halfway
t_mid = t1 + timedelta(seconds=5)
pos = interpolate_gpx(gpx_points, t_mid)
self.assertIsNotNone(pos)
self.assertAlmostEqual(pos['lat'], 50.05)
self.assertAlmostEqual(pos['lon'], 4.1)
self.assertAlmostEqual(pos['ele'], 15.0)
def test_parse_gpx(self):
"""Test parsing of GPX XML content"""
gpx_content = """<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="StreetUp" xmlns="http://www.topografix.com/GPX/1/1">
<trk>
<trkseg>
<trkpt lat="50.8503" lon="4.3517">
<ele>12.5</ele>
<time>2026-07-19T10:00:00Z</time>
</trkpt>
<trkpt lat="50.8510" lon="4.3525">
<ele>13.0</ele>
<time>2026-07-19T10:00:05Z</time>
</trkpt>
</trkseg>
</trk>
</gpx>
"""
with tempfile.NamedTemporaryFile(suffix='.gpx', delete=False, mode='w', encoding='utf-8') as temp_file:
temp_file.write(gpx_content)
temp_file_path = temp_file.name
try:
points = parse_gpx(temp_file_path)
self.assertEqual(len(points), 2)
self.assertEqual(points[0]['lat'], 50.8503)
self.assertEqual(points[0]['lon'], 4.3517)
self.assertEqual(points[0]['ele'], 12.5)
self.assertEqual(points[0]['time'].year, 2026)
finally:
if os.path.exists(temp_file_path):
os.unlink(temp_file_path)
def test_parse_gpmf_value(self):
"""Test GPMF value decoding for float, integers, chars"""
from .utils import parse_gpmf_value
import struct
self.assertEqual(parse_gpmf_value('c', 1, 4, b'test'), 'test')
data = struct.pack('>I', 10000000)
self.assertEqual(parse_gpmf_value('L', 4, 1, data), 10000000)
data_list = struct.pack('>II', 10, 20)
self.assertEqual(parse_gpmf_value('L', 4, 2, data_list), (10, 20))
def test_parse_klv(self):
"""Test GPMF KLV nesting and element parsing"""
from .utils import parse_klv
import struct
gps5_data = struct.pack('>5i', 50850000, 4350000, 100, 15, 15)
gps5_klv = b'GPS5l\x04\x00\x05' + gps5_data
scal_data = struct.pack('>I', 1000000)
scal_klv = b'SCALL\x04\x00\x01' + scal_data
strm_payload = gps5_klv + scal_klv
strm_klv = b'STRM\x00\x01' + struct.pack('>H', len(strm_payload)) + strm_payload
devi_klv = b'DEVI\x00\x01' + struct.pack('>H', len(strm_klv)) + strm_klv
parsed = parse_klv(devi_klv)
self.assertIn('DEVI', parsed)
self.assertEqual(len(parsed['DEVI']), 1)
self.assertIn('STRM', parsed['DEVI'][0])
self.assertEqual(len(parsed['DEVI'][0]['STRM']), 1)
strm = parsed['DEVI'][0]['STRM'][0]
self.assertIn('GPS5', strm)
self.assertIn('SCAL', strm)
self.assertEqual(strm['SCAL'], 1000000)
self.assertEqual(strm['GPS5'], (50850000, 4350000, 100, 15, 15))
from unittest.mock import patch
class PanoramaxDeleteViewsTestCase(TestCase):
"""Test cases for the delete_collection and delete_picture views"""
def setUp(self):
# Create users
self.owner = User.objects.create_user(username='owner', email='owner@example.com', password='password123')
self.other = User.objects.create_user(username='other', email='other@example.com', password='password123')
self.admin = User.objects.create_user(username='admin', email='admin@example.com', password='password123', is_staff=True)
# Access configs
self.panoramax_view = AppView.objects.get_or_create(
code='panoramax',
defaults={
'name_fr': 'Photos 360',
'name_nl': "360° Foto's",
'icon': 'bi-images',
'url_name': 'panoramax:index',
'is_default': False,
'order': 40
}
)[0]
for u in [self.owner, self.other, self.admin]:
config = UserConfig.objects.get_or_create(user=u)[0]
config.accessible_views.add(self.panoramax_view)
# Create an upload task belonging to owner with an associated collection
self.col_id = "00000000-0000-0000-0000-000000000001"
self.upload_task = PanoramaxUpload.objects.create(
user=self.owner,
title='Owner Sequence',
upload_type='files',
status='completed',
progress=100,
associated_collections=[{'id': self.col_id, 'date': '2026-07-21'}]
)
def test_delete_collection_requires_post(self):
"""Test that delete_collection returns 400 Bad Request for GET requests"""
client = Client()
client.login(username='owner', password='password123')
response = client.get(reverse('panoramax:delete_collection', args=[self.col_id]))
self.assertEqual(response.status_code, 400)
@patch('requests.delete')
def test_delete_collection_owner_success(self, mock_delete):
"""Test that the owner of a collection can successfully delete it"""
# Mock requests.delete returning 204 No Content
mock_delete.return_value.status_code = 204
client = Client()
client.login(username='owner', password='password123')
response = client.post(reverse('panoramax:delete_collection', args=[self.col_id]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "deleted successfully")
# Verify db was cleaned up (collection ID removed from associated_collections)
self.upload_task.refresh_from_db()
self.assertEqual(self.upload_task.associated_collections, [])
def test_delete_collection_non_owner_forbidden(self):
"""Test that a non-owner non-admin user cannot delete the collection"""
client = Client()
client.login(username='other', password='password123')
response = client.post(reverse('panoramax:delete_collection', args=[self.col_id]))
self.assertEqual(response.status_code, 403)
@patch('requests.delete')
def test_delete_collection_admin_success(self, mock_delete):
"""Test that an admin (staff) can delete another user's collection"""
mock_delete.return_value.status_code = 204
client = Client()
client.login(username='admin', password='password123')
response = client.post(reverse('panoramax:delete_collection', args=[self.col_id]))
self.assertEqual(response.status_code, 200)
@patch('requests.delete')
def test_delete_picture_owner_success(self, mock_delete):
"""Test that the owner can delete a picture from their collection"""
mock_delete.return_value.status_code = 204
pic_id = "00000000-0000-0000-0000-000000000002"
client = Client()
client.login(username='owner', password='password123')
response = client.post(reverse('panoramax:delete_picture', args=[self.col_id, pic_id]))
self.assertEqual(response.status_code, 200)
def test_delete_picture_non_owner_forbidden(self):
"""Test that a non-owner cannot delete a picture"""
pic_id = "00000000-0000-0000-0000-000000000002"
client = Client()
client.login(username='other', password='password123')
response = client.post(reverse('panoramax:delete_picture', args=[self.col_id, pic_id]))
self.assertEqual(response.status_code, 403)
class PanoramaxStableHeadingTestCase(TestCase):
"""Test cases for the calculate_stable_heading utility"""
def test_calculate_stable_heading_forward(self):
# Create a simple straight track going north (lon constant, lat increasing)
start_time = datetime(2026, 7, 21, 10, 0, 0, tzinfo=timezone.utc)
gps_points = [
{'lat': 50.8500, 'lon': 4.3500, 'time': start_time, 'ele': 100},
{'lat': 50.8501, 'lon': 4.3500, 'time': start_time + timedelta(seconds=1), 'ele': 100},
{'lat': 50.8502, 'lon': 4.3500, 'time': start_time + timedelta(seconds=2), 'ele': 100},
{'lat': 50.8503, 'lon': 4.3500, 'time': start_time + timedelta(seconds=3), 'ele': 100},
{'lat': 50.8504, 'lon': 4.3500, 'time': start_time + timedelta(seconds=4), 'ele': 100},
]
pos = gps_points[1] # At lat 50.8501
# Calculate heading - should point straight north (bearing ~0)
heading = calculate_stable_heading(pos, gps_points, start_time + timedelta(seconds=1))
# Bearing from (50.8501, 4.3500) to (50.8502, 4.3500) is 0
self.assertAlmostEqual(heading, 0.0, places=2)
def test_calculate_stable_heading_backward_at_end(self):
# Near end of track, should look backward
start_time = datetime(2026, 7, 21, 10, 0, 0, tzinfo=timezone.utc)
gps_points = [
{'lat': 50.8500, 'lon': 4.3500, 'time': start_time, 'ele': 100},
{'lat': 50.8501, 'lon': 4.3500, 'time': start_time + timedelta(seconds=1), 'ele': 100},
{'lat': 50.8502, 'lon': 4.3500, 'time': start_time + timedelta(seconds=2), 'ele': 100},
{'lat': 50.8503, 'lon': 4.3500, 'time': start_time + timedelta(seconds=3), 'ele': 100},
{'lat': 50.8504, 'lon': 4.3500, 'time': start_time + timedelta(seconds=4), 'ele': 100},
]
pos = gps_points[4] # At very end of track
heading = calculate_stable_heading(pos, gps_points, start_time + timedelta(seconds=4), min_distance=10)
# Should look backward to a point at least 10 meters behind, resulting in north travel (~0)
self.assertAlmostEqual(heading, 0.0, places=2)
def test_calculate_stable_heading_camera_offset(self):
# Straight track going north, but with camera offset 90 (looking right)
start_time = datetime(2026, 7, 21, 10, 0, 0, tzinfo=timezone.utc)
gps_points = [
{'lat': 50.8500, 'lon': 4.3500, 'time': start_time, 'ele': 100},
{'lat': 50.8501, 'lon': 4.3500, 'time': start_time + timedelta(seconds=1), 'ele': 100},
{'lat': 50.8502, 'lon': 4.3500, 'time': start_time + timedelta(seconds=2), 'ele': 100},
]
pos = gps_points[0]
heading = calculate_stable_heading(pos, gps_points, start_time, camera_offset=90)
# 0 + 90 = 90
self.assertAlmostEqual(heading, 90.0, places=2)
# Test negative offset wrap-around
heading_neg = calculate_stable_heading(pos, gps_points, start_time, camera_offset=-90)
# 0 - 90 = -90 -> 270
self.assertAlmostEqual(heading_neg, 270.0, places=2)