Files
labhelper/boxes/tests.py
Adrian A. Baumann da506221f7
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
Box edit taken out into it's own page; Editing of all fields added
2026-01-05 13:28:10 +01:00

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'])