397 lines
16 KiB
Python
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)
|
|
|
|
|
|
|