All checks were successful
Build containers when image tags change / build-if-image-changed (., web, containers, main container, git.baumann.gr/adebaumann/labhelper) (push) Successful in 18s
Build containers when image tags change / build-if-image-changed (data-loader, loader, initContainers, init-container, git.baumann.gr/adebaumann/labhelper-data-loader) (push) Successful in 4s
1889 lines
70 KiB
Python
1889 lines
70 KiB
Python
from django.contrib.admin.sites import AdminSite
|
|
from django.contrib.auth.models import User
|
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
|
from django.db import IntegrityError
|
|
from django.test import Client, TestCase
|
|
from django.urls import reverse
|
|
|
|
from .admin import BoxAdmin, BoxTypeAdmin, ThingAdmin
|
|
from .models import Box, BoxType, Facet, Tag, Thing, ThingFile, ThingLink
|
|
|
|
|
|
class AuthTestCase(TestCase):
|
|
"""Base test case that provides authenticated client."""
|
|
|
|
def setUp(self):
|
|
"""Set up test user and authenticated client."""
|
|
self.user = User.objects.create_user(
|
|
username='testuser',
|
|
password='testpass123'
|
|
)
|
|
self.client = Client()
|
|
self.client.login(username='testuser', password='testpass123')
|
|
|
|
|
|
class BoxTypeModelTests(TestCase):
|
|
"""Tests for the BoxType model."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
self.box_type = BoxType.objects.create(
|
|
name='Small Box',
|
|
width=100,
|
|
height=50,
|
|
length=150
|
|
)
|
|
|
|
def test_box_type_str_returns_name(self):
|
|
"""BoxType __str__ should return the name."""
|
|
self.assertEqual(str(self.box_type), 'Small Box')
|
|
|
|
def test_box_type_creation(self):
|
|
"""BoxType should be created with correct attributes."""
|
|
self.assertEqual(self.box_type.name, 'Small Box')
|
|
self.assertEqual(self.box_type.width, 100)
|
|
self.assertEqual(self.box_type.height, 50)
|
|
self.assertEqual(self.box_type.length, 150)
|
|
|
|
def test_box_type_ordering(self):
|
|
"""BoxTypes should be ordered by name."""
|
|
BoxType.objects.create(name='Alpha Box', width=10, height=10, length=10)
|
|
BoxType.objects.create(name='Zeta Box', width=20, height=20, length=20)
|
|
box_types = list(BoxType.objects.values_list('name', flat=True))
|
|
self.assertEqual(box_types, ['Alpha Box', 'Small Box', 'Zeta Box'])
|
|
|
|
|
|
class BoxModelTests(TestCase):
|
|
"""Tests for the Box model."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
self.box_type = BoxType.objects.create(
|
|
name='Standard Box',
|
|
width=200,
|
|
height=100,
|
|
length=300
|
|
)
|
|
self.box = Box.objects.create(
|
|
id='BOX001',
|
|
box_type=self.box_type
|
|
)
|
|
|
|
def test_box_str_returns_id(self):
|
|
"""Box __str__ should return the box ID."""
|
|
self.assertEqual(str(self.box), 'BOX001')
|
|
|
|
def test_box_creation(self):
|
|
"""Box should be created with correct attributes."""
|
|
self.assertEqual(self.box.id, 'BOX001')
|
|
self.assertEqual(self.box.box_type, self.box_type)
|
|
|
|
def test_box_type_relationship(self):
|
|
"""Box should be accessible from BoxType via related_name."""
|
|
self.assertIn(self.box, self.box_type.boxes.all())
|
|
|
|
def test_box_id_max_length(self):
|
|
"""Box ID should accept up to 10 characters."""
|
|
box = Box.objects.create(id='ABCD123456', box_type=self.box_type)
|
|
self.assertEqual(len(box.id), 10)
|
|
|
|
def test_box_type_protect_on_delete(self):
|
|
"""Deleting a BoxType with boxes should raise IntegrityError."""
|
|
with self.assertRaises(IntegrityError):
|
|
self.box_type.delete()
|
|
|
|
def test_box_type_delete_when_no_boxes(self):
|
|
"""Deleting a BoxType without boxes should succeed."""
|
|
empty_type = BoxType.objects.create(
|
|
name='Empty Type',
|
|
width=10,
|
|
height=10,
|
|
length=10
|
|
)
|
|
empty_type_id = empty_type.id
|
|
empty_type.delete()
|
|
self.assertFalse(BoxType.objects.filter(id=empty_type_id).exists())
|
|
|
|
|
|
class BoxTypeAdminTests(TestCase):
|
|
"""Tests for the BoxType admin configuration."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
self.site = AdminSite()
|
|
self.admin = BoxTypeAdmin(BoxType, self.site)
|
|
|
|
def test_list_display(self):
|
|
"""BoxTypeAdmin should display correct fields."""
|
|
self.assertEqual(
|
|
self.admin.list_display,
|
|
('name', 'width', 'height', 'length')
|
|
)
|
|
|
|
def test_search_fields(self):
|
|
"""BoxTypeAdmin should search by name."""
|
|
self.assertEqual(self.admin.search_fields, ('name',))
|
|
|
|
|
|
class BoxAdminTests(TestCase):
|
|
"""Tests for the Box admin configuration."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
self.site = AdminSite()
|
|
self.admin = BoxAdmin(Box, self.site)
|
|
|
|
def test_list_display(self):
|
|
"""BoxAdmin should display correct fields."""
|
|
self.assertEqual(self.admin.list_display, ('id', 'box_type'))
|
|
|
|
def test_list_filter(self):
|
|
"""BoxAdmin should filter by box_type."""
|
|
self.assertEqual(self.admin.list_filter, ('box_type',))
|
|
|
|
def test_search_fields(self):
|
|
"""BoxAdmin should search by id."""
|
|
self.assertEqual(self.admin.search_fields, ('id',))
|
|
|
|
|
|
class ThingModelTests(TestCase):
|
|
"""Tests for the Thing model."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
self.box_type = BoxType.objects.create(
|
|
name='Standard Box',
|
|
width=200,
|
|
height=100,
|
|
length=300
|
|
)
|
|
self.box = Box.objects.create(
|
|
id='BOX001',
|
|
box_type=self.box_type
|
|
)
|
|
self.thing = Thing.objects.create(
|
|
name='Arduino Uno',
|
|
box=self.box
|
|
)
|
|
|
|
def test_thing_str_returns_name(self):
|
|
"""Thing __str__ should return the name."""
|
|
self.assertEqual(str(self.thing), 'Arduino Uno')
|
|
|
|
def test_thing_creation(self):
|
|
"""Thing should be created with correct attributes."""
|
|
self.assertEqual(self.thing.name, 'Arduino Uno')
|
|
self.assertEqual(self.thing.box, self.box)
|
|
|
|
def test_thing_optional_description(self):
|
|
"""Thing description should be optional."""
|
|
self.assertEqual(self.thing.description, '')
|
|
self.thing.description = 'A microcontroller board'
|
|
self.thing.save()
|
|
self.thing.refresh_from_db()
|
|
self.assertEqual(self.thing.description, 'A microcontroller board')
|
|
|
|
def test_thing_optional_picture(self):
|
|
"""Thing picture should be optional."""
|
|
self.assertEqual(self.thing.picture.name, '')
|
|
|
|
def test_thing_with_picture(self):
|
|
"""Thing should accept an image upload."""
|
|
# Create a simple 1x1 pixel PNG
|
|
image_data = (
|
|
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01'
|
|
b'\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00'
|
|
b'\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00'
|
|
b'\x05\x18\xd8N\x00\x00\x00\x00IEND\xaeB`\x82'
|
|
)
|
|
image = SimpleUploadedFile(
|
|
name='test.png',
|
|
content=image_data,
|
|
content_type='image/png'
|
|
)
|
|
thing = Thing.objects.create(
|
|
name='Test Item',
|
|
box=self.box,
|
|
picture=image
|
|
)
|
|
self.assertTrue(thing.picture.name.startswith('things/'))
|
|
# Clean up
|
|
thing.picture.delete()
|
|
|
|
def test_thing_ordering(self):
|
|
"""Things should be ordered by name."""
|
|
Thing.objects.create(
|
|
name='Zeta Item',
|
|
box=self.box
|
|
)
|
|
Thing.objects.create(
|
|
name='Alpha Item',
|
|
box=self.box
|
|
)
|
|
things = list(Thing.objects.values_list('name', flat=True))
|
|
self.assertEqual(things, ['Alpha Item', 'Arduino Uno', 'Zeta Item'])
|
|
|
|
def test_thing_box_relationship(self):
|
|
"""Thing should be accessible from Box via related_name."""
|
|
self.assertIn(self.thing, self.box.things.all())
|
|
|
|
def test_box_protect_on_delete_with_things(self):
|
|
"""Deleting a Box with things should raise IntegrityError."""
|
|
with self.assertRaises(IntegrityError):
|
|
self.box.delete()
|
|
|
|
|
|
class ThingAdminTests(TestCase):
|
|
"""Tests for the Thing admin configuration."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
self.site = AdminSite()
|
|
self.admin = ThingAdmin(Thing, self.site)
|
|
|
|
def test_list_display(self):
|
|
"""ThingAdmin should display correct fields."""
|
|
self.assertEqual(
|
|
self.admin.list_display,
|
|
('name', 'box')
|
|
)
|
|
|
|
def test_search_fields(self):
|
|
"""ThingAdmin should search by name and description."""
|
|
self.assertEqual(self.admin.search_fields, ('name', 'description'))
|
|
|
|
|
|
class IndexViewTests(AuthTestCase):
|
|
"""Tests for the index view."""
|
|
|
|
def test_index_returns_200(self):
|
|
"""Index page should return 200 status."""
|
|
response = self.client.get('/')
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
def test_index_contains_labhelper(self):
|
|
"""Index page should contain LabHelper title."""
|
|
response = self.client.get('/')
|
|
self.assertContains(response, 'LabHelper')
|
|
|
|
def test_index_contains_admin_link(self):
|
|
"""Index page should contain link to admin."""
|
|
response = self.client.get('/')
|
|
self.assertContains(response, '/admin/')
|
|
|
|
def test_index_requires_login(self):
|
|
"""Index page should redirect to login if not authenticated."""
|
|
self.client.logout()
|
|
response = self.client.get('/')
|
|
self.assertRedirects(response, '/login/?next=/')
|
|
|
|
|
|
class BoxDetailViewTests(AuthTestCase):
|
|
"""Tests for the box detail view."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
super().setUp()
|
|
self.box_type = BoxType.objects.create(
|
|
name='Standard Box',
|
|
width=200,
|
|
height=100,
|
|
length=300
|
|
)
|
|
self.box = Box.objects.create(
|
|
id='BOX001',
|
|
box_type=self.box_type
|
|
)
|
|
|
|
def test_box_detail_returns_200(self):
|
|
"""Box detail page should return 200 status."""
|
|
response = self.client.get(f'/box/{self.box.id}/')
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
def test_box_detail_returns_404_for_invalid_box(self):
|
|
"""Box detail page should return 404 for non-existent box."""
|
|
response = self.client.get('/box/INVALID/')
|
|
self.assertEqual(response.status_code, 404)
|
|
|
|
def test_box_detail_shows_box_id(self):
|
|
"""Box detail page should show box ID."""
|
|
response = self.client.get(f'/box/{self.box.id}/')
|
|
self.assertContains(response, 'BOX001')
|
|
|
|
def test_box_detail_shows_box_type(self):
|
|
"""Box detail page should show box type name."""
|
|
response = self.client.get(f'/box/{self.box.id}/')
|
|
self.assertContains(response, 'Standard Box')
|
|
|
|
def test_box_detail_shows_dimensions(self):
|
|
"""Box detail page should show box dimensions."""
|
|
response = self.client.get(f'/box/{self.box.id}/')
|
|
self.assertContains(response, '200')
|
|
self.assertContains(response, '100')
|
|
self.assertContains(response, '300')
|
|
|
|
def test_box_detail_shows_empty_message(self):
|
|
"""Box detail page should show empty message when no things."""
|
|
response = self.client.get(f'/box/{self.box.id}/')
|
|
self.assertContains(response, 'This box is empty')
|
|
|
|
def test_box_detail_shows_thing(self):
|
|
"""Box detail page should show things in the box."""
|
|
Thing.objects.create(
|
|
name='Arduino Uno',
|
|
box=self.box,
|
|
description='A microcontroller board'
|
|
)
|
|
response = self.client.get(f'/box/{self.box.id}/')
|
|
self.assertContains(response, 'Arduino Uno')
|
|
self.assertContains(response, 'A microcontroller board')
|
|
|
|
def test_box_detail_shows_no_image_placeholder(self):
|
|
"""Box detail page should show placeholder for things without images."""
|
|
Thing.objects.create(
|
|
name='Test Item',
|
|
box=self.box
|
|
)
|
|
response = self.client.get(f'/box/{self.box.id}/')
|
|
self.assertContains(response, 'No image')
|
|
|
|
def test_box_detail_shows_multiple_things(self):
|
|
"""Box detail page should show multiple things."""
|
|
Thing.objects.create(
|
|
name='Item One',
|
|
box=self.box
|
|
)
|
|
Thing.objects.create(
|
|
name='Item Two',
|
|
box=self.box
|
|
)
|
|
response = self.client.get(f'/box/{self.box.id}/')
|
|
self.assertContains(response, 'Item One')
|
|
self.assertContains(response, 'Item Two')
|
|
|
|
def test_box_detail_uses_correct_template(self):
|
|
"""Box detail page should use the correct template."""
|
|
response = self.client.get(f'/box/{self.box.id}/')
|
|
self.assertTemplateUsed(response, 'boxes/box_detail.html')
|
|
|
|
def test_box_detail_with_image(self):
|
|
"""Box detail page should show thumbnail for things with images."""
|
|
# Create a simple 1x1 pixel PNG
|
|
image_data = (
|
|
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01'
|
|
b'\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00'
|
|
b'\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00'
|
|
b'\x05\x18\xd8N\x00\x00\x00\x00IEND\xaeB`\x82'
|
|
)
|
|
image = SimpleUploadedFile(
|
|
name='test.png',
|
|
content=image_data,
|
|
content_type='image/png'
|
|
)
|
|
thing = Thing.objects.create(
|
|
name='Item With Image',
|
|
box=self.box,
|
|
picture=image
|
|
)
|
|
response = self.client.get(f'/box/{self.box.id}/')
|
|
self.assertContains(response, 'Item With Image')
|
|
self.assertContains(response, '<img')
|
|
# Clean up
|
|
thing.picture.delete()
|
|
|
|
def test_box_detail_url_name(self):
|
|
"""Box detail URL should be reversible by name."""
|
|
url = reverse('box_detail', kwargs={'box_id': 'BOX001'})
|
|
self.assertEqual(url, '/box/BOX001/')
|
|
|
|
def test_box_detail_requires_login(self):
|
|
"""Box detail page should redirect to login if not authenticated."""
|
|
self.client.logout()
|
|
response = self.client.get(f'/box/{self.box.id}/')
|
|
self.assertRedirects(response, f'/login/?next=/box/{self.box.id}/')
|
|
|
|
|
|
class ThingDetailViewTests(AuthTestCase):
|
|
"""Tests for thing detail view."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
super().setUp()
|
|
self.box_type = BoxType.objects.create(
|
|
name='Standard Box',
|
|
width=200,
|
|
height=100,
|
|
length=300
|
|
)
|
|
self.box = Box.objects.create(
|
|
id='BOX001',
|
|
box_type=self.box_type
|
|
)
|
|
self.thing = Thing.objects.create(
|
|
name='Arduino Uno',
|
|
box=self.box,
|
|
description='A microcontroller board'
|
|
)
|
|
|
|
def test_thing_detail_returns_200(self):
|
|
"""Thing detail page should return 200 status."""
|
|
response = self.client.get(f'/thing/{self.thing.id}/')
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
def test_thing_detail_returns_404_for_invalid_thing(self):
|
|
"""Thing detail page should return 404 for non-existent thing."""
|
|
response = self.client.get('/thing/99999/')
|
|
self.assertEqual(response.status_code, 404)
|
|
|
|
def test_thing_detail_shows_thing_name(self):
|
|
"""Thing detail page should show thing name."""
|
|
response = self.client.get(f'/thing/{self.thing.id}/')
|
|
self.assertContains(response, 'Arduino Uno')
|
|
|
|
def test_thing_detail_shows_tags_section(self):
|
|
"""Thing detail page should show tags section when tags exist."""
|
|
facet = Facet.objects.create(
|
|
name='Electronics',
|
|
color='#FF5733',
|
|
cardinality=Facet.Cardinality.MULTIPLE
|
|
)
|
|
tag = Tag.objects.create(facet=facet, name='Arduino')
|
|
self.thing.tags.add(tag)
|
|
response = self.client.get(f'/thing/{self.thing.id}/')
|
|
self.assertContains(response, 'Electronics')
|
|
|
|
def test_thing_detail_shows_description(self):
|
|
"""Thing detail page should show thing description."""
|
|
response = self.client.get(f'/thing/{self.thing.id}/')
|
|
self.assertContains(response, 'A microcontroller board')
|
|
|
|
def test_thing_detail_shows_box(self):
|
|
"""Thing detail page should show box info."""
|
|
response = self.client.get(f'/thing/{self.thing.id}/')
|
|
self.assertContains(response, 'BOX001')
|
|
self.assertContains(response, 'Standard Box')
|
|
|
|
def test_thing_detail_uses_correct_template(self):
|
|
"""Thing detail page should use correct template."""
|
|
response = self.client.get(f'/thing/{self.thing.id}/')
|
|
self.assertTemplateUsed(response, 'boxes/thing_detail.html')
|
|
|
|
def test_thing_detail_url_name(self):
|
|
"""Thing detail URL should be reversible by name."""
|
|
url = reverse('thing_detail', kwargs={'thing_id': 1})
|
|
self.assertEqual(url, '/thing/1/')
|
|
|
|
def test_thing_detail_requires_login(self):
|
|
"""Thing detail page should redirect to login if not authenticated."""
|
|
self.client.logout()
|
|
response = self.client.get(f'/thing/{self.thing.id}/')
|
|
self.assertRedirects(response, f'/login/?next=/thing/{self.thing.id}/')
|
|
|
|
def test_thing_detail_has_edit_button(self):
|
|
"""Thing detail page should have an edit button."""
|
|
response = self.client.get(f'/thing/{self.thing.id}/')
|
|
self.assertContains(response, 'Edit')
|
|
self.assertContains(response, f'/thing/{self.thing.id}/edit/')
|
|
|
|
|
|
class SearchViewTests(AuthTestCase):
|
|
"""Tests for search view."""
|
|
|
|
def test_search_returns_200(self):
|
|
"""Search page should return 200 status."""
|
|
response = self.client.get('/search/')
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
def test_search_contains_search_input(self):
|
|
"""Search page should contain search input field."""
|
|
response = self.client.get('/search/')
|
|
self.assertContains(response, 'id="search-input"')
|
|
|
|
def test_search_contains_results_container(self):
|
|
"""Search page should contain results table."""
|
|
response = self.client.get('/search/')
|
|
self.assertContains(response, 'id="results-container"')
|
|
|
|
def test_search_requires_login(self):
|
|
"""Search page should redirect to login if not authenticated."""
|
|
self.client.logout()
|
|
response = self.client.get('/search/')
|
|
self.assertRedirects(response, '/login/?next=/search/')
|
|
|
|
|
|
class SearchApiTests(AuthTestCase):
|
|
"""Tests for search API."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
super().setUp()
|
|
self.box_type = BoxType.objects.create(
|
|
name='Standard Box',
|
|
width=200,
|
|
height=100,
|
|
length=300
|
|
)
|
|
self.box = Box.objects.create(
|
|
id='BOX001',
|
|
box_type=self.box_type
|
|
)
|
|
Thing.objects.create(
|
|
name='Arduino Uno',
|
|
box=self.box,
|
|
description='A microcontroller board'
|
|
)
|
|
Thing.objects.create(
|
|
name='Raspberry Pi',
|
|
box=self.box
|
|
)
|
|
|
|
def test_search_api_returns_empty_for_short_query(self):
|
|
"""Search API should return empty results for queries under 2 chars."""
|
|
response = self.client.get('/search/api/?q=a')
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(response.json()['results'], [])
|
|
|
|
def test_search_api_returns_results(self):
|
|
"""Search API should return matching results."""
|
|
response = self.client.get('/search/api/?q=ard')
|
|
self.assertEqual(response.status_code, 200)
|
|
results = response.json()['results']
|
|
self.assertEqual(len(results), 1)
|
|
self.assertEqual(results[0]['name'], 'Arduino Uno')
|
|
|
|
def test_search_api_is_case_insensitive(self):
|
|
"""Search API should be case-insensitive."""
|
|
response = self.client.get('/search/api/?q=ARDUINO')
|
|
self.assertEqual(response.status_code, 200)
|
|
results = response.json()['results']
|
|
self.assertEqual(len(results), 1)
|
|
self.assertEqual(results[0]['name'], 'Arduino Uno')
|
|
|
|
def test_search_api_truncates_description(self):
|
|
"""Search API should truncate long descriptions."""
|
|
Thing.objects.create(
|
|
name='Long Description Item',
|
|
box=self.box,
|
|
description='A' * 200
|
|
)
|
|
response = self.client.get('/search/api/?q=long')
|
|
self.assertEqual(response.status_code, 200)
|
|
results = response.json()['results']
|
|
self.assertEqual(len(results), 1)
|
|
# Truncated text + '...' should be around 100 chars
|
|
self.assertLessEqual(len(results[0]['description']), 105)
|
|
|
|
def test_search_api_limits_results(self):
|
|
"""Search API should limit results to 50."""
|
|
for i in range(60):
|
|
Thing.objects.create(
|
|
name=f'Item {i}',
|
|
box=self.box
|
|
)
|
|
response = self.client.get('/search/api/?q=Item')
|
|
self.assertEqual(response.status_code, 200)
|
|
results = response.json()['results']
|
|
self.assertEqual(len(results), 50)
|
|
|
|
def test_search_api_includes_tags_and_box(self):
|
|
"""Search API results should include tags and box info."""
|
|
response = self.client.get('/search/api/?q=ard')
|
|
self.assertEqual(response.status_code, 200)
|
|
results = response.json()['results']
|
|
self.assertEqual(results[0]['tags'], [])
|
|
self.assertEqual(results[0]['box'], 'BOX001')
|
|
|
|
def test_search_api_requires_login(self):
|
|
"""Search API should redirect to login if not authenticated."""
|
|
self.client.logout()
|
|
response = self.client.get('/search/api/?q=test')
|
|
self.assertRedirects(response, '/login/?next=/search/api/%3Fq%3Dtest')
|
|
|
|
|
|
class AddThingsViewTests(AuthTestCase):
|
|
"""Tests for add things view."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
super().setUp()
|
|
self.box_type = BoxType.objects.create(
|
|
name='Standard Box',
|
|
width=200,
|
|
height=100,
|
|
length=300
|
|
)
|
|
self.box = Box.objects.create(
|
|
id='BOX001',
|
|
box_type=self.box_type
|
|
)
|
|
|
|
def test_add_things_get_request(self):
|
|
"""Add things page should return 200 for GET request."""
|
|
response = self.client.get(f'/box/{self.box.id}/add/')
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
def test_add_things_shows_box_id(self):
|
|
"""Add things page should show box ID."""
|
|
response = self.client.get(f'/box/{self.box.id}/add/')
|
|
self.assertContains(response, 'BOX001')
|
|
self.assertContains(response, 'Standard Box')
|
|
|
|
def test_add_things_shows_form(self):
|
|
"""Add things page should show form."""
|
|
response = self.client.get(f'/box/{self.box.id}/add/')
|
|
self.assertContains(response, 'Save Things')
|
|
self.assertContains(response, 'Name')
|
|
self.assertContains(response, 'Type')
|
|
self.assertContains(response, 'Description')
|
|
self.assertContains(response, 'Picture')
|
|
|
|
def test_add_things_post_valid(self):
|
|
"""Adding valid things should show success message and create things."""
|
|
response = self.client.post(f'/box/{self.box.id}/add/', {
|
|
'form-TOTAL_FORMS': '3',
|
|
'form-INITIAL_FORMS': '0',
|
|
'form-0-name': 'Arduino Uno',
|
|
'form-0-description': 'A microcontroller',
|
|
'form-1-name': 'LED Strip',
|
|
'form-1-description': 'Lighting component',
|
|
'form-2-name': '',
|
|
'form-2-description': '',
|
|
})
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, 'Added 2 things successfully')
|
|
self.assertEqual(Thing.objects.count(), 2)
|
|
|
|
def test_add_things_post_required_name(self):
|
|
"""Adding things without name should show error."""
|
|
response = self.client.post(f'/box/{self.box.id}/add/', {
|
|
'form-TOTAL_FORMS': '2',
|
|
})
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, 'This field is required')
|
|
|
|
def test_add_things_post_partial_valid_and_empty(self):
|
|
"""Partial submission: one valid, one empty - only valid ones are saved."""
|
|
response = self.client.post(f'/box/{self.box.id}/add/', {
|
|
'form-TOTAL_FORMS': '2',
|
|
'form-INITIAL_FORMS': '0',
|
|
'form-0-name': 'Arduino Uno',
|
|
'form-1-name': '',
|
|
})
|
|
self.assertEqual(response.status_code, 200)
|
|
# Valid row is saved, empty row is skipped
|
|
self.assertContains(response, 'Added 1 thing successfully')
|
|
self.assertEqual(Thing.objects.count(), 1)
|
|
self.assertEqual(Thing.objects.first().name, 'Arduino Uno')
|
|
|
|
def test_add_things_empty_all(self):
|
|
"""Submitting empty forms should not create anything."""
|
|
response = self.client.post(f'/box/{self.box.id}/add/', {
|
|
'form-TOTAL_FORMS': '2',
|
|
'form-INITIAL_FORMS': '0',
|
|
'form-0-name': '',
|
|
'form-1-name': '',
|
|
})
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(Thing.objects.count(), 0)
|
|
|
|
def test_add_things_box_not_exists(self):
|
|
"""Adding things to non-existent box should return 404."""
|
|
response = self.client.get('/box/INVALID/add/')
|
|
self.assertEqual(response.status_code, 404)
|
|
|
|
def test_add_things_populates_box(self):
|
|
"""Created things should be assigned to the correct box."""
|
|
response = self.client.post(f'/box/{self.box.id}/add/', {
|
|
'form-TOTAL_FORMS': '3',
|
|
'form-INITIAL_FORMS': '0',
|
|
'form-0-name': 'Bolt',
|
|
'form-1-name': 'Nut',
|
|
'form-2-name': 'Washer',
|
|
})
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, 'Added 3 things successfully')
|
|
things = Thing.objects.all()
|
|
self.assertEqual(things.count(), 3)
|
|
for thing in things:
|
|
self.assertEqual(thing.box, self.box)
|
|
|
|
def test_add_things_requires_login(self):
|
|
"""Add things page should redirect to login if not authenticated."""
|
|
self.client.logout()
|
|
response = self.client.get(f'/box/{self.box.id}/add/')
|
|
self.assertRedirects(response, f'/login/?next=/box/{self.box.id}/add/')
|
|
|
|
|
|
class LoginViewTests(TestCase):
|
|
"""Tests for login view."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
self.client = Client()
|
|
self.user = User.objects.create_user(
|
|
username='testuser',
|
|
password='testpass123'
|
|
)
|
|
|
|
def test_login_page_returns_200(self):
|
|
"""Login page should return 200 status."""
|
|
response = self.client.get('/login/')
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
def test_login_page_contains_form(self):
|
|
"""Login page should contain login form."""
|
|
response = self.client.get('/login/')
|
|
self.assertContains(response, 'Username')
|
|
self.assertContains(response, 'Password')
|
|
self.assertContains(response, 'Login')
|
|
|
|
def test_login_with_valid_credentials(self):
|
|
"""Login with valid credentials should redirect to home."""
|
|
response = self.client.post('/login/', {
|
|
'username': 'testuser',
|
|
'password': 'testpass123'
|
|
})
|
|
self.assertRedirects(response, '/')
|
|
|
|
def test_login_with_invalid_credentials(self):
|
|
"""Login with invalid credentials should show error."""
|
|
response = self.client.post('/login/', {
|
|
'username': 'testuser',
|
|
'password': 'wrongpassword'
|
|
})
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, 'username and password')
|
|
|
|
def test_login_redirects_to_next(self):
|
|
"""Login should redirect to 'next' parameter after success."""
|
|
response = self.client.post('/login/?next=/search/', {
|
|
'username': 'testuser',
|
|
'password': 'testpass123'
|
|
})
|
|
self.assertRedirects(response, '/search/')
|
|
|
|
def test_logout_redirects_to_login(self):
|
|
"""Logout should redirect to login page."""
|
|
self.client.login(username='testuser', password='testpass123')
|
|
response = self.client.post('/logout/')
|
|
self.assertRedirects(response, '/login/')
|
|
|
|
|
|
class BoxManagementViewTests(AuthTestCase):
|
|
"""Tests for box management view."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
super().setUp()
|
|
self.box_type = BoxType.objects.create(
|
|
name='Standard Box',
|
|
width=200,
|
|
height=100,
|
|
length=300
|
|
)
|
|
self.box = Box.objects.create(
|
|
id='BOX001',
|
|
box_type=self.box_type
|
|
)
|
|
|
|
def test_box_management_returns_200(self):
|
|
"""Box management page should return 200 status."""
|
|
response = self.client.get('/box-management/')
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
def test_box_management_shows_box_types(self):
|
|
"""Box management page should show box types."""
|
|
response = self.client.get('/box-management/')
|
|
self.assertContains(response, 'Standard Box')
|
|
|
|
def test_box_management_shows_boxes(self):
|
|
"""Box management page should show boxes."""
|
|
response = self.client.get('/box-management/')
|
|
self.assertContains(response, 'BOX001')
|
|
|
|
def test_box_management_shows_add_box_type_form(self):
|
|
"""Box management page should show add box type form."""
|
|
response = self.client.get('/box-management/')
|
|
self.assertContains(response, 'Add New Box Type')
|
|
self.assertContains(response, 'name="name"')
|
|
self.assertContains(response, 'name="width"')
|
|
self.assertContains(response, 'name="height"')
|
|
self.assertContains(response, 'name="length"')
|
|
|
|
def test_box_management_shows_add_box_form(self):
|
|
"""Box management page should show add box form."""
|
|
response = self.client.get('/box-management/')
|
|
self.assertContains(response, 'Add New Box')
|
|
self.assertContains(response, 'name="id"')
|
|
self.assertContains(response, 'name="box_type"')
|
|
|
|
def test_box_management_requires_login(self):
|
|
"""Box management page should redirect to login if not authenticated."""
|
|
self.client.logout()
|
|
response = self.client.get('/box-management/')
|
|
self.assertRedirects(response, '/login/?next=/box-management/')
|
|
|
|
|
|
class BoxTypeCRUDTests(AuthTestCase):
|
|
"""Tests for box type CRUD operations."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
super().setUp()
|
|
self.box_type = BoxType.objects.create(
|
|
name='Standard Box',
|
|
width=200,
|
|
height=100,
|
|
length=300
|
|
)
|
|
|
|
def test_add_box_type_post_creates_box_type(self):
|
|
"""Adding a box type should create it in the database."""
|
|
response = self.client.post('/box-type/add/', {
|
|
'name': 'Large Box',
|
|
'width': '400',
|
|
'height': '200',
|
|
'length': '600'
|
|
})
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertRedirects(response, '/box-management/')
|
|
self.assertTrue(BoxType.objects.filter(name='Large Box').exists())
|
|
|
|
def test_add_box_type_invalid_data(self):
|
|
"""Adding a box type with invalid data should not create it."""
|
|
response = self.client.post('/box-type/add/', {
|
|
'name': 'Invalid Box',
|
|
'width': 'invalid',
|
|
'height': '100',
|
|
'length': '200'
|
|
})
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertFalse(BoxType.objects.filter(name='Invalid Box').exists())
|
|
|
|
def test_edit_box_type_updates_box_type(self):
|
|
"""Editing a box type should update it in the database."""
|
|
response = self.client.post(f'/box-type/{self.box_type.id}/edit/', {
|
|
'name': 'Updated Box',
|
|
'width': '300',
|
|
'height': '150',
|
|
'length': '450'
|
|
})
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertRedirects(response, '/box-management/')
|
|
self.box_type.refresh_from_db()
|
|
self.assertEqual(self.box_type.name, 'Updated Box')
|
|
self.assertEqual(self.box_type.width, 300)
|
|
|
|
def test_edit_box_type_invalid_data(self):
|
|
"""Editing a box type with invalid data should not update it."""
|
|
old_name = self.box_type.name
|
|
response = self.client.post(f'/box-type/{self.box_type.id}/edit/', {
|
|
'name': 'Updated Box',
|
|
'width': 'invalid',
|
|
'height': '150',
|
|
'length': '450'
|
|
})
|
|
self.assertEqual(response.status_code, 302)
|
|
self.box_type.refresh_from_db()
|
|
self.assertEqual(self.box_type.name, old_name)
|
|
|
|
def test_delete_box_type_deletes_box_type(self):
|
|
"""Deleting a box type should remove it from the database."""
|
|
type_id = self.box_type.id
|
|
response = self.client.post(f'/box-type/{type_id}/delete/')
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertRedirects(response, '/box-management/')
|
|
self.assertFalse(BoxType.objects.filter(id=type_id).exists())
|
|
|
|
def test_delete_box_type_with_boxes_redirects(self):
|
|
"""Deleting a box type with boxes should redirect without deleting."""
|
|
Box.objects.create(id='BOX001', box_type=self.box_type)
|
|
type_id = self.box_type.id
|
|
response = self.client.post(f'/box-type/{type_id}/delete/')
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertRedirects(response, '/box-management/')
|
|
self.assertTrue(BoxType.objects.filter(id=type_id).exists())
|
|
|
|
|
|
class BoxCRUDTests(AuthTestCase):
|
|
"""Tests for box CRUD operations."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
super().setUp()
|
|
self.box_type = BoxType.objects.create(
|
|
name='Standard Box',
|
|
width=200,
|
|
height=100,
|
|
length=300
|
|
)
|
|
self.box = Box.objects.create(
|
|
id='BOX001',
|
|
box_type=self.box_type
|
|
)
|
|
|
|
def test_add_box_post_creates_box(self):
|
|
"""Adding a box should create it in the database."""
|
|
response = self.client.post('/box/add/', {
|
|
'id': 'BOX002',
|
|
'box_type': str(self.box_type.id)
|
|
})
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertRedirects(response, '/box-management/')
|
|
self.assertTrue(Box.objects.filter(id='BOX002').exists())
|
|
|
|
def test_add_box_invalid_data(self):
|
|
"""Adding a box with invalid data should not create it."""
|
|
response = self.client.post('/box/add/', {
|
|
'id': '',
|
|
'box_type': str(self.box_type.id)
|
|
})
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual(Box.objects.count(), 1)
|
|
|
|
def test_edit_box_updates_box_type(self):
|
|
"""Editing a box should update its box type in the database."""
|
|
new_type = BoxType.objects.create(
|
|
name='Large Box',
|
|
width=400,
|
|
height=200,
|
|
length=600
|
|
)
|
|
response = self.client.post(f'/box/{self.box.id}/edit/', {
|
|
'id': 'BOX001',
|
|
'box_type': str(new_type.id)
|
|
})
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertRedirects(response, '/box-management/')
|
|
self.box.refresh_from_db()
|
|
self.assertEqual(self.box.box_type, new_type)
|
|
|
|
def test_edit_box_invalid_data(self):
|
|
"""Editing a box with invalid data should not update it."""
|
|
old_id = self.box.id
|
|
response = self.client.post(f'/box/{self.box.id}/edit/', {
|
|
'id': '',
|
|
'box_type': str(self.box_type.id)
|
|
})
|
|
self.assertEqual(response.status_code, 302)
|
|
self.box.refresh_from_db()
|
|
self.assertEqual(self.box.id, old_id)
|
|
|
|
def test_delete_box_deletes_box(self):
|
|
"""Deleting a box should remove it from the database."""
|
|
box_id = self.box.id
|
|
response = self.client.post(f'/box/{box_id}/delete/')
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertRedirects(response, '/box-management/')
|
|
self.assertFalse(Box.objects.filter(id=box_id).exists())
|
|
|
|
def test_delete_box_with_things_redirects(self):
|
|
"""Deleting a box with things should redirect without deleting."""
|
|
Thing.objects.create(
|
|
name='Test Item',
|
|
box=self.box
|
|
)
|
|
box_id = self.box.id
|
|
response = self.client.post(f'/box/{box_id}/delete/')
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertRedirects(response, '/box-management/')
|
|
self.assertTrue(Box.objects.filter(id=box_id).exists())
|
|
|
|
|
|
class ThingPictureUploadTests(AuthTestCase):
|
|
"""Tests for thing picture deletion."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
super().setUp()
|
|
self.box_type = BoxType.objects.create(
|
|
name='Standard Box',
|
|
width=200,
|
|
height=100,
|
|
length=300
|
|
)
|
|
self.box = Box.objects.create(
|
|
id='BOX001',
|
|
box_type=self.box_type
|
|
)
|
|
self.thing = Thing.objects.create(
|
|
name='Arduino Uno',
|
|
box=self.box
|
|
)
|
|
self.image_data = (
|
|
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01'
|
|
b'\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00'
|
|
b'\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00'
|
|
b'\x05\x18\xd8N\x00\x00\x00\x00IEND\xaeB`\x82'
|
|
)
|
|
|
|
def test_edit_thing_shows_add_picture_button(self):
|
|
"""Edit thing page should show 'Add picture' button when thing has no picture."""
|
|
response = self.client.get(f'/thing/{self.thing.id}/edit/')
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, 'Add picture')
|
|
|
|
def test_edit_thing_shows_change_picture_button(self):
|
|
"""Edit thing page should show 'Change picture' button when thing has a picture."""
|
|
image = SimpleUploadedFile(
|
|
name='test.png',
|
|
content=self.image_data,
|
|
content_type='image/png'
|
|
)
|
|
self.thing.picture = image
|
|
self.thing.save()
|
|
response = self.client.get(f'/thing/{self.thing.id}/edit/')
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, 'Change picture')
|
|
# Clean up
|
|
self.thing.picture.delete(save=False)
|
|
|
|
def test_edit_thing_shows_remove_button(self):
|
|
"""Edit thing page should show 'Remove' button when thing has a picture."""
|
|
image = SimpleUploadedFile(
|
|
name='test.png',
|
|
content=self.image_data,
|
|
content_type='image/png'
|
|
)
|
|
self.thing.picture = image
|
|
self.thing.save()
|
|
response = self.client.get(f'/thing/{self.thing.id}/edit/')
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, 'Remove')
|
|
# Clean up
|
|
self.thing.picture.delete(save=False)
|
|
|
|
def test_delete_picture_removes_picture(self):
|
|
"""Deleting a picture should remove it from thing."""
|
|
image = SimpleUploadedFile(
|
|
name='test.png',
|
|
content=self.image_data,
|
|
content_type='image/png'
|
|
)
|
|
self.thing.picture = image
|
|
self.thing.save()
|
|
|
|
response = self.client.post(f'/thing/{self.thing.id}/edit/', {
|
|
'action': 'delete_picture'
|
|
})
|
|
self.assertRedirects(response, f'/thing/{self.thing.id}/edit/')
|
|
self.thing.refresh_from_db()
|
|
self.assertFalse(self.thing.picture.name)
|
|
|
|
def test_delete_picture_on_thing_without_picture(self):
|
|
"""Deleting a picture from a thing without a picture should succeed."""
|
|
response = self.client.post(f'/thing/{self.thing.id}/edit/', {
|
|
'action': 'delete_picture'
|
|
})
|
|
self.assertRedirects(response, f'/thing/{self.thing.id}/edit/')
|
|
self.thing.refresh_from_db()
|
|
self.assertFalse(self.thing.picture.name)
|
|
|
|
def test_delete_picture_on_thing_without_picture(self):
|
|
"""Deleting a picture from a thing without a picture should succeed."""
|
|
response = self.client.post(f'/thing/{self.thing.id}/edit/', {
|
|
'action': 'delete_picture'
|
|
})
|
|
self.assertRedirects(response, f'/thing/{self.thing.id}/edit/')
|
|
self.thing.refresh_from_db()
|
|
self.assertFalse(self.thing.picture.name)
|
|
|
|
|
|
class ThingFileModelTests(AuthTestCase):
|
|
"""Tests for the ThingFile model."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
super().setUp()
|
|
self.box_type = BoxType.objects.create(
|
|
name='Standard Box',
|
|
width=200,
|
|
height=100,
|
|
length=300
|
|
)
|
|
self.box = Box.objects.create(
|
|
id='BOX001',
|
|
box_type=self.box_type
|
|
)
|
|
self.thing = Thing.objects.create(
|
|
name='Arduino Uno',
|
|
box=self.box
|
|
)
|
|
|
|
def test_thing_file_creation(self):
|
|
"""ThingFile should be created with correct attributes."""
|
|
thing_file = ThingFile.objects.create(
|
|
thing=self.thing,
|
|
title='Datasheet',
|
|
file='datasheets/test.pdf'
|
|
)
|
|
self.assertEqual(thing_file.thing, self.thing)
|
|
self.assertEqual(thing_file.title, 'Datasheet')
|
|
self.assertEqual(thing_file.file.name, 'datasheets/test.pdf')
|
|
|
|
def test_thing_file_str(self):
|
|
"""ThingFile __str__ should return thing name and title."""
|
|
thing_file = ThingFile.objects.create(
|
|
thing=self.thing,
|
|
title='Manual',
|
|
file='manuals/test.pdf'
|
|
)
|
|
expected = f'{self.thing.name} - Manual'
|
|
self.assertEqual(str(thing_file), expected)
|
|
|
|
def test_thing_file_ordering(self):
|
|
"""ThingFiles should be ordered by uploaded_at descending."""
|
|
file1 = ThingFile.objects.create(
|
|
thing=self.thing,
|
|
title='First File',
|
|
file='test1.pdf'
|
|
)
|
|
file2 = ThingFile.objects.create(
|
|
thing=self.thing,
|
|
title='Second File',
|
|
file='test2.pdf'
|
|
)
|
|
files = list(ThingFile.objects.all())
|
|
self.assertEqual(files[0], file2)
|
|
self.assertEqual(files[1], file1)
|
|
|
|
def test_thing_file_deletion_on_thing_delete(self):
|
|
"""Deleting a Thing should delete its files."""
|
|
thing_file = ThingFile.objects.create(
|
|
thing=self.thing,
|
|
title='Test File',
|
|
file='test.pdf'
|
|
)
|
|
file_id = thing_file.id
|
|
self.thing.delete()
|
|
self.assertFalse(ThingFile.objects.filter(id=file_id).exists())
|
|
|
|
|
|
class ThingLinkModelTests(AuthTestCase):
|
|
"""Tests for the ThingLink model."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
super().setUp()
|
|
self.box_type = BoxType.objects.create(
|
|
name='Standard Box',
|
|
width=200,
|
|
height=100,
|
|
length=300
|
|
)
|
|
self.box = Box.objects.create(
|
|
id='BOX001',
|
|
box_type=self.box_type
|
|
)
|
|
self.thing = Thing.objects.create(
|
|
name='Arduino Uno',
|
|
box=self.box
|
|
)
|
|
|
|
def test_thing_link_creation(self):
|
|
"""ThingLink should be created with correct attributes."""
|
|
thing_link = ThingLink.objects.create(
|
|
thing=self.thing,
|
|
title='Manufacturer Website',
|
|
url='https://www.arduino.cc'
|
|
)
|
|
self.assertEqual(thing_link.thing, self.thing)
|
|
self.assertEqual(thing_link.title, 'Manufacturer Website')
|
|
self.assertEqual(thing_link.url, 'https://www.arduino.cc')
|
|
|
|
def test_thing_link_str(self):
|
|
"""ThingLink __str__ should return thing name and title."""
|
|
thing_link = ThingLink.objects.create(
|
|
thing=self.thing,
|
|
title='Documentation',
|
|
url='https://docs.arduino.cc'
|
|
)
|
|
expected = f'{self.thing.name} - Documentation'
|
|
self.assertEqual(str(thing_link), expected)
|
|
|
|
def test_thing_link_ordering(self):
|
|
"""ThingLinks should be ordered by uploaded_at descending."""
|
|
link1 = ThingLink.objects.create(
|
|
thing=self.thing,
|
|
title='First Link',
|
|
url='https://example1.com'
|
|
)
|
|
link2 = ThingLink.objects.create(
|
|
thing=self.thing,
|
|
title='Second Link',
|
|
url='https://example2.com'
|
|
)
|
|
links = list(ThingLink.objects.all())
|
|
self.assertEqual(links[0], link2)
|
|
self.assertEqual(links[1], link1)
|
|
|
|
def test_thing_link_deletion_on_thing_delete(self):
|
|
"""Deleting a Thing should delete its links."""
|
|
thing_link = ThingLink.objects.create(
|
|
thing=self.thing,
|
|
title='Test Link',
|
|
url='https://example.com'
|
|
)
|
|
link_id = thing_link.id
|
|
self.thing.delete()
|
|
self.assertFalse(ThingLink.objects.filter(id=link_id).exists())
|
|
|
|
|
|
class ThingFileAndLinkCRUDTests(AuthTestCase):
|
|
"""Tests for Thing file and link CRUD operations."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
super().setUp()
|
|
self.box_type = BoxType.objects.create(
|
|
name='Standard Box',
|
|
width=200,
|
|
height=100,
|
|
length=300
|
|
)
|
|
self.box = Box.objects.create(
|
|
id='BOX001',
|
|
box_type=self.box_type
|
|
)
|
|
self.thing = Thing.objects.create(
|
|
name='Arduino Uno',
|
|
box=self.box
|
|
)
|
|
|
|
def test_add_file_to_thing(self):
|
|
"""Adding a file should create ThingFile."""
|
|
file_content = b'Sample PDF content'
|
|
uploaded_file = SimpleUploadedFile(
|
|
name='datasheet.pdf',
|
|
content=file_content,
|
|
content_type='application/pdf'
|
|
)
|
|
response = self.client.post(
|
|
f'/thing/{self.thing.id}/edit/',
|
|
{'action': 'add_file', 'title': 'Datasheet', 'file': uploaded_file}
|
|
)
|
|
self.assertRedirects(response, f'/thing/{self.thing.id}/edit/')
|
|
self.assertEqual(self.thing.files.count(), 1)
|
|
self.assertEqual(self.thing.files.first().title, 'Datasheet')
|
|
|
|
def test_add_link_to_thing(self):
|
|
"""Adding a link should create ThingLink."""
|
|
response = self.client.post(
|
|
f'/thing/{self.thing.id}/edit/',
|
|
{
|
|
'action': 'add_link',
|
|
'title': 'Manufacturer',
|
|
'url': 'https://www.arduino.cc'
|
|
}
|
|
)
|
|
self.assertRedirects(response, f'/thing/{self.thing.id}/edit/')
|
|
self.assertEqual(self.thing.links.count(), 1)
|
|
self.assertEqual(self.thing.links.first().title, 'Manufacturer')
|
|
self.assertEqual(self.thing.links.first().url, 'https://www.arduino.cc')
|
|
|
|
def test_delete_file_from_thing(self):
|
|
"""Deleting a file should remove it from database."""
|
|
thing_file = ThingFile.objects.create(
|
|
thing=self.thing,
|
|
title='Test File',
|
|
file='test.pdf'
|
|
)
|
|
file_id = thing_file.id
|
|
response = self.client.post(
|
|
f'/thing/{self.thing.id}/edit/',
|
|
{'action': 'delete_file', 'file_id': str(file_id)}
|
|
)
|
|
self.assertRedirects(response, f'/thing/{self.thing.id}/edit/')
|
|
self.assertFalse(ThingFile.objects.filter(id=file_id).exists())
|
|
|
|
def test_delete_link_from_thing(self):
|
|
"""Deleting a link should remove it from database."""
|
|
thing_link = ThingLink.objects.create(
|
|
thing=self.thing,
|
|
title='Test Link',
|
|
url='https://example.com'
|
|
)
|
|
link_id = thing_link.id
|
|
response = self.client.post(
|
|
f'/thing/{self.thing.id}/edit/',
|
|
{'action': 'delete_link', 'link_id': str(link_id)}
|
|
)
|
|
self.assertRedirects(response, f'/thing/{self.thing.id}/edit/')
|
|
self.assertFalse(ThingLink.objects.filter(id=link_id).exists())
|
|
|
|
def test_cannot_delete_file_from_other_thing(self):
|
|
"""Cannot delete file from another thing."""
|
|
other_thing = Thing.objects.create(
|
|
name='Other Item',
|
|
box=self.box
|
|
)
|
|
thing_file = ThingFile.objects.create(
|
|
thing=other_thing,
|
|
title='Other File',
|
|
file='other.pdf'
|
|
)
|
|
file_id = thing_file.id
|
|
response = self.client.post(
|
|
f'/thing/{self.thing.id}/edit/',
|
|
{'action': 'delete_file', 'file_id': str(file_id)}
|
|
)
|
|
self.assertRedirects(response, f'/thing/{self.thing.id}/edit/')
|
|
self.assertTrue(ThingFile.objects.filter(id=file_id).exists())
|
|
|
|
def test_cannot_delete_link_from_other_thing(self):
|
|
"""Cannot delete link from another thing."""
|
|
other_thing = Thing.objects.create(
|
|
name='Other Item',
|
|
box=self.box
|
|
)
|
|
thing_link = ThingLink.objects.create(
|
|
thing=other_thing,
|
|
title='Other Link',
|
|
url='https://other.com'
|
|
)
|
|
link_id = thing_link.id
|
|
response = self.client.post(
|
|
f'/thing/{self.thing.id}/edit/',
|
|
{'action': 'delete_link', 'link_id': str(link_id)}
|
|
)
|
|
self.assertRedirects(response, f'/thing/{self.thing.id}/edit/')
|
|
self.assertTrue(ThingLink.objects.filter(id=link_id).exists())
|
|
|
|
def test_thing_detail_shows_files_section(self):
|
|
"""Thing detail page should show files when they exist."""
|
|
ThingFile.objects.create(
|
|
thing=self.thing,
|
|
title='Datasheet',
|
|
file='test.pdf'
|
|
)
|
|
response = self.client.get(f'/thing/{self.thing.id}/')
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, 'Files')
|
|
self.assertContains(response, 'Datasheet')
|
|
|
|
def test_thing_detail_shows_links_section(self):
|
|
"""Thing detail page should show links when they exist."""
|
|
ThingLink.objects.create(
|
|
thing=self.thing,
|
|
title='Documentation',
|
|
url='https://docs.example.com'
|
|
)
|
|
response = self.client.get(f'/thing/{self.thing.id}/')
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, 'Links')
|
|
self.assertContains(response, 'Documentation')
|
|
|
|
def test_edit_thing_shows_upload_forms(self):
|
|
"""Edit thing page should show upload forms."""
|
|
response = self.client.get(f'/thing/{self.thing.id}/edit/')
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, 'Upload File')
|
|
self.assertContains(response, 'Add Link')
|
|
|
|
def test_search_api_includes_files(self):
|
|
"""Search API results should include files."""
|
|
ThingFile.objects.create(
|
|
thing=self.thing,
|
|
title='Datasheet',
|
|
file='datasheets/test.pdf'
|
|
)
|
|
response = self.client.get('/search/api/?q=ard')
|
|
self.assertEqual(response.status_code, 200)
|
|
results = response.json()['results']
|
|
self.assertEqual(len(results), 1)
|
|
self.assertEqual(len(results[0]['files']), 1)
|
|
self.assertEqual(results[0]['files'][0]['title'], 'Datasheet')
|
|
self.assertIn('filename', results[0]['files'][0])
|
|
|
|
def test_search_api_includes_links(self):
|
|
"""Search API results should include links."""
|
|
ThingLink.objects.create(
|
|
thing=self.thing,
|
|
title='Documentation',
|
|
url='https://docs.example.com'
|
|
)
|
|
response = self.client.get('/search/api/?q=ard')
|
|
self.assertEqual(response.status_code, 200)
|
|
results = response.json()['results']
|
|
self.assertEqual(len(results), 1)
|
|
self.assertEqual(len(results[0]['links']), 1)
|
|
self.assertEqual(results[0]['links'][0]['title'], 'Documentation')
|
|
self.assertEqual(results[0]['links'][0]['url'], 'https://docs.example.com')
|
|
|
|
def test_search_api_shows_empty_files_and_links(self):
|
|
"""Search API should show empty arrays for things without files/links."""
|
|
response = self.client.get('/search/api/?q=ard')
|
|
self.assertEqual(response.status_code, 200)
|
|
results = response.json()['results']
|
|
self.assertEqual(len(results), 1)
|
|
self.assertEqual(results[0]['files'], [])
|
|
self.assertEqual(results[0]['links'], [])
|
|
|
|
def test_search_api_searches_by_file_title(self):
|
|
"""Search API should find things by file title."""
|
|
ThingFile.objects.create(
|
|
thing=self.thing,
|
|
title='Datasheet PDF',
|
|
file='datasheets/test.pdf'
|
|
)
|
|
response = self.client.get('/search/api/?q=Datasheet')
|
|
self.assertEqual(response.status_code, 200)
|
|
results = response.json()['results']
|
|
self.assertEqual(len(results), 1)
|
|
self.assertEqual(results[0]['name'], self.thing.name)
|
|
|
|
def test_search_api_searches_by_filename(self):
|
|
"""Search API should find things by filename."""
|
|
ThingFile.objects.create(
|
|
thing=self.thing,
|
|
title='Test File',
|
|
file='models/part123.stl'
|
|
)
|
|
response = self.client.get('/search/api/?q=stl')
|
|
self.assertEqual(response.status_code, 200)
|
|
results = response.json()['results']
|
|
self.assertEqual(len(results), 1)
|
|
self.assertEqual(results[0]['name'], self.thing.name)
|
|
|
|
def test_search_api_searches_by_link_title(self):
|
|
"""Search API should find things by link title."""
|
|
ThingLink.objects.create(
|
|
thing=self.thing,
|
|
title='Documentation Link',
|
|
url='https://docs.example.com/manual.pdf'
|
|
)
|
|
response = self.client.get('/search/api/?q=Documentation')
|
|
self.assertEqual(response.status_code, 200)
|
|
results = response.json()['results']
|
|
self.assertEqual(len(results), 1)
|
|
self.assertEqual(results[0]['name'], self.thing.name)
|
|
|
|
def test_search_api_searches_by_link_url(self):
|
|
"""Search API should find things by link URL."""
|
|
ThingLink.objects.create(
|
|
thing=self.thing,
|
|
title='Manual',
|
|
url='https://arduino.cc/products/uno'
|
|
)
|
|
response = self.client.get('/search/api/?q=arduino')
|
|
self.assertEqual(response.status_code, 200)
|
|
results = response.json()['results']
|
|
self.assertGreater(len(results), 0)
|
|
|
|
|
|
class FacetModelTests(TestCase):
|
|
"""Tests for the Facet model."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
self.facet = Facet.objects.create(
|
|
name='Category',
|
|
color='#667eea',
|
|
cardinality=Facet.Cardinality.MULTIPLE
|
|
)
|
|
|
|
def test_facet_str_returns_name(self):
|
|
"""Facet __str__ should return the name."""
|
|
self.assertEqual(str(self.facet), 'Category')
|
|
|
|
def test_facet_creation(self):
|
|
"""Facet should be created with correct attributes."""
|
|
self.assertEqual(self.facet.name, 'Category')
|
|
self.assertEqual(self.facet.color, '#667eea')
|
|
self.assertEqual(self.facet.cardinality, Facet.Cardinality.MULTIPLE)
|
|
|
|
def test_facet_auto_slug(self):
|
|
"""Facet should auto-generate slug from name."""
|
|
facet = Facet.objects.create(name='Test Facet')
|
|
self.assertEqual(facet.slug, 'test-facet')
|
|
|
|
def test_facet_ordering(self):
|
|
"""Facets should be ordered by name."""
|
|
Facet.objects.create(name='Alpha Facet')
|
|
Facet.objects.create(name='Zeta Facet')
|
|
facets = list(Facet.objects.values_list('name', flat=True))
|
|
self.assertEqual(facets, ['Alpha Facet', 'Category', 'Zeta Facet'])
|
|
|
|
def test_facet_single_cardinality(self):
|
|
"""Facet can have single cardinality."""
|
|
facet = Facet.objects.create(
|
|
name='Priority',
|
|
cardinality=Facet.Cardinality.SINGLE
|
|
)
|
|
self.assertEqual(facet.cardinality, Facet.Cardinality.SINGLE)
|
|
|
|
def test_facet_default_color(self):
|
|
"""Facet should have default color."""
|
|
facet = Facet.objects.create(name='No Color')
|
|
self.assertEqual(facet.color, '#667eea')
|
|
|
|
def test_facet_name_unique(self):
|
|
"""Facet name should be unique."""
|
|
from django.db import IntegrityError
|
|
with self.assertRaises(IntegrityError):
|
|
Facet.objects.create(name='Category')
|
|
|
|
|
|
class TagModelTests(TestCase):
|
|
"""Tests for the Tag model."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
self.facet = Facet.objects.create(
|
|
name='Category',
|
|
color='#667eea'
|
|
)
|
|
self.tag = Tag.objects.create(
|
|
facet=self.facet,
|
|
name='Electronics'
|
|
)
|
|
|
|
def test_tag_str_returns_facet_and_name(self):
|
|
"""Tag __str__ should return facet:name format."""
|
|
self.assertEqual(str(self.tag), 'Category:Electronics')
|
|
|
|
def test_tag_creation(self):
|
|
"""Tag should be created with correct attributes."""
|
|
self.assertEqual(self.tag.facet, self.facet)
|
|
self.assertEqual(self.tag.name, 'Electronics')
|
|
|
|
def test_tag_ordering(self):
|
|
"""Tags should be ordered by facet then name."""
|
|
Tag.objects.create(facet=self.facet, name='Alpha')
|
|
Tag.objects.create(facet=self.facet, name='Zeta')
|
|
tags = list(Tag.objects.values_list('name', flat=True))
|
|
self.assertEqual(tags, ['Alpha', 'Electronics', 'Zeta'])
|
|
|
|
def test_tag_facet_relationship(self):
|
|
"""Tag should be accessible from Facet via related_name."""
|
|
self.assertIn(self.tag, self.facet.tags.all())
|
|
|
|
def test_tag_cascade_on_facet_delete(self):
|
|
"""Deleting a Facet should delete its tags."""
|
|
tag_id = self.tag.id
|
|
self.facet.delete()
|
|
self.assertFalse(Tag.objects.filter(id=tag_id).exists())
|
|
|
|
def test_tag_unique_within_facet(self):
|
|
"""Tag name should be unique within a facet."""
|
|
from django.db import IntegrityError
|
|
with self.assertRaises(IntegrityError):
|
|
Tag.objects.create(facet=self.facet, name='Electronics')
|
|
|
|
def test_same_tag_name_different_facet(self):
|
|
"""Same tag name can exist in different facets."""
|
|
other_facet = Facet.objects.create(name='Other')
|
|
tag = Tag.objects.create(facet=other_facet, name='Electronics')
|
|
self.assertEqual(tag.name, 'Electronics')
|
|
|
|
|
|
class ThingTagTests(AuthTestCase):
|
|
"""Tests for Thing-Tag relationships."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
super().setUp()
|
|
self.box_type = BoxType.objects.create(
|
|
name='Standard Box',
|
|
width=200,
|
|
height=100,
|
|
length=300
|
|
)
|
|
self.box = Box.objects.create(
|
|
id='BOX001',
|
|
box_type=self.box_type
|
|
)
|
|
self.thing = Thing.objects.create(
|
|
name='Arduino Uno',
|
|
box=self.box
|
|
)
|
|
self.facet = Facet.objects.create(
|
|
name='Category',
|
|
color='#667eea',
|
|
cardinality=Facet.Cardinality.MULTIPLE
|
|
)
|
|
self.tag = Tag.objects.create(
|
|
facet=self.facet,
|
|
name='Electronics'
|
|
)
|
|
|
|
def test_thing_can_have_tags(self):
|
|
"""Thing can have tags."""
|
|
self.thing.tags.add(self.tag)
|
|
self.assertIn(self.tag, self.thing.tags.all())
|
|
|
|
def test_thing_can_have_multiple_tags(self):
|
|
"""Thing can have multiple tags."""
|
|
tag2 = Tag.objects.create(facet=self.facet, name='Microcontroller')
|
|
self.thing.tags.add(self.tag, tag2)
|
|
self.assertEqual(self.thing.tags.count(), 2)
|
|
|
|
def test_tag_can_have_multiple_things(self):
|
|
"""Tag can be assigned to multiple things."""
|
|
thing2 = Thing.objects.create(name='Raspberry Pi', box=self.box)
|
|
self.thing.tags.add(self.tag)
|
|
thing2.tags.add(self.tag)
|
|
self.assertEqual(self.tag.things.count(), 2)
|
|
|
|
def test_removing_tag_from_thing(self):
|
|
"""Tag can be removed from thing."""
|
|
self.thing.tags.add(self.tag)
|
|
self.thing.tags.remove(self.tag)
|
|
self.assertNotIn(self.tag, self.thing.tags.all())
|
|
|
|
def test_thing_detail_add_tag(self):
|
|
"""Edit thing page can add a tag via POST."""
|
|
response = self.client.post(
|
|
f'/thing/{self.thing.id}/edit/',
|
|
{'action': 'add_tag', 'tag_id': str(self.tag.id)}
|
|
)
|
|
self.assertRedirects(response, f'/thing/{self.thing.id}/edit/')
|
|
self.thing.refresh_from_db()
|
|
self.assertIn(self.tag, self.thing.tags.all())
|
|
|
|
def test_thing_detail_remove_tag(self):
|
|
"""Edit thing page can remove a tag via POST."""
|
|
self.thing.tags.add(self.tag)
|
|
response = self.client.post(
|
|
f'/thing/{self.thing.id}/edit/',
|
|
{'action': 'remove_tag', 'tag_id': str(self.tag.id)}
|
|
)
|
|
self.assertRedirects(response, f'/thing/{self.thing.id}/edit/')
|
|
self.thing.refresh_from_db()
|
|
self.assertNotIn(self.tag, self.thing.tags.all())
|
|
|
|
def test_thing_detail_shows_available_tags(self):
|
|
"""Edit thing page should show available tags to add."""
|
|
response = self.client.get(f'/thing/{self.thing.id}/edit/')
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, 'Electronics')
|
|
|
|
def test_thing_detail_shows_current_tags(self):
|
|
"""Thing detail page should show thing's current tags."""
|
|
self.thing.tags.add(self.tag)
|
|
response = self.client.get(f'/thing/{self.thing.id}/')
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, 'Electronics')
|
|
|
|
def test_single_cardinality_facet_replaces_tag(self):
|
|
"""Adding tag from single-cardinality facet replaces existing tag."""
|
|
single_facet = Facet.objects.create(
|
|
name='Priority',
|
|
cardinality=Facet.Cardinality.SINGLE
|
|
)
|
|
tag_low = Tag.objects.create(facet=single_facet, name='Low')
|
|
tag_high = Tag.objects.create(facet=single_facet, name='High')
|
|
|
|
self.thing.tags.add(tag_low)
|
|
response = self.client.post(
|
|
f'/thing/{self.thing.id}/edit/',
|
|
{'action': 'add_tag', 'tag_id': str(tag_high.id)}
|
|
)
|
|
self.assertRedirects(response, f'/thing/{self.thing.id}/edit/')
|
|
self.thing.refresh_from_db()
|
|
self.assertNotIn(tag_low, self.thing.tags.all())
|
|
self.assertIn(tag_high, self.thing.tags.all())
|
|
|
|
def test_search_api_includes_tags(self):
|
|
"""Search API results should include tags."""
|
|
self.thing.tags.add(self.tag)
|
|
response = self.client.get('/search/api/?q=ard')
|
|
self.assertEqual(response.status_code, 200)
|
|
results = response.json()['results']
|
|
self.assertEqual(len(results), 1)
|
|
self.assertEqual(len(results[0]['tags']), 1)
|
|
self.assertEqual(results[0]['tags'][0]['name'], 'Electronics')
|
|
|
|
def test_search_api_searches_by_tag_name(self):
|
|
"""Search API should find things by tag name."""
|
|
self.thing.tags.add(self.tag)
|
|
response = self.client.get('/search/api/?q=Electronics')
|
|
self.assertEqual(response.status_code, 200)
|
|
results = response.json()['results']
|
|
self.assertEqual(len(results), 1)
|
|
self.assertEqual(results[0]['name'], 'Arduino Uno')
|
|
|
|
def test_search_api_facet_colon_tag_format(self):
|
|
"""Search API should support Facet:Tag search format."""
|
|
self.thing.tags.add(self.tag)
|
|
response = self.client.get('/search/api/?q=Category:Electronics')
|
|
self.assertEqual(response.status_code, 200)
|
|
results = response.json()['results']
|
|
self.assertEqual(len(results), 1)
|
|
self.assertEqual(results[0]['name'], 'Arduino Uno')
|
|
|
|
|
|
class MarkdownRenderingTests(TestCase):
|
|
"""Tests for Markdown rendering in templates."""
|
|
|
|
def test_render_markdown_basic(self):
|
|
"""render_markdown filter should convert basic Markdown to HTML."""
|
|
from boxes.templatetags.dict_extras import render_markdown
|
|
result = render_markdown('**bold** and *italic*')
|
|
self.assertIn('<strong>bold</strong>', result)
|
|
self.assertIn('<em>italic</em>', result)
|
|
|
|
def test_render_markdown_links(self):
|
|
"""render_markdown filter should convert links."""
|
|
from boxes.templatetags.dict_extras import render_markdown
|
|
result = render_markdown('[Example](https://example.com)')
|
|
self.assertIn('href="https://example.com"', result)
|
|
self.assertIn('target="_blank"', result)
|
|
self.assertIn('rel="noopener noreferrer"', result)
|
|
|
|
def test_render_markdown_code(self):
|
|
"""render_markdown filter should convert code blocks."""
|
|
from boxes.templatetags.dict_extras import render_markdown
|
|
result = render_markdown('`inline code`')
|
|
self.assertIn('<code>inline code</code>', result)
|
|
|
|
def test_render_markdown_lists(self):
|
|
"""render_markdown filter should convert lists."""
|
|
from boxes.templatetags.dict_extras import render_markdown
|
|
result = render_markdown('- item 1\n- item 2')
|
|
self.assertIn('<ul>', result)
|
|
self.assertIn('<li>item 1</li>', result)
|
|
|
|
def test_render_markdown_sanitizes_script(self):
|
|
"""render_markdown filter should sanitize script tags."""
|
|
from boxes.templatetags.dict_extras import render_markdown
|
|
result = render_markdown('<script>alert("xss")</script>')
|
|
self.assertNotIn('<script>', result)
|
|
self.assertNotIn('</script>', result)
|
|
|
|
def test_render_markdown_empty_string(self):
|
|
"""render_markdown filter should handle empty strings."""
|
|
from boxes.templatetags.dict_extras import render_markdown
|
|
result = render_markdown('')
|
|
self.assertEqual(result, '')
|
|
|
|
def test_render_markdown_none(self):
|
|
"""render_markdown filter should handle None."""
|
|
from boxes.templatetags.dict_extras import render_markdown
|
|
result = render_markdown(None)
|
|
self.assertEqual(result, '')
|
|
|
|
def test_render_markdown_tables(self):
|
|
"""render_markdown filter should convert tables."""
|
|
from boxes.templatetags.dict_extras import render_markdown
|
|
md = '| A | B |\n|---|---|\n| 1 | 2 |'
|
|
result = render_markdown(md)
|
|
self.assertIn('<table>', result)
|
|
self.assertIn('<th>', result)
|
|
self.assertIn('<td>', result)
|
|
|
|
def test_truncate_markdown_basic(self):
|
|
"""truncate_markdown filter should truncate and strip Markdown."""
|
|
from boxes.templatetags.dict_extras import truncate_markdown
|
|
result = truncate_markdown('**bold** text here', 10)
|
|
self.assertNotIn('**', result)
|
|
self.assertNotIn('<strong>', result)
|
|
|
|
def test_truncate_markdown_long_text(self):
|
|
"""truncate_markdown filter should add ellipsis for long text."""
|
|
from boxes.templatetags.dict_extras import truncate_markdown
|
|
long_text = 'This is a very long text that should be truncated'
|
|
result = truncate_markdown(long_text, 20)
|
|
self.assertTrue(result.endswith('...'))
|
|
self.assertLessEqual(len(result), 25)
|
|
|
|
def test_truncate_markdown_empty(self):
|
|
"""truncate_markdown filter should handle empty strings."""
|
|
from boxes.templatetags.dict_extras import truncate_markdown
|
|
result = truncate_markdown('')
|
|
self.assertEqual(result, '')
|
|
|
|
def test_truncate_markdown_short_text(self):
|
|
"""truncate_markdown filter should not truncate short text."""
|
|
from boxes.templatetags.dict_extras import truncate_markdown
|
|
result = truncate_markdown('Short', 100)
|
|
self.assertEqual(result, 'Short')
|
|
self.assertNotIn('...', result)
|
|
|
|
|
|
class MarkdownInViewsTests(AuthTestCase):
|
|
"""Tests for Markdown rendering in views."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
super().setUp()
|
|
self.box_type = BoxType.objects.create(
|
|
name='Standard Box',
|
|
width=200,
|
|
height=100,
|
|
length=300
|
|
)
|
|
self.box = Box.objects.create(
|
|
id='BOX001',
|
|
box_type=self.box_type
|
|
)
|
|
|
|
def test_thing_detail_renders_markdown(self):
|
|
"""Thing detail page should render Markdown in description."""
|
|
thing = Thing.objects.create(
|
|
name='Test Item',
|
|
box=self.box,
|
|
description='**Bold text** and *italic*'
|
|
)
|
|
response = self.client.get(f'/thing/{thing.id}/')
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, '<strong>Bold text</strong>')
|
|
self.assertContains(response, '<em>italic</em>')
|
|
|
|
def test_thing_detail_renders_markdown_links(self):
|
|
"""Thing detail page should render Markdown links with target blank."""
|
|
thing = Thing.objects.create(
|
|
name='Test Item',
|
|
box=self.box,
|
|
description='Check [this link](https://example.com)'
|
|
)
|
|
response = self.client.get(f'/thing/{thing.id}/')
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, 'href="https://example.com"')
|
|
self.assertContains(response, 'target="_blank"')
|
|
|
|
def test_thing_detail_sanitizes_html(self):
|
|
"""Thing detail page should sanitize dangerous HTML in description."""
|
|
thing = Thing.objects.create(
|
|
name='Test Item',
|
|
box=self.box,
|
|
description='<script>alert("xss")</script>Normal text'
|
|
)
|
|
response = self.client.get(f'/thing/{thing.id}/')
|
|
self.assertEqual(response.status_code, 200)
|
|
# The description should not contain script tags (they are stripped)
|
|
# Note: The base template has a <script> tag for jQuery, so we check
|
|
# specifically that the malicious script content is not executed
|
|
self.assertContains(response, 'Normal text')
|
|
# Check the markdown-content div doesn't have script tags
|
|
self.assertNotContains(response, '<script>alert')
|
|
|
|
def test_box_detail_truncates_markdown(self):
|
|
"""Box detail page should show truncated plain text description."""
|
|
thing = Thing.objects.create(
|
|
name='Test Item',
|
|
box=self.box,
|
|
description='**Bold** and a very long description that should be truncated in the box detail view'
|
|
)
|
|
response = self.client.get(f'/box/{self.box.id}/')
|
|
self.assertEqual(response.status_code, 200)
|
|
# Should not contain raw Markdown syntax
|
|
self.assertNotContains(response, '**Bold**')
|
|
# Should contain the plain text (truncated)
|
|
self.assertContains(response, 'Bold')
|
|
|
|
def test_search_api_strips_markdown(self):
|
|
"""Search API should return plain text description."""
|
|
thing = Thing.objects.create(
|
|
name='Searchable Item',
|
|
box=self.box,
|
|
description='**Bold text** in description'
|
|
)
|
|
response = self.client.get('/search/api/?q=Searchable')
|
|
self.assertEqual(response.status_code, 200)
|
|
results = response.json()['results']
|
|
self.assertEqual(len(results), 1)
|
|
# Description should be plain text without Markdown
|
|
self.assertNotIn('**', results[0]['description'])
|
|
self.assertIn('Bold text', results[0]['description'])
|