import json import tempfile import zipfile from datetime import date, timedelta from io import BytesIO from django.contrib.auth.models import User from django.core.management import call_command from django.test import TestCase from django.urls import reverse from django.core.files.uploadedfile import SimpleUploadedFile from django.utils import timezone from openpyxl import Workbook, load_workbook from .forms import DeviceForm from .import_export import import_workbook from .models import AuditLog, ChoiceValue, Device, Employee, FieldDefinition, InventoryAPIKey, NumberSequence, UserPreference, UserProfile class InventoryTestCase(TestCase): @classmethod def setUpTestData(cls): call_command("seed_initial_data", verbosity=0) cls.status = ChoiceValue.objects.get(kind=ChoiceValue.Kind.STATUS, label="Eingang") cls.done = ChoiceValue.objects.get(kind=ChoiceValue.Kind.STATUS, label="Fertig") cls.device_type = ChoiceValue.objects.get(kind=ChoiceValue.Kind.DEVICE_TYPE, label="Laptop") cls.employee = Employee.objects.get(code="JH") cls.admin = User.objects.create_user("admin", password="strong-test-password", is_superuser=True, is_staff=True) cls.editor = User.objects.create_user("editor", password="strong-test-password") cls.editor.profile.role = UserProfile.Role.EDITOR cls.editor.profile.save() cls.viewer = User.objects.create_user("viewer", password="strong-test-password") def make_device(self, **kwargs): defaults = {"status": self.status, "model": "Lenovo ThinkPad P50", "cpu": "Intel Core i5-8250U"} defaults.update(kwargs) return Device.objects.create(**defaults) def test_device_number_is_automatic_unique_and_not_reused_after_archive(self): first = self.make_device() second = self.make_device() self.assertEqual(second.number, first.number + 1) first.archived_at = first.updated_at first.save() third = self.make_device() self.assertEqual(third.number, second.number + 1) self.assertEqual(Device.objects.values("number").distinct().count(), 3) def test_imported_high_number_advances_sequence(self): imported = self.make_device(number=3068) following = self.make_device() self.assertEqual(following.number, 3069) def test_quick_add_creates_device_and_audit(self): self.client.force_login(self.editor) response = self.client.post(reverse("inventory:quick-add"), {"number": 25, "quantity": 1, "shelf": "A1", "status": self.status.pk, "date": "19.09.2026", "device_type": self.device_type.pk, "employee": self.employee.pk, "donor": "Testspender"}) self.assertEqual(response.status_code, 302) device = Device.objects.get(shelf="A1") self.assertEqual(device.number, 25) self.assertEqual(device.date, date(2026, 9, 19)) self.assertEqual(device.device_type, self.device_type) self.assertEqual(device.employee, self.employee) self.assertEqual(device.donor, "Testspender") self.assertTrue(AuditLog.objects.filter(device=device, action=AuditLog.Action.CREATE).exists()) self.assertEqual(response["Location"], reverse("inventory:quick-add")) duplicate = self.client.post(reverse("inventory:quick-add"), {"number": 25, "quantity": 1, "shelf": "A2", "status": self.status.pk, "date": "19.09.2026", "device_type": "", "employee": self.employee.pk, "donor": ""}) self.assertEqual(duplicate.status_code, 200) self.assertContains(duplicate, "bereits vergeben") self.assertEqual(Device.objects.filter(number=25).count(), 1) def test_quick_add_prefills_next_number_and_today(self): self.make_device(number=40) self.client.force_login(self.editor) response = self.client.get(reverse("inventory:quick-add")) self.assertContains(response, 'name="number"') self.assertContains(response, 'value="41"') self.assertContains(response, timezone.localdate().strftime("%d.%m.%Y")) self.assertContains(response, "TT.MM.JJJJ") def test_quick_add_shows_type_fields_and_saves_hardware(self): self.client.force_login(self.editor) page = self.client.get(reverse("inventory:quick-add")) self.assertContains(page, 'data-device-type-form') self.assertContains(page, 'data-hardware-field="ram_gb"') self.assertContains(page, 'id="device-type-fields"') response = self.client.post(reverse("inventory:quick-add"), { "number": 45, "quantity": 2, "status": self.status.pk, "date": "28.09.2026", "device_type": self.device_type.pk, "employee": self.employee.pk, "model": "ThinkPad T14", "cpu": "Ryzen 5", "ram_gb": "16", "ssd_gb": "512", "battery": "YES", "wifi": "YES", "lan": "NO", "ports": "USB-C, HDMI", }) self.assertEqual(response.status_code, 302) devices = list(Device.objects.filter(number__in=(45, 46)).order_by("number")) self.assertEqual(len(devices), 2) self.assertTrue(all(device.ram_gb == 16 and device.ssd_gb == 512 for device in devices)) self.assertTrue(all(device.ports == "USB-C, HDMI" and device.model == "ThinkPad T14" for device in devices)) printer = ChoiceValue.objects.get(kind=ChoiceValue.Kind.DEVICE_TYPE, label="Drucker") response = self.client.post(reverse("inventory:quick-add"), { "number": 47, "quantity": 1, "status": self.status.pk, "date": "28.09.2026", "device_type": printer.pk, "employee": self.employee.pk, "print_technology": "Laser", "duplex": "YES", "color_printing": "NO", "ram_gb": "999", }) self.assertEqual(response.status_code, 302) device = Device.objects.get(number=47) self.assertEqual(device.print_technology, "Laser") self.assertIsNone(device.ram_gb) def test_quick_add_creates_consecutive_device_batch(self): self.client.force_login(self.editor) response = self.client.post(reverse("inventory:quick-add"), { "number": 50, "quantity": 4, "shelf": "B2", "status": self.status.pk, "date": "19.09.2026", "device_type": self.device_type.pk, "employee": self.employee.pk, "donor": "Serienspender", }) self.assertEqual(response.status_code, 302) devices = list(Device.objects.filter(number__range=(50, 53)).order_by("number")) self.assertEqual([device.number for device in devices], [50, 51, 52, 53]) self.assertTrue(all(device.device_type == self.device_type for device in devices)) self.assertTrue(all(device.employee == self.employee for device in devices)) self.assertTrue(all(device.donor == "Serienspender" for device in devices)) self.assertEqual(AuditLog.objects.filter(device__in=devices, action=AuditLog.Action.CREATE).count(), 4) self.assertEqual(NumberSequence.suggested_device_number(), 54) def test_quick_add_batch_rejects_number_range_conflict(self): self.make_device(number=62) self.client.force_login(self.editor) response = self.client.post(reverse("inventory:quick-add"), { "number": 60, "quantity": 5, "shelf": "", "status": self.status.pk, "date": "19.09.2026", "device_type": "", "employee": self.employee.pk, "donor": "", }) self.assertEqual(response.status_code, 200) self.assertContains(response, "Gerätenummer 62 ist bereits vergeben") self.assertFalse(Device.objects.filter(number__in=[60, 61, 63, 64]).exists()) def test_viewer_cannot_create_or_inline_edit(self): device = self.make_device(ram_gb=4) self.client.force_login(self.viewer) self.assertEqual(self.client.get(reverse("inventory:quick-add")).status_code, 403) response = self.client.post(reverse("inventory:inline-update", args=[device.pk]), data=json.dumps({"field": "ram_gb", "value": 8}), content_type="application/json") self.assertEqual(response.status_code, 403) device.refresh_from_db() self.assertEqual(device.ram_gb, 4) def test_quick_add_requires_employee(self): self.client.force_login(self.editor) response = self.client.post(reverse("inventory:quick-add"), { "number": 70, "quantity": 1, "shelf": "", "status": self.status.pk, "date": "19.09.2026", "device_type": self.device_type.pk, "employee": "", "donor": "", }) self.assertEqual(response.status_code, 200) self.assertContains(response, "Bitte einen Bearbeiter auswählen") self.assertFalse(Device.objects.filter(number=70).exists()) def test_inline_edit_validates_updates_and_audits(self): device = self.make_device(ram_gb=4) self.client.force_login(self.editor) response = self.client.post( reverse("inventory:inline-update", args=[device.pk]), data=json.dumps({"field": "ram_gb", "value": "8", "expected_updated_at": device.updated_at.isoformat()}), content_type="application/json", ) self.assertEqual(response.status_code, 200) device.refresh_from_db() self.assertEqual(device.ram_gb, 8) self.assertTrue(AuditLog.objects.filter(device=device, field="RAM", old_value="4", new_value="8").exists()) def test_inline_date_accepts_and_displays_german_format(self): device = self.make_device(date=date(2026, 9, 1)) self.client.force_login(self.editor) response = self.client.post( reverse("inventory:inline-update", args=[device.pk]), data=json.dumps({"field": "date", "value": "19.09.2026", "expected_updated_at": device.updated_at.isoformat()}), content_type="application/json", ) self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["display"], "19.09.2026") self.assertEqual(response.json()["value"], "2026-09-19") device.refresh_from_db() self.assertEqual(device.date, date(2026, 9, 19)) def test_editor_can_bulk_update_status_and_recipient(self): recipient = ChoiceValue.objects.get(kind=ChoiceValue.Kind.RECIPIENT, label="NGO") first = self.make_device(number=7201) second = self.make_device(number=7202) untouched = self.make_device(number=7203) self.client.force_login(self.editor) response = self.client.post(reverse("inventory:bulk-update-devices"), { "device_ids": [first.pk, second.pk], "status": self.done.pk, "recipient": recipient.pk, "return_query": f"status={self.status.pk}&sort=number&dir=asc", }) self.assertEqual(response.status_code, 302) self.assertIn(f"status={self.status.pk}", response["Location"]) first.refresh_from_db(); second.refresh_from_db(); untouched.refresh_from_db() self.assertEqual((first.status, second.status), (self.done, self.done)) self.assertEqual((first.recipient, second.recipient), (recipient, recipient)) self.assertEqual(untouched.status, self.status) self.assertIsNone(untouched.recipient) self.assertEqual(AuditLog.objects.filter(device__in=[first, second], field="Status").count(), 2) self.assertEqual(AuditLog.objects.filter(device__in=[first, second], field="Empfänger").count(), 2) def test_viewer_cannot_bulk_update_devices(self): device = self.make_device(number=7210) self.client.force_login(self.viewer) response = self.client.post(reverse("inventory:bulk-update-devices"), { "device_ids": [device.pk], "status": self.done.pk, }) self.assertEqual(response.status_code, 403) device.refresh_from_db() self.assertEqual(device.status, self.status) def test_detail_navigation_respects_current_filters_and_sorting(self): first = self.make_device(number=7301, status=self.done) second = self.make_device(number=7302, status=self.done) self.make_device(number=7303, status=self.status) self.client.force_login(self.editor) response = self.client.get(reverse("inventory:device-detail", args=[first.pk]), { "status": self.done.pk, "sort": "number", "dir": "asc", }) self.assertEqual(response.status_code, 200) self.assertIsNone(response.context["previous_device_id"]) self.assertEqual(response.context["next_device_id"], second.pk) self.assertEqual(response.context["navigation_position"], 1) self.assertEqual(response.context["navigation_total"], 2) self.assertContains(response, f"/devices/{second.pk}/?status={self.done.pk}") def test_editor_can_revert_a_single_change_from_history(self): device = self.make_device(ram_gb=4) self.client.force_login(self.editor) self.client.post( reverse("inventory:inline-update", args=[device.pk]), data=json.dumps({"field": "ram_gb", "value": "8", "expected_updated_at": device.updated_at.isoformat()}), content_type="application/json", ) change = AuditLog.objects.get(device=device, field="RAM", old_value="4", new_value="8") response = self.client.post(reverse("inventory:audit-revert", args=[change.pk])) self.assertEqual(response.status_code, 302) device.refresh_from_db() change.refresh_from_db() self.assertEqual(device.ram_gb, 4) self.assertIsNotNone(change.reverted_at) self.assertEqual(change.reverted_by, self.editor) self.assertTrue(AuditLog.objects.filter(device=device, field_key="ram_gb", old_value="8", new_value="4").exists()) def test_old_change_is_not_reverted_after_same_field_changed_again(self): device = self.make_device(ram_gb=4) self.client.force_login(self.editor) first = self.client.post( reverse("inventory:inline-update", args=[device.pk]), data=json.dumps({"field": "ram_gb", "value": "8", "expected_updated_at": device.updated_at.isoformat()}), content_type="application/json", ) device.refresh_from_db() old_change = AuditLog.objects.get(device=device, field="RAM", old_value="4", new_value="8") self.client.post( reverse("inventory:inline-update", args=[device.pk]), data=json.dumps({"field": "ram_gb", "value": "16", "expected_updated_at": device.updated_at.isoformat()}), content_type="application/json", ) self.client.post(reverse("inventory:audit-revert", args=[old_change.pk])) device.refresh_from_db() old_change.refresh_from_db() self.assertEqual(first.status_code, 200) self.assertEqual(device.ram_gb, 16) self.assertIsNone(old_change.reverted_at) def test_optimistic_concurrency_returns_conflict(self): device = self.make_device(ram_gb=4) stale = device.updated_at.isoformat() device.ram_gb = 8 device.save() self.client.force_login(self.editor) response = self.client.post(reverse("inventory:inline-update", args=[device.pk]), data=json.dumps({"field": "ram_gb", "value": 16, "expected_updated_at": stale}), content_type="application/json") self.assertEqual(response.status_code, 409) def test_search_matches_only_device_number_prefix(self): self.make_device(number=1234, model="Lenovo ThinkPad P50", ram_gb=16, status=self.done, device_type=self.device_type) self.make_device(number=1299, model="Dell OptiPlex", ram_gb=4) self.make_device(number=9800, model="Another ThinkPad", ram_gb=16) self.client.force_login(self.viewer) response = self.client.get(reverse("inventory:device-list"), {"q": "12", "ram_min": "8", "sort": "ram_gb", "dir": "asc"}) self.assertContains(response, "ThinkPad P50") self.assertNotContains(response, "Dell OptiPlex") response = self.client.get(reverse("inventory:device-list"), {"q": "ThinkPad"}) self.assertNotContains(response, "ThinkPad P50") self.assertNotContains(response, "Another ThinkPad") exact = self.client.get(reverse("inventory:device-list"), {"q": "12", "exact": "1"}) self.assertNotContains(exact, "ThinkPad P50") exact = self.client.get(reverse("inventory:device-list"), {"q": "1234", "exact": "1"}) self.assertContains(exact, "ThinkPad P50") self.assertNotContains(exact, "Dell OptiPlex") def test_cpu_autocomplete_uses_masterlist_and_ranks_likely_match_first(self): self.client.force_login(self.viewer) intel = self.client.get(reverse("inventory:autocomplete", args=["cpu"]), {"q": "i5 8500"}) amd = self.client.get(reverse("inventory:autocomplete", args=["cpu"]), {"q": "ryzen 5 5600x"}) self.assertEqual(intel.status_code, 200) self.assertEqual(intel.json()["results"][0]["label"], "Intel Core i5-8500") self.assertEqual(amd.json()["results"][0]["label"], "AMD Ryzen 5 5600X") self.assertGreaterEqual(len(intel.json()["results"]), 2) def test_cpu_vendor_family_and_free_text_filters_can_be_combined(self): self.make_device(number=7101, cpu="Intel Core i5-8500", model="INTEL-I5-TREFFER") self.make_device(number=7102, cpu="Intel Core i7-8700", model="INTEL-I7") self.make_device(number=7103, cpu="AMD Ryzen 5 5600X", model="AMD-R5-TREFFER") self.make_device(number=7104, cpu="AMD Ryzen 9 5950X", model="AMD-R9") self.client.force_login(self.viewer) amd = self.client.get(reverse("inventory:device-list"), {"cpu_vendor": "amd", "cpu_family": "amd_ryzen_5"}) self.assertContains(amd, "AMD-R5-TREFFER") self.assertNotContains(amd, "AMD-R9") self.assertNotContains(amd, "INTEL-I5-TREFFER") intel = self.client.get(reverse("inventory:device-list"), {"cpu_vendor": "intel", "cpu_family": ["intel_i5", "intel_i7"], "cpu_contains": "i5 8500"}) self.assertContains(intel, "INTEL-I5-TREFFER") self.assertNotContains(intel, "INTEL-I7") self.assertContains(intel, "data-cpu-autocomplete") def test_overview_aggregates_recent_devices_and_is_available_to_viewers(self): today = timezone.localdate() month_index = today.year * 12 + today.month - 1 - 3 previous_period_date = date(month_index // 12, month_index % 12 + 1, 1) desktop = ChoiceValue.objects.get(kind=ChoiceValue.Kind.DEVICE_TYPE, label="Desktop") self.make_device(date=today, device_type=self.device_type) self.make_device(date=today, device_type=self.device_type, status=self.done) self.make_device(date=today, device_type=desktop) self.make_device(date=previous_period_date, device_type=desktop) self.client.force_login(self.viewer) response = self.client.get(reverse("inventory:overview")) self.assertEqual(response.status_code, 200) self.assertEqual(response.context["period_months"], 3) self.assertEqual(response.context["period_count"], 3) self.assertEqual(response.context["period_laptops"], 2) self.assertEqual(response.context["period_finished"], 1) self.assertEqual(len(response.context["month_series"]), 12) self.assertContains(response, "Geräteeingänge") self.assertContains(response, "Datenvollständigkeit") def test_device_form_uses_german_labels(self): form = DeviceForm() self.assertEqual(form.fields["device_type"].label, "Typ") self.assertEqual(form.fields["employee"].label, "Bearbeiter") self.assertEqual(form.fields["comment"].label, "Kommentar") def test_archive_and_restore(self): device = self.make_device() self.client.force_login(self.editor) self.client.post(reverse("inventory:archive-device", args=[device.pk])) device.refresh_from_db() self.assertIsNotNone(device.archived_at) self.client.force_login(self.admin) self.client.post(reverse("inventory:restore-device", args=[device.pk])) device.refresh_from_db() self.assertIsNone(device.archived_at) self.assertEqual(list(device.audit_entries.values_list("action", flat=True))[:2], [AuditLog.Action.RESTORE, AuditLog.Action.ARCHIVE]) def test_custom_field_saved_without_migration(self): definition = FieldDefinition.objects.create(name="Windows-Version", key="windows-version", field_type=FieldDefinition.FieldType.TEXT, required=True) device = self.make_device() self.client.force_login(self.editor) form_data = { "shelf": "", "status": self.status.pk, "date": "2026-09-19", "recipient": "", "location": "", "ticket": "", "device_type": "", "price": "", "battery": "UNKNOWN", "wifi": "UNKNOWN", "lan": "UNKNOWN", "display_inches": "", "ram_gb": "", "ssd_gb": "", "hdd_gb": "", "cpu": "", "model": "", "employee": "", "donor": "", "comment": "", "custom_windows-version": "Windows 11", "expected_updated_at": device.updated_at.isoformat(), } response = self.client.post(reverse("inventory:device-detail", args=[device.pk]), form_data) self.assertEqual(response.status_code, 302) device.refresh_from_db() self.assertEqual(device.custom_values[definition.key], "Windows 11") def test_excel_import_transaction_and_number_preservation(self): workbook = Workbook() sheet = workbook.active sheet.append(["Nr.", "Status", "Modell", "Bearbeiter"]) sheet.append([4200, "Eingang", "Lenovo ThinkPad", "RG"]) buffer = BytesIO() workbook.save(buffer) buffer.seek(0) result = import_workbook(buffer, {0: "number", 1: "status", 2: "model", 3: "employee"}, self.admin) self.assertEqual(result["created"], 1) imported = Device.objects.get(number=4200) self.assertEqual(imported.employee.code, "RG") self.assertEqual(self.make_device().number, 4201) def test_excel_import_rolls_back_on_invalid_row(self): workbook = Workbook() sheet = workbook.active sheet.append(["Nr.", "Status", "Bearbeiter"]) sheet.append([5001, "Eingang", "RG"]) sheet.append([5002, "Eingang", "UNBEKANNT"]) buffer = BytesIO(); workbook.save(buffer); buffer.seek(0) with self.assertRaises(ValueError): import_workbook(buffer, {0: "number", 1: "status", 2: "employee"}, self.admin) self.assertFalse(Device.objects.filter(number__in=[5001, 5002]).exists()) def test_excel_upload_mapping_workflow(self): workbook = Workbook(); sheet = workbook.active sheet.append(["Nr.", "Status", "Modell"]); sheet.append([7001, "Eingang", "Importgerät"]) buffer = BytesIO(); workbook.save(buffer) upload = SimpleUploadedFile("geraete.xlsx", buffer.getvalue(), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") self.client.force_login(self.admin) with tempfile.TemporaryDirectory() as media_root, self.settings(MEDIA_ROOT=media_root): response = self.client.post(reverse("inventory:import-excel"), {"file": upload}) self.assertEqual(response.status_code, 302) mapping_url = response["Location"] self.assertEqual(self.client.get(mapping_url).status_code, 200) response = self.client.post(mapping_url, {"column_0": "number", "column_1": "status", "column_2": "model"}) self.assertEqual(response.status_code, 302) self.assertTrue(Device.objects.filter(number=7001, model="Importgerät").exists()) def test_csv_xlsx_and_ods_export(self): self.make_device(model="Exportmodell") self.client.force_login(self.editor) csv_response = self.client.get(reverse("inventory:export-devices", args=["csv"])) self.assertEqual(csv_response.status_code, 200) self.assertIn("Exportmodell", csv_response.content.decode("utf-8-sig")) xlsx = self.client.get(reverse("inventory:export-devices", args=["xlsx"])) workbook = load_workbook(BytesIO(xlsx.content), read_only=True) self.assertEqual(workbook.active["A1"].value, "Nr.") ods = self.client.get(reverse("inventory:export-devices", args=["ods"])) self.assertEqual(ods.status_code, 200) self.assertEqual(ods["Content-Type"], "application/vnd.oasis.opendocument.spreadsheet") with zipfile.ZipFile(BytesIO(ods.content)) as archive: self.assertEqual(archive.namelist()[0], "mimetype") self.assertEqual(archive.read("mimetype"), b"application/vnd.oasis.opendocument.spreadsheet") content = archive.read("content.xml").decode("utf-8") self.assertIn("Exportmodell", content) self.assertIn('table:name="Geräte"', content) def test_admin_can_change_status_color_and_list_uses_it(self): self.make_device(status=self.status) self.client.force_login(self.admin) response = self.client.post(reverse("inventory:choice-edit", args=[self.status.pk]), { "kind": ChoiceValue.Kind.STATUS, "label": self.status.label, "color": "#be185d", "numeric_value": "", "active": "on", "sort_order": self.status.sort_order, }) self.assertEqual(response.status_code, 302) self.status.refresh_from_db() self.assertEqual(self.status.color, "#be185d") device_list = self.client.get(reverse("inventory:device-list")) self.assertContains(device_list, "device-list-page") self.assertContains(device_list, "--status-color:#be185d") self.assertContains(device_list, "Als ODS für LibreOffice") def test_column_order_is_saved_per_user_and_rendered_for_the_list(self): self.client.force_login(self.editor) order = ["status", "number", "model", "cpu"] response = self.client.post( reverse("inventory:save-preferences"), data=json.dumps({"column_order": order}), content_type="application/json", ) self.assertEqual(response.status_code, 200) self.assertEqual(UserPreference.objects.get(user=self.editor).column_order, order) device_list = self.client.get(reverse("inventory:device-list")) self.assertContains(device_list, 'id="server-column-order"') self.assertContains(device_list, "Bearbeiter") for column in [ "number", "shelf", "status", "date", "recipient", "location", "ticket", "device_type", "price", "battery", "wifi", "lan", "display_inches", "ram_gb", "ssd_gb", "hdd_gb", "cpu", "model", "employee", "donor", "comment", ]: self.assertContains(device_list, f'data-column="{column}"') def test_filtered_export_contains_only_matching_date_status_and_type(self): matching = self.make_device(number=8101, date=date(2026, 9, 19), status=self.done, device_type=self.device_type, model="TREFFER") self.make_device(number=8102, date=date(2025, 1, 1), status=self.done, device_type=self.device_type, model="ZU_ALT") self.make_device(number=8103, date=date(2026, 9, 19), status=self.status, device_type=self.device_type, model="FALSCHER_STATUS") self.client.force_login(self.editor) response = self.client.get(reverse("inventory:export-devices", args=["csv"]), { "scope": "filtered", "date_from": "01.09.2026", "date_to": "30.09.2026", "status": self.done.pk, "type": self.device_type.pk, }) content = response.content.decode("utf-8-sig") self.assertEqual(response.status_code, 200) self.assertIn(str(matching.number), content) self.assertIn("TREFFER", content) self.assertNotIn("ZU_ALT", content) self.assertNotIn("FALSCHER_STATUS", content) def test_seed_command_is_idempotent(self): counts = (ChoiceValue.objects.count(), Employee.objects.count()) call_command("seed_initial_data", verbosity=0) self.assertEqual(counts, (ChoiceValue.objects.count(), Employee.objects.count())) def test_screen_and_printer_device_types_are_seeded(self): labels = set(ChoiceValue.objects.filter(kind=ChoiceValue.Kind.DEVICE_TYPE).values_list("label", flat=True)) self.assertTrue({"Bildschirm", "Drucker"}.issubset(labels)) def test_admin_can_create_user_with_role(self): self.client.force_login(self.admin) response = self.client.post(reverse("inventory:user-new"), { "username": "new-editor", "first_name": "Neue", "last_name": "Person", "email": "", "is_active": "on", "role": UserProfile.Role.EDITOR, "password": "A-strong-unique-test-password-2026!", }) self.assertEqual(response.status_code, 302) created = User.objects.get(username="new-editor") self.assertEqual(created.profile.role, UserProfile.Role.EDITOR) self.assertTrue(created.check_password("A-strong-unique-test-password-2026!")) def test_admin_can_add_edit_and_remove_employee(self): self.client.force_login(self.admin) response = self.client.post(reverse("inventory:employee-new"), {"code": "NP", "name": "Neue Person", "active": "on"}) self.assertEqual(response.status_code, 302) employee = Employee.objects.get(code="NP") response = self.client.post(reverse("inventory:employee-edit", args=[employee.pk]), {"code": "NP", "name": "Neuer Name", "active": "on"}) self.assertEqual(response.status_code, 302) employee.refresh_from_db() self.assertEqual(employee.name, "Neuer Name") self.make_device(employee=employee) response = self.client.post(reverse("inventory:employee-remove", args=[employee.pk])) self.assertEqual(response.status_code, 302) employee.refresh_from_db() self.assertFalse(employee.active) def api_header(self, key): return {"HTTP_AUTHORIZATION": f"Api-Key {key}"} def test_api_rejects_anonymous_and_browser_session_access(self): device = self.make_device(number=9101) anonymous = self.client.get(reverse("inventory-api:device-detail", args=[device.number])) self.assertEqual(anonymous.status_code, 401) self.client.force_login(self.admin) browser_session_only = self.client.get(reverse("inventory-api:device-detail", args=[device.number])) self.assertEqual(browser_session_only.status_code, 401) def test_read_only_api_key_can_read_but_not_write(self): device = self.make_device(number=9102, ram_gb=8) _, key = InventoryAPIKey.objects.create_key( name="report-generator", service_user=self.viewer, can_read=True, can_write=False, expiry_date=timezone.now() + timedelta(days=30), ) detail_url = reverse("inventory-api:device-detail", args=[device.number]) response = self.client.get(detail_url, **self.api_header(key)) self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["number"], device.number) response = self.client.patch( detail_url, data=json.dumps({"ram_gb": 16, "expected_updated_at": device.updated_at.isoformat()}), content_type="application/json", **self.api_header(key), ) self.assertEqual(response.status_code, 403) device.refresh_from_db() self.assertEqual(device.ram_gb, 8) def test_write_api_key_updates_device_and_records_client(self): device = self.make_device(number=9103, ram_gb=8, hwid="old") _, key = InventoryAPIKey.objects.create_key( name="hardware-test-team2", service_user=self.editor, can_read=True, can_write=True, expiry_date=timezone.now() + timedelta(days=30), ) response = self.client.patch( reverse("inventory-api:device-detail", args=[device.number]), data=json.dumps({ "ram_gb": 32, "hwid": "abc-123", "expected_updated_at": device.updated_at.isoformat(), }), content_type="application/json", **self.api_header(key), ) self.assertEqual(response.status_code, 200) device.refresh_from_db() self.assertEqual(device.ram_gb, 32) self.assertEqual(device.hwid, "abc-123") logs = AuditLog.objects.filter(device=device, api_client="hardware-test-team2") self.assertEqual(logs.count(), 2) self.assertTrue(all(log.user == self.editor for log in logs)) def test_api_rejects_expired_key_password_fields_and_stale_updates(self): device = self.make_device(number=9104) _, expired_key = InventoryAPIKey.objects.create_key( name="expired", service_user=self.editor, can_read=True, expiry_date=timezone.now() - timedelta(seconds=1), ) detail_url = reverse("inventory-api:device-detail", args=[device.number]) self.assertEqual(self.client.get(detail_url, **self.api_header(expired_key)).status_code, 401) _, key = InventoryAPIKey.objects.create_key( name="secure-writer", service_user=self.editor, can_read=True, can_write=True, expiry_date=timezone.now() + timedelta(days=30), ) rejected = self.client.patch( detail_url, data=json.dumps({"password": "secret", "expected_updated_at": device.updated_at.isoformat()}), content_type="application/json", **self.api_header(key), ) self.assertEqual(rejected.status_code, 400) self.assertIn("unbekannte_felder", rejected.json()) stale_timestamp = device.updated_at.isoformat() device.comment = "andere Änderung" device.save() conflict = self.client.patch( detail_url, data=json.dumps({"ram_gb": 16, "expected_updated_at": stale_timestamp}), content_type="application/json", **self.api_header(key), ) self.assertEqual(conflict.status_code, 409) def test_api_bulk_update_is_atomic(self): first = self.make_device(number=9110) second = self.make_device(number=9111) recipient = ChoiceValue.objects.get(kind=ChoiceValue.Kind.RECIPIENT, label="NGO") _, key = InventoryAPIKey.objects.create_key( name="bulk-team2", service_user=self.editor, can_read=True, can_write=True, expiry_date=timezone.now() + timedelta(days=30), ) response = self.client.post( reverse("inventory-api:device-bulk-update"), data=json.dumps({ "device_numbers": [first.number, second.number], "changes": {"status": self.done.pk, "recipient": recipient.pk}, }), content_type="application/json", **self.api_header(key), ) self.assertEqual(response.status_code, 200) first.refresh_from_db(); second.refresh_from_db() self.assertEqual(first.status, self.done) self.assertEqual(second.recipient, recipient) failed = self.client.post( reverse("inventory-api:device-bulk-update"), data=json.dumps({"device_numbers": [first.number, 999999], "changes": {"ram_gb": 64}}), content_type="application/json", **self.api_header(key), ) self.assertEqual(failed.status_code, 400) first.refresh_from_db() self.assertNotEqual(first.ram_gb, 64)