Files
labhelper/boxes/tests.py
Adrian A. Baumann f6a953158d
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 32s
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 8s
Add form to add multiple things to a box
2025-12-28 22:50:37 +01:00

789 lines
28 KiB
Python

from django.contrib.admin.sites import AdminSite
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, ThingTypeAdmin
from .models import Box, BoxType, Thing, ThingType
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 ThingTypeModelTests(TestCase):
"""Tests for the ThingType model."""
def setUp(self):
"""Set up test fixtures."""
self.thing_type = ThingType.objects.create(name='Electronics')
def test_thing_type_str_returns_name(self):
"""ThingType __str__ should return the name."""
self.assertEqual(str(self.thing_type), 'Electronics')
def test_thing_type_creation(self):
"""ThingType should be created with correct attributes."""
self.assertEqual(self.thing_type.name, 'Electronics')
def test_thing_type_hierarchy(self):
"""ThingType should support parent-child relationships."""
child = ThingType.objects.create(
name='Resistors',
parent=self.thing_type
)
self.assertEqual(child.parent, self.thing_type)
self.assertIn(child, self.thing_type.children.all())
def test_thing_type_is_leaf_node(self):
"""ThingType without children should be a leaf node."""
self.assertTrue(self.thing_type.is_leaf_node())
def test_thing_type_is_not_leaf_with_children(self):
"""ThingType with children should not be a leaf node."""
ThingType.objects.create(name='Capacitors', parent=self.thing_type)
self.assertFalse(self.thing_type.is_leaf_node())
def test_thing_type_ancestors(self):
"""ThingType should return correct ancestors."""
child = ThingType.objects.create(
name='Resistors',
parent=self.thing_type
)
grandchild = ThingType.objects.create(
name='10k Resistors',
parent=child
)
ancestors = list(grandchild.get_ancestors())
self.assertEqual(ancestors, [self.thing_type, child])
def test_thing_type_descendants(self):
"""ThingType should return correct descendants."""
child = ThingType.objects.create(
name='Resistors',
parent=self.thing_type
)
grandchild = ThingType.objects.create(
name='10k Resistors',
parent=child
)
descendants = list(self.thing_type.get_descendants())
self.assertEqual(descendants, [child, grandchild])
def test_thing_type_level(self):
"""ThingType should have correct level in hierarchy."""
self.assertEqual(self.thing_type.level, 0)
child = ThingType.objects.create(
name='Resistors',
parent=self.thing_type
)
self.assertEqual(child.level, 1)
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_type = ThingType.objects.create(name='Electronics')
self.thing = Thing.objects.create(
name='Arduino Uno',
thing_type=self.thing_type,
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.thing_type, self.thing_type)
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',
thing_type=self.thing_type,
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',
thing_type=self.thing_type,
box=self.box
)
Thing.objects.create(
name='Alpha Item',
thing_type=self.thing_type,
box=self.box
)
things = list(Thing.objects.values_list('name', flat=True))
self.assertEqual(things, ['Alpha Item', 'Arduino Uno', 'Zeta Item'])
def test_thing_type_relationship(self):
"""Thing should be accessible from ThingType via related_name."""
self.assertIn(self.thing, self.thing_type.things.all())
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_thing_type_protect_on_delete(self):
"""Deleting a ThingType with things should raise IntegrityError."""
with self.assertRaises(IntegrityError):
self.thing_type.delete()
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 ThingTypeAdminTests(TestCase):
"""Tests for the ThingType admin configuration."""
def setUp(self):
"""Set up test fixtures."""
self.site = AdminSite()
self.admin = ThingTypeAdmin(ThingType, self.site)
def test_search_fields(self):
"""ThingTypeAdmin should search by name."""
self.assertEqual(self.admin.search_fields, ('name',))
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', 'thing_type', 'box')
)
def test_list_filter(self):
"""ThingAdmin should filter by thing_type and box."""
self.assertEqual(self.admin.list_filter, ('thing_type', 'box'))
def test_search_fields(self):
"""ThingAdmin should search by name and description."""
self.assertEqual(self.admin.search_fields, ('name', 'description'))
class IndexViewTests(TestCase):
"""Tests for the index view."""
def setUp(self):
"""Set up test client."""
self.client = Client()
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/')
class BoxDetailViewTests(TestCase):
"""Tests for the box detail view."""
def setUp(self):
"""Set up test fixtures."""
self.client = Client()
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_type = ThingType.objects.create(name='Electronics')
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',
thing_type=self.thing_type,
box=self.box,
description='A microcontroller board'
)
response = self.client.get(f'/box/{self.box.id}/')
self.assertContains(response, 'Arduino Uno')
self.assertContains(response, 'Electronics')
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',
thing_type=self.thing_type,
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',
thing_type=self.thing_type,
box=self.box
)
Thing.objects.create(
name='Item Two',
thing_type=self.thing_type,
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',
thing_type=self.thing_type,
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/')
class ThingDetailViewTests(TestCase):
"""Tests for thing detail view."""
def setUp(self):
"""Set up test fixtures."""
self.client = Client()
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_type = ThingType.objects.create(name='Electronics')
self.thing = Thing.objects.create(
name='Arduino Uno',
thing_type=self.thing_type,
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_type(self):
"""Thing detail page should show thing type."""
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/')
class SearchViewTests(TestCase):
"""Tests for search view."""
def setUp(self):
"""Set up test client."""
self.client = Client()
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"')
class SearchApiTests(TestCase):
"""Tests for search API."""
def setUp(self):
"""Set up test fixtures."""
self.client = Client()
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_type = ThingType.objects.create(name='Electronics')
Thing.objects.create(
name='Arduino Uno',
thing_type=self.thing_type,
box=self.box,
description='A microcontroller board'
)
Thing.objects.create(
name='Raspberry Pi',
thing_type=self.thing_type,
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',
thing_type=self.thing_type,
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)
self.assertLessEqual(len(results[0]['description']), 100)
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}',
thing_type=self.thing_type,
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_type_and_box(self):
"""Search API results should include type 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]['type'], 'Electronics')
self.assertEqual(results[0]['box'], 'BOX001')
class AddThingsViewTests(TestCase):
"""Tests for add things view."""
def setUp(self):
"""Set up test fixtures."""
self.client = Client()
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_type = ThingType.objects.create(name='Electronics')
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 redirect to box detail."""
response = self.client.post(f'/box/{self.box.id}/add/', {
'form-TOTAL_FORMS': '3',
'form-0-name': 'Arduino Uno',
'form-0-thing_type': self.thing_type.id,
'form-0-description': 'A microcontroller',
'form-1-name': 'LED Strip',
'form-1-thing_type': self.thing_type.id,
'form-1-description': 'Lighting component',
})
self.assertRedirects(response, f'/box/{self.box.id}/')
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',
'form-0-thing_type': self.thing_type.id,
'form-1-thing_type': self.thing_type.id,
})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'This field is required')
def test_add_things_post_partial_valid_invalid(self):
"""Partial submission: one valid, one missing name."""
response = self.client.post(f'/box/{self.box.id}/add/', {
'form-TOTAL_FORMS': '2',
'form-0-name': 'Arduino Uno',
'form-0-thing_type': self.thing_type.id,
'form-1-thing_type': self.thing_type.id,
})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'This field is required')
self.assertEqual(Thing.objects.count(), 1)
def test_add_things_creates_thing_types(self):
"""Can create new thing types while adding things."""
new_type = ThingType.objects.create(name='Components')
response = self.client.post(f'/box/{self.box.id}/add/', {
'form-TOTAL_FORMS': '2',
'form-0-name': 'Resistor',
'form-0-thing_type': new_type.id,
'form-1-name': 'Capacitor',
'form-1-thing_type': new_type.id,
})
self.assertRedirects(response, f'/box/{self.box.id}/')
self.assertEqual(Thing.objects.count(), 2)
self.assertEqual(Thing.objects.filter(thing_type=new_type).count(), 2)
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-0-name': '',
'form-0-thing_type': self.thing_type.id,
'form-1-name': '',
'form-1-thing_type': self.thing_type.id,
})
self.assertRedirects(response, f'/box/{self.box.id}/')
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."""
self.thing_type_2 = ThingType.objects.create(name='Mechanical')
response = self.client.post(f'/box/{self.box.id}/add/', {
'form-TOTAL_FORMS': '3',
'form-0-name': 'Bolt',
'form-0-thing_type': self.thing_type_2.id,
'form-1-name': 'Nut',
'form-1-thing_type': self.thing_type_2.id,
'form-2-name': 'Washer',
'form-2-thing_type': self.thing_type_2.id,
})
self.assertRedirects(response, f'/box/{self.box.id}/')
things = Thing.objects.all()
for thing in things:
self.assertEqual(thing.box, self.box)