From a31305b3309a9aa05c8e948670ae789dda140629 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Nov 2025 13:01:31 +0000 Subject: [PATCH 1/3] Initial plan From d31bdaaab8e0699cdc4f023f734259d5ce156c04 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Nov 2025 13:10:14 +0000 Subject: [PATCH 2/3] Add comprehensive unit tests for AI deletion manager and DeletionRequest model Co-authored-by: dawnsystem <42047891+dawnsystem@users.noreply.github.com> --- .../tests/test_ai_deletion_manager.py | 582 +++++++++++++++ .../tests/test_deletion_request_model.py | 699 ++++++++++++++++++ 2 files changed, 1281 insertions(+) create mode 100644 src/documents/tests/test_ai_deletion_manager.py create mode 100644 src/documents/tests/test_deletion_request_model.py diff --git a/src/documents/tests/test_ai_deletion_manager.py b/src/documents/tests/test_ai_deletion_manager.py new file mode 100644 index 000000000..0a5502e2b --- /dev/null +++ b/src/documents/tests/test_ai_deletion_manager.py @@ -0,0 +1,582 @@ +""" +Unit tests for AI Deletion Manager (ai_deletion_manager.py) + +Tests cover: +- create_deletion_request() with impact analysis +- _analyze_impact() with different document scenarios +- format_deletion_request_for_user() with various scenarios +- get_pending_requests() with filters +- can_ai_delete_automatically() security constraint +- Complete deletion workflows +- Audit trail and tracking +""" + +from datetime import datetime +from unittest import mock + +from django.contrib.auth.models import User +from django.test import TestCase +from django.utils import timezone + +from documents.ai_deletion_manager import AIDeletionManager +from documents.models import ( + Correspondent, + DeletionRequest, + Document, + DocumentType, + Tag, +) + + +class TestAIDeletionManagerCreateRequest(TestCase): + """Test create_deletion_request() functionality.""" + + def setUp(self): + """Set up test data.""" + self.user = User.objects.create_user(username="testuser", password="testpass") + + # Create test documents with various metadata + self.correspondent = Correspondent.objects.create(name="Test Corp") + self.doc_type = DocumentType.objects.create(name="Invoice") + self.tag1 = Tag.objects.create(name="Important") + self.tag2 = Tag.objects.create(name="2024") + + self.doc1 = Document.objects.create( + title="Test Document 1", + content="Test content 1", + checksum="checksum1", + mime_type="application/pdf", + correspondent=self.correspondent, + document_type=self.doc_type, + ) + self.doc1.tags.add(self.tag1, self.tag2) + + self.doc2 = Document.objects.create( + title="Test Document 2", + content="Test content 2", + checksum="checksum2", + mime_type="application/pdf", + correspondent=self.correspondent, + ) + self.doc2.tags.add(self.tag1) + + def test_create_deletion_request_basic(self): + """Test creating a basic deletion request.""" + documents = [self.doc1, self.doc2] + reason = "Duplicate documents detected" + + request = AIDeletionManager.create_deletion_request( + documents=documents, + reason=reason, + user=self.user, + ) + + self.assertIsNotNone(request) + self.assertIsInstance(request, DeletionRequest) + self.assertEqual(request.ai_reason, reason) + self.assertEqual(request.user, self.user) + self.assertEqual(request.status, DeletionRequest.STATUS_PENDING) + self.assertTrue(request.requested_by_ai) + self.assertEqual(request.documents.count(), 2) + + def test_create_deletion_request_with_impact_analysis(self): + """Test that deletion request includes impact analysis.""" + documents = [self.doc1, self.doc2] + reason = "Test deletion" + + request = AIDeletionManager.create_deletion_request( + documents=documents, + reason=reason, + user=self.user, + ) + + impact = request.impact_summary + self.assertIsNotNone(impact) + self.assertEqual(impact["document_count"], 2) + self.assertIn("documents", impact) + self.assertIn("affected_tags", impact) + self.assertIn("affected_correspondents", impact) + self.assertIn("affected_types", impact) + self.assertIn("date_range", impact) + + def test_create_deletion_request_with_custom_impact(self): + """Test creating request with pre-computed impact analysis.""" + documents = [self.doc1] + reason = "Test deletion" + custom_impact = { + "document_count": 1, + "custom_field": "custom_value", + } + + request = AIDeletionManager.create_deletion_request( + documents=documents, + reason=reason, + user=self.user, + impact_analysis=custom_impact, + ) + + self.assertEqual(request.impact_summary, custom_impact) + self.assertEqual(request.impact_summary["custom_field"], "custom_value") + + def test_create_deletion_request_empty_documents(self): + """Test creating request with empty document list.""" + documents = [] + reason = "Test deletion" + + request = AIDeletionManager.create_deletion_request( + documents=documents, + reason=reason, + user=self.user, + ) + + self.assertIsNotNone(request) + self.assertEqual(request.documents.count(), 0) + self.assertEqual(request.impact_summary["document_count"], 0) + + +class TestAIDeletionManagerAnalyzeImpact(TestCase): + """Test _analyze_impact() functionality.""" + + def setUp(self): + """Set up test data.""" + self.correspondent1 = Correspondent.objects.create(name="Corp A") + self.correspondent2 = Correspondent.objects.create(name="Corp B") + self.doc_type1 = DocumentType.objects.create(name="Invoice") + self.doc_type2 = DocumentType.objects.create(name="Receipt") + self.tag1 = Tag.objects.create(name="Important") + self.tag2 = Tag.objects.create(name="Archive") + self.tag3 = Tag.objects.create(name="2024") + + def test_analyze_impact_single_document(self): + """Test impact analysis for a single document.""" + doc = Document.objects.create( + title="Test Document", + content="Test content", + checksum="checksum1", + mime_type="application/pdf", + correspondent=self.correspondent1, + document_type=self.doc_type1, + ) + doc.tags.add(self.tag1, self.tag2) + + impact = AIDeletionManager._analyze_impact([doc]) + + self.assertEqual(impact["document_count"], 1) + self.assertEqual(len(impact["documents"]), 1) + self.assertEqual(impact["documents"][0]["id"], doc.id) + self.assertEqual(impact["documents"][0]["title"], "Test Document") + self.assertIn("Corp A", impact["affected_correspondents"]) + self.assertIn("Invoice", impact["affected_types"]) + self.assertIn("Important", impact["affected_tags"]) + self.assertIn("Archive", impact["affected_tags"]) + + def test_analyze_impact_multiple_documents(self): + """Test impact analysis for multiple documents.""" + doc1 = Document.objects.create( + title="Document 1", + content="Content 1", + checksum="checksum1", + mime_type="application/pdf", + correspondent=self.correspondent1, + document_type=self.doc_type1, + ) + doc1.tags.add(self.tag1) + + doc2 = Document.objects.create( + title="Document 2", + content="Content 2", + checksum="checksum2", + mime_type="application/pdf", + correspondent=self.correspondent2, + document_type=self.doc_type2, + ) + doc2.tags.add(self.tag2, self.tag3) + + impact = AIDeletionManager._analyze_impact([doc1, doc2]) + + self.assertEqual(impact["document_count"], 2) + self.assertEqual(len(impact["documents"]), 2) + self.assertIn("Corp A", impact["affected_correspondents"]) + self.assertIn("Corp B", impact["affected_correspondents"]) + self.assertIn("Invoice", impact["affected_types"]) + self.assertIn("Receipt", impact["affected_types"]) + self.assertEqual(len(impact["affected_tags"]), 3) + + def test_analyze_impact_document_without_metadata(self): + """Test impact analysis for document without correspondent/type.""" + doc = Document.objects.create( + title="Basic Document", + content="Content", + checksum="checksum1", + mime_type="application/pdf", + ) + + impact = AIDeletionManager._analyze_impact([doc]) + + self.assertEqual(impact["document_count"], 1) + self.assertEqual(impact["documents"][0]["correspondent"], None) + self.assertEqual(impact["documents"][0]["document_type"], None) + self.assertEqual(impact["documents"][0]["tags"], []) + self.assertEqual(len(impact["affected_correspondents"]), 0) + self.assertEqual(len(impact["affected_types"]), 0) + self.assertEqual(len(impact["affected_tags"]), 0) + + def test_analyze_impact_date_range(self): + """Test that date range is properly calculated.""" + # Create documents with different dates + doc1 = Document.objects.create( + title="Old Document", + content="Content", + checksum="checksum1", + mime_type="application/pdf", + ) + # Force set the created date to an earlier time + doc1.created = timezone.make_aware(datetime(2023, 1, 1)) + doc1.save() + + doc2 = Document.objects.create( + title="New Document", + content="Content", + checksum="checksum2", + mime_type="application/pdf", + ) + doc2.created = timezone.make_aware(datetime(2024, 12, 31)) + doc2.save() + + impact = AIDeletionManager._analyze_impact([doc1, doc2]) + + self.assertIsNotNone(impact["date_range"]["earliest"]) + self.assertIsNotNone(impact["date_range"]["latest"]) + # Check that dates are ISO formatted strings + self.assertIn("2023-01-01", impact["date_range"]["earliest"]) + self.assertIn("2024-12-31", impact["date_range"]["latest"]) + + def test_analyze_impact_empty_list(self): + """Test impact analysis with empty document list.""" + impact = AIDeletionManager._analyze_impact([]) + + self.assertEqual(impact["document_count"], 0) + self.assertEqual(len(impact["documents"]), 0) + self.assertEqual(len(impact["affected_correspondents"]), 0) + self.assertEqual(len(impact["affected_types"]), 0) + self.assertEqual(len(impact["affected_tags"]), 0) + + +class TestAIDeletionManagerFormatRequest(TestCase): + """Test format_deletion_request_for_user() functionality.""" + + def setUp(self): + """Set up test data.""" + self.user = User.objects.create_user(username="testuser", password="testpass") + + self.correspondent = Correspondent.objects.create(name="Test Corp") + self.doc_type = DocumentType.objects.create(name="Invoice") + self.tag = Tag.objects.create(name="Important") + + self.doc = Document.objects.create( + title="Test Document", + content="Test content", + checksum="checksum1", + mime_type="application/pdf", + correspondent=self.correspondent, + document_type=self.doc_type, + ) + self.doc.tags.add(self.tag) + + def test_format_deletion_request_basic(self): + """Test basic formatting of deletion request.""" + request = AIDeletionManager.create_deletion_request( + documents=[self.doc], + reason="Test reason for deletion", + user=self.user, + ) + + message = AIDeletionManager.format_deletion_request_for_user(request) + + self.assertIsInstance(message, str) + self.assertIn("AI DELETION REQUEST", message) + self.assertIn("Test reason for deletion", message) + self.assertIn("Test Document", message) + self.assertIn("REQUIRED ACTION", message) + + def test_format_deletion_request_includes_impact_summary(self): + """Test that formatted message includes impact summary.""" + doc2 = Document.objects.create( + title="Document 2", + content="Content 2", + checksum="checksum2", + mime_type="application/pdf", + ) + + request = AIDeletionManager.create_deletion_request( + documents=[self.doc, doc2], + reason="Multiple documents", + user=self.user, + ) + + message = AIDeletionManager.format_deletion_request_for_user(request) + + self.assertIn("Number of documents: 2", message) + self.assertIn("Test Corp", message) + self.assertIn("Invoice", message) + self.assertIn("Important", message) + + def test_format_deletion_request_with_no_metadata(self): + """Test formatting when documents have no metadata.""" + doc = Document.objects.create( + title="Basic Document", + content="Content", + checksum="checksum1", + mime_type="application/pdf", + ) + + request = AIDeletionManager.create_deletion_request( + documents=[doc], + reason="Test deletion", + user=self.user, + ) + + message = AIDeletionManager.format_deletion_request_for_user(request) + + self.assertIn("Basic Document", message) + self.assertIn("None", message) # Should show None for missing metadata + + def test_format_deletion_request_shows_security_warning(self): + """Test that formatted message emphasizes user approval requirement.""" + request = AIDeletionManager.create_deletion_request( + documents=[self.doc], + reason="Test", + user=self.user, + ) + + message = AIDeletionManager.format_deletion_request_for_user(request) + + self.assertIn("explicit approval", message.lower()) + self.assertIn("no files will be deleted until you confirm", message.lower()) + + +class TestAIDeletionManagerGetPendingRequests(TestCase): + """Test get_pending_requests() functionality.""" + + def setUp(self): + """Set up test data.""" + self.user1 = User.objects.create_user(username="user1", password="pass1") + self.user2 = User.objects.create_user(username="user2", password="pass2") + + self.doc = Document.objects.create( + title="Test Document", + content="Content", + checksum="checksum1", + mime_type="application/pdf", + ) + + def test_get_pending_requests_for_user(self): + """Test getting pending requests for a specific user.""" + # Create requests for user1 + req1 = AIDeletionManager.create_deletion_request( + documents=[self.doc], + reason="Reason 1", + user=self.user1, + ) + req2 = AIDeletionManager.create_deletion_request( + documents=[self.doc], + reason="Reason 2", + user=self.user1, + ) + + # Create request for user2 + AIDeletionManager.create_deletion_request( + documents=[self.doc], + reason="Reason 3", + user=self.user2, + ) + + pending = AIDeletionManager.get_pending_requests(self.user1) + + self.assertEqual(len(pending), 2) + self.assertIn(req1, pending) + self.assertIn(req2, pending) + + def test_get_pending_requests_excludes_approved(self): + """Test that approved requests are not returned.""" + req1 = AIDeletionManager.create_deletion_request( + documents=[self.doc], + reason="Reason 1", + user=self.user1, + ) + req2 = AIDeletionManager.create_deletion_request( + documents=[self.doc], + reason="Reason 2", + user=self.user1, + ) + + # Approve one request + req1.approve(self.user1, "Approved") + + pending = AIDeletionManager.get_pending_requests(self.user1) + + self.assertEqual(len(pending), 1) + self.assertNotIn(req1, pending) + self.assertIn(req2, pending) + + def test_get_pending_requests_excludes_rejected(self): + """Test that rejected requests are not returned.""" + req1 = AIDeletionManager.create_deletion_request( + documents=[self.doc], + reason="Reason 1", + user=self.user1, + ) + req2 = AIDeletionManager.create_deletion_request( + documents=[self.doc], + reason="Reason 2", + user=self.user1, + ) + + # Reject one request + req1.reject(self.user1, "Rejected") + + pending = AIDeletionManager.get_pending_requests(self.user1) + + self.assertEqual(len(pending), 1) + self.assertNotIn(req1, pending) + self.assertIn(req2, pending) + + def test_get_pending_requests_empty(self): + """Test getting pending requests when none exist.""" + pending = AIDeletionManager.get_pending_requests(self.user1) + + self.assertEqual(len(pending), 0) + + +class TestAIDeletionManagerSecurityConstraints(TestCase): + """Test security constraints and AI deletion prevention.""" + + def test_can_ai_delete_automatically_always_false(self): + """Test that AI can never delete automatically.""" + # This is a critical security test + result = AIDeletionManager.can_ai_delete_automatically() + + self.assertFalse(result) + + def test_deletion_request_requires_pending_status(self): + """Test that all new deletion requests start as pending.""" + user = User.objects.create_user(username="testuser", password="pass") + doc = Document.objects.create( + title="Test", + content="Content", + checksum="checksum1", + mime_type="application/pdf", + ) + + request = AIDeletionManager.create_deletion_request( + documents=[doc], + reason="Test", + user=user, + ) + + self.assertEqual(request.status, DeletionRequest.STATUS_PENDING) + + def test_deletion_request_marked_as_ai_initiated(self): + """Test that deletion requests are marked as AI-initiated.""" + user = User.objects.create_user(username="testuser", password="pass") + doc = Document.objects.create( + title="Test", + content="Content", + checksum="checksum1", + mime_type="application/pdf", + ) + + request = AIDeletionManager.create_deletion_request( + documents=[doc], + reason="Test", + user=user, + ) + + self.assertTrue(request.requested_by_ai) + + +class TestAIDeletionManagerWorkflow(TestCase): + """Test complete deletion workflow.""" + + def setUp(self): + """Set up test data.""" + self.user = User.objects.create_user(username="testuser", password="pass") + self.approver = User.objects.create_user(username="approver", password="pass") + + self.doc1 = Document.objects.create( + title="Document 1", + content="Content 1", + checksum="checksum1", + mime_type="application/pdf", + ) + self.doc2 = Document.objects.create( + title="Document 2", + content="Content 2", + checksum="checksum2", + mime_type="application/pdf", + ) + + def test_complete_approval_workflow(self): + """Test complete workflow from creation to approval.""" + # Step 1: Create deletion request + request = AIDeletionManager.create_deletion_request( + documents=[self.doc1, self.doc2], + reason="Duplicates detected", + user=self.user, + ) + + self.assertEqual(request.status, DeletionRequest.STATUS_PENDING) + self.assertIsNone(request.reviewed_at) + self.assertIsNone(request.reviewed_by) + + # Step 2: Approve request + success = request.approve(self.approver, "Looks good") + + self.assertTrue(success) + self.assertEqual(request.status, DeletionRequest.STATUS_APPROVED) + self.assertIsNotNone(request.reviewed_at) + self.assertEqual(request.reviewed_by, self.approver) + self.assertEqual(request.review_comment, "Looks good") + + def test_complete_rejection_workflow(self): + """Test complete workflow from creation to rejection.""" + # Step 1: Create deletion request + request = AIDeletionManager.create_deletion_request( + documents=[self.doc1], + reason="Should be deleted", + user=self.user, + ) + + self.assertEqual(request.status, DeletionRequest.STATUS_PENDING) + + # Step 2: Reject request + success = request.reject(self.approver, "Not a duplicate") + + self.assertTrue(success) + self.assertEqual(request.status, DeletionRequest.STATUS_REJECTED) + self.assertIsNotNone(request.reviewed_at) + self.assertEqual(request.reviewed_by, self.approver) + self.assertEqual(request.review_comment, "Not a duplicate") + + def test_workflow_audit_trail(self): + """Test that workflow maintains complete audit trail.""" + request = AIDeletionManager.create_deletion_request( + documents=[self.doc1], + reason="Test deletion", + user=self.user, + ) + + # Record initial state + created_at = request.created_at + self.assertIsNotNone(created_at) + + # Approve + request.approve(self.approver, "Approved") + + # Verify audit trail + self.assertIsNotNone(request.created_at) + self.assertIsNotNone(request.updated_at) + self.assertIsNotNone(request.reviewed_at) + self.assertEqual(request.reviewed_by, self.approver) + self.assertTrue(request.requested_by_ai) + self.assertEqual(request.user, self.user) diff --git a/src/documents/tests/test_deletion_request_model.py b/src/documents/tests/test_deletion_request_model.py new file mode 100644 index 000000000..ed1ce5974 --- /dev/null +++ b/src/documents/tests/test_deletion_request_model.py @@ -0,0 +1,699 @@ +""" +Unit tests for DeletionRequest Model + +Tests cover: +- Model creation and field validation +- approve() method with different states +- reject() method with different states +- Status transitions and constraints +- Complete workflow scenarios +- Audit trail validation +- Model relationships and data integrity +""" + +from django.contrib.auth.models import User +from django.test import TestCase +from django.utils import timezone + +from documents.models import ( + Correspondent, + DeletionRequest, + Document, + DocumentType, + Tag, +) + + +class TestDeletionRequestModelCreation(TestCase): + """Test DeletionRequest model creation and basic functionality.""" + + def setUp(self): + """Set up test data.""" + self.user = User.objects.create_user(username="testuser", password="pass") + self.doc = Document.objects.create( + title="Test Document", + content="Content", + checksum="checksum1", + mime_type="application/pdf", + ) + + def test_create_deletion_request_basic(self): + """Test creating a basic deletion request.""" + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test reason", + user=self.user, + status=DeletionRequest.STATUS_PENDING, + ) + + self.assertIsNotNone(request) + self.assertTrue(request.requested_by_ai) + self.assertEqual(request.ai_reason, "Test reason") + self.assertEqual(request.user, self.user) + self.assertEqual(request.status, DeletionRequest.STATUS_PENDING) + + def test_deletion_request_auto_timestamps(self): + """Test that timestamps are automatically set.""" + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test", + user=self.user, + ) + + self.assertIsNotNone(request.created_at) + self.assertIsNotNone(request.updated_at) + + def test_deletion_request_default_status(self): + """Test that default status is pending.""" + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test", + user=self.user, + ) + + self.assertEqual(request.status, DeletionRequest.STATUS_PENDING) + + def test_deletion_request_with_documents(self): + """Test adding documents to deletion request.""" + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test", + user=self.user, + ) + + doc2 = Document.objects.create( + title="Document 2", + content="Content 2", + checksum="checksum2", + mime_type="application/pdf", + ) + + request.documents.add(self.doc, doc2) + + self.assertEqual(request.documents.count(), 2) + self.assertIn(self.doc, request.documents.all()) + self.assertIn(doc2, request.documents.all()) + + def test_deletion_request_impact_summary_default(self): + """Test that impact_summary defaults to empty dict.""" + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test", + user=self.user, + ) + + self.assertIsInstance(request.impact_summary, dict) + self.assertEqual(request.impact_summary, {}) + + def test_deletion_request_impact_summary_json(self): + """Test storing JSON data in impact_summary.""" + impact = { + "document_count": 5, + "affected_tags": ["tag1", "tag2"], + "metadata": {"key": "value"}, + } + + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test", + user=self.user, + impact_summary=impact, + ) + + self.assertEqual(request.impact_summary["document_count"], 5) + self.assertEqual(request.impact_summary["affected_tags"], ["tag1", "tag2"]) + + def test_deletion_request_str_representation(self): + """Test string representation of deletion request.""" + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test", + user=self.user, + ) + request.documents.add(self.doc) + + str_repr = str(request) + + self.assertIn("Deletion Request", str_repr) + self.assertIn(str(request.id), str_repr) + self.assertIn("1 documents", str_repr) + self.assertIn("pending", str_repr) + + +class TestDeletionRequestApprove(TestCase): + """Test approve() method functionality.""" + + def setUp(self): + """Set up test data.""" + self.user = User.objects.create_user(username="user1", password="pass") + self.approver = User.objects.create_user(username="approver", password="pass") + self.doc = Document.objects.create( + title="Test Document", + content="Content", + checksum="checksum1", + mime_type="application/pdf", + ) + + def test_approve_pending_request(self): + """Test approving a pending request.""" + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test", + user=self.user, + status=DeletionRequest.STATUS_PENDING, + ) + + result = request.approve(self.approver, "Approved") + + self.assertTrue(result) + self.assertEqual(request.status, DeletionRequest.STATUS_APPROVED) + self.assertEqual(request.reviewed_by, self.approver) + self.assertIsNotNone(request.reviewed_at) + self.assertEqual(request.review_comment, "Approved") + + def test_approve_with_empty_comment(self): + """Test approving without a comment.""" + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test", + user=self.user, + status=DeletionRequest.STATUS_PENDING, + ) + + result = request.approve(self.approver) + + self.assertTrue(result) + self.assertEqual(request.review_comment, "") + + def test_approve_already_approved_request(self): + """Test that approving an already approved request fails.""" + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test", + user=self.user, + status=DeletionRequest.STATUS_APPROVED, + reviewed_by=self.user, + reviewed_at=timezone.now(), + ) + + result = request.approve(self.approver, "Trying to approve again") + + self.assertFalse(result) + self.assertEqual(request.reviewed_by, self.user) # Should not change + + def test_approve_rejected_request(self): + """Test that approving a rejected request fails.""" + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test", + user=self.user, + status=DeletionRequest.STATUS_REJECTED, + reviewed_by=self.user, + reviewed_at=timezone.now(), + ) + + result = request.approve(self.approver, "Trying to approve rejected") + + self.assertFalse(result) + self.assertEqual(request.status, DeletionRequest.STATUS_REJECTED) + + def test_approve_cancelled_request(self): + """Test that approving a cancelled request fails.""" + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test", + user=self.user, + status=DeletionRequest.STATUS_CANCELLED, + ) + + result = request.approve(self.approver, "Trying to approve cancelled") + + self.assertFalse(result) + self.assertEqual(request.status, DeletionRequest.STATUS_CANCELLED) + + def test_approve_completed_request(self): + """Test that approving a completed request fails.""" + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test", + user=self.user, + status=DeletionRequest.STATUS_COMPLETED, + ) + + result = request.approve(self.approver, "Trying to approve completed") + + self.assertFalse(result) + self.assertEqual(request.status, DeletionRequest.STATUS_COMPLETED) + + def test_approve_sets_timestamp(self): + """Test that approve() sets the reviewed_at timestamp.""" + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test", + user=self.user, + status=DeletionRequest.STATUS_PENDING, + ) + + before_approval = timezone.now() + result = request.approve(self.approver, "Approved") + after_approval = timezone.now() + + self.assertTrue(result) + self.assertIsNotNone(request.reviewed_at) + self.assertGreaterEqual(request.reviewed_at, before_approval) + self.assertLessEqual(request.reviewed_at, after_approval) + + +class TestDeletionRequestReject(TestCase): + """Test reject() method functionality.""" + + def setUp(self): + """Set up test data.""" + self.user = User.objects.create_user(username="user1", password="pass") + self.reviewer = User.objects.create_user(username="reviewer", password="pass") + + def test_reject_pending_request(self): + """Test rejecting a pending request.""" + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test", + user=self.user, + status=DeletionRequest.STATUS_PENDING, + ) + + result = request.reject(self.reviewer, "Not necessary") + + self.assertTrue(result) + self.assertEqual(request.status, DeletionRequest.STATUS_REJECTED) + self.assertEqual(request.reviewed_by, self.reviewer) + self.assertIsNotNone(request.reviewed_at) + self.assertEqual(request.review_comment, "Not necessary") + + def test_reject_with_empty_comment(self): + """Test rejecting without a comment.""" + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test", + user=self.user, + status=DeletionRequest.STATUS_PENDING, + ) + + result = request.reject(self.reviewer) + + self.assertTrue(result) + self.assertEqual(request.review_comment, "") + + def test_reject_already_rejected_request(self): + """Test that rejecting an already rejected request fails.""" + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test", + user=self.user, + status=DeletionRequest.STATUS_REJECTED, + reviewed_by=self.user, + reviewed_at=timezone.now(), + ) + + result = request.reject(self.reviewer, "Trying to reject again") + + self.assertFalse(result) + self.assertEqual(request.reviewed_by, self.user) # Should not change + + def test_reject_approved_request(self): + """Test that rejecting an approved request fails.""" + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test", + user=self.user, + status=DeletionRequest.STATUS_APPROVED, + reviewed_by=self.user, + reviewed_at=timezone.now(), + ) + + result = request.reject(self.reviewer, "Trying to reject approved") + + self.assertFalse(result) + self.assertEqual(request.status, DeletionRequest.STATUS_APPROVED) + + def test_reject_cancelled_request(self): + """Test that rejecting a cancelled request fails.""" + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test", + user=self.user, + status=DeletionRequest.STATUS_CANCELLED, + ) + + result = request.reject(self.reviewer, "Trying to reject cancelled") + + self.assertFalse(result) + self.assertEqual(request.status, DeletionRequest.STATUS_CANCELLED) + + def test_reject_completed_request(self): + """Test that rejecting a completed request fails.""" + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test", + user=self.user, + status=DeletionRequest.STATUS_COMPLETED, + ) + + result = request.reject(self.reviewer, "Trying to reject completed") + + self.assertFalse(result) + self.assertEqual(request.status, DeletionRequest.STATUS_COMPLETED) + + def test_reject_sets_timestamp(self): + """Test that reject() sets the reviewed_at timestamp.""" + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test", + user=self.user, + status=DeletionRequest.STATUS_PENDING, + ) + + before_rejection = timezone.now() + result = request.reject(self.reviewer, "Rejected") + after_rejection = timezone.now() + + self.assertTrue(result) + self.assertIsNotNone(request.reviewed_at) + self.assertGreaterEqual(request.reviewed_at, before_rejection) + self.assertLessEqual(request.reviewed_at, after_rejection) + + +class TestDeletionRequestWorkflowScenarios(TestCase): + """Test complete workflow scenarios.""" + + def setUp(self): + """Set up test data.""" + self.user = User.objects.create_user(username="user1", password="pass") + self.approver = User.objects.create_user(username="approver", password="pass") + + self.correspondent = Correspondent.objects.create(name="Test Corp") + self.doc_type = DocumentType.objects.create(name="Invoice") + self.tag = Tag.objects.create(name="Important") + + self.doc1 = Document.objects.create( + title="Document 1", + content="Content 1", + checksum="checksum1", + mime_type="application/pdf", + correspondent=self.correspondent, + document_type=self.doc_type, + ) + self.doc1.tags.add(self.tag) + + self.doc2 = Document.objects.create( + title="Document 2", + content="Content 2", + checksum="checksum2", + mime_type="application/pdf", + ) + + def test_workflow_pending_to_approved(self): + """Test workflow transition from pending to approved.""" + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Duplicate documents", + user=self.user, + status=DeletionRequest.STATUS_PENDING, + impact_summary={"document_count": 2}, + ) + request.documents.add(self.doc1, self.doc2) + + # Verify initial state + self.assertEqual(request.status, DeletionRequest.STATUS_PENDING) + self.assertIsNone(request.reviewed_by) + self.assertIsNone(request.reviewed_at) + + # Approve + success = request.approve(self.approver, "Confirmed duplicates") + + # Verify final state + self.assertTrue(success) + self.assertEqual(request.status, DeletionRequest.STATUS_APPROVED) + self.assertEqual(request.reviewed_by, self.approver) + self.assertIsNotNone(request.reviewed_at) + self.assertEqual(request.review_comment, "Confirmed duplicates") + + def test_workflow_pending_to_rejected(self): + """Test workflow transition from pending to rejected.""" + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Suspected duplicates", + user=self.user, + status=DeletionRequest.STATUS_PENDING, + ) + request.documents.add(self.doc1) + + # Verify initial state + self.assertEqual(request.status, DeletionRequest.STATUS_PENDING) + + # Reject + success = request.reject(self.approver, "Not duplicates") + + # Verify final state + self.assertTrue(success) + self.assertEqual(request.status, DeletionRequest.STATUS_REJECTED) + self.assertEqual(request.reviewed_by, self.approver) + self.assertIsNotNone(request.reviewed_at) + self.assertEqual(request.review_comment, "Not duplicates") + + def test_workflow_cannot_approve_after_rejection(self): + """Test that request cannot be approved after rejection.""" + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test", + user=self.user, + status=DeletionRequest.STATUS_PENDING, + ) + + # Reject first + request.reject(self.user, "Rejected") + self.assertEqual(request.status, DeletionRequest.STATUS_REJECTED) + + # Try to approve + success = request.approve(self.approver, "Changed my mind") + + # Should fail + self.assertFalse(success) + self.assertEqual(request.status, DeletionRequest.STATUS_REJECTED) + + def test_workflow_cannot_reject_after_approval(self): + """Test that request cannot be rejected after approval.""" + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test", + user=self.user, + status=DeletionRequest.STATUS_PENDING, + ) + + # Approve first + request.approve(self.approver, "Approved") + self.assertEqual(request.status, DeletionRequest.STATUS_APPROVED) + + # Try to reject + success = request.reject(self.user, "Changed my mind") + + # Should fail + self.assertFalse(success) + self.assertEqual(request.status, DeletionRequest.STATUS_APPROVED) + + +class TestDeletionRequestAuditTrail(TestCase): + """Test audit trail and tracking functionality.""" + + def setUp(self): + """Set up test data.""" + self.user = User.objects.create_user(username="user1", password="pass") + self.approver = User.objects.create_user(username="approver", password="pass") + + def test_audit_trail_records_creator(self): + """Test that audit trail records the user who needs to approve.""" + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test", + user=self.user, + ) + + self.assertEqual(request.user, self.user) + + def test_audit_trail_records_reviewer(self): + """Test that audit trail records who reviewed the request.""" + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test", + user=self.user, + status=DeletionRequest.STATUS_PENDING, + ) + + request.approve(self.approver, "Approved") + + self.assertEqual(request.reviewed_by, self.approver) + self.assertNotEqual(request.reviewed_by, request.user) + + def test_audit_trail_records_timestamps(self): + """Test that all timestamps are properly recorded.""" + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test", + user=self.user, + ) + + created_at = request.created_at + + # Approve the request + request.approve(self.approver, "Approved") + + # Verify timestamps + self.assertIsNotNone(request.created_at) + self.assertIsNotNone(request.updated_at) + self.assertIsNotNone(request.reviewed_at) + self.assertGreaterEqual(request.reviewed_at, created_at) + + def test_audit_trail_preserves_ai_reason(self): + """Test that AI's original reason is preserved.""" + original_reason = "AI detected duplicates based on content similarity" + + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason=original_reason, + user=self.user, + ) + + # Approve with different comment + request.approve(self.approver, "User confirmed") + + # Original AI reason should be preserved + self.assertEqual(request.ai_reason, original_reason) + self.assertEqual(request.review_comment, "User confirmed") + + def test_audit_trail_completion_details(self): + """Test that completion details can be stored.""" + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test", + user=self.user, + status=DeletionRequest.STATUS_COMPLETED, + completion_details={ + "deleted_count": 5, + "failed_count": 0, + "completed_by": "system", + }, + ) + + self.assertEqual(request.completion_details["deleted_count"], 5) + self.assertEqual(request.completion_details["failed_count"], 0) + + def test_audit_trail_multiple_requests_same_user(self): + """Test audit trail with multiple requests for same user.""" + request1 = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Reason 1", + user=self.user, + ) + + request2 = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Reason 2", + user=self.user, + ) + + # Approve one, reject another + request1.approve(self.approver, "Approved") + request2.reject(self.approver, "Rejected") + + # Verify each has its own audit trail + self.assertEqual(request1.status, DeletionRequest.STATUS_APPROVED) + self.assertEqual(request2.status, DeletionRequest.STATUS_REJECTED) + self.assertEqual(request1.review_comment, "Approved") + self.assertEqual(request2.review_comment, "Rejected") + + +class TestDeletionRequestModelRelationships(TestCase): + """Test model relationships and data integrity.""" + + def setUp(self): + """Set up test data.""" + self.user = User.objects.create_user(username="user1", password="pass") + + def test_user_deletion_cascades_to_requests(self): + """Test that deleting a user deletes their deletion requests.""" + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test", + user=self.user, + ) + + request_id = request.id + self.assertEqual(DeletionRequest.objects.filter(id=request_id).count(), 1) + + # Delete user + self.user.delete() + + # Request should be deleted + self.assertEqual(DeletionRequest.objects.filter(id=request_id).count(), 0) + + def test_document_relationship_many_to_many(self): + """Test many-to-many relationship with documents.""" + doc1 = Document.objects.create( + title="Doc 1", + content="Content", + checksum="checksum1", + mime_type="application/pdf", + ) + doc2 = Document.objects.create( + title="Doc 2", + content="Content", + checksum="checksum2", + mime_type="application/pdf", + ) + + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test", + user=self.user, + ) + + request.documents.add(doc1, doc2) + + self.assertEqual(request.documents.count(), 2) + self.assertEqual(doc1.deletion_requests.count(), 1) + self.assertEqual(doc2.deletion_requests.count(), 1) + + def test_reviewed_by_nullable(self): + """Test that reviewed_by can be null.""" + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test", + user=self.user, + status=DeletionRequest.STATUS_PENDING, + ) + + self.assertIsNone(request.reviewed_by) + + def test_reviewed_by_set_null_on_delete(self): + """Test that reviewed_by is set to null when reviewer is deleted.""" + approver = User.objects.create_user(username="approver", password="pass") + + request = DeletionRequest.objects.create( + requested_by_ai=True, + ai_reason="Test", + user=self.user, + status=DeletionRequest.STATUS_PENDING, + ) + + request.approve(approver, "Approved") + self.assertEqual(request.reviewed_by, approver) + + # Delete approver + approver.delete() + + # Refresh request + request.refresh_from_db() + + # reviewed_by should be null + self.assertIsNone(request.reviewed_by) + # But the request should still exist + self.assertEqual(request.status, DeletionRequest.STATUS_APPROVED) From b86f541a0dfbaca0b20d66641ec3406cd54c23fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Nov 2025 14:03:08 +0000 Subject: [PATCH 3/3] Remove unused mock import from test_ai_deletion_manager.py Co-authored-by: dawnsystem <42047891+dawnsystem@users.noreply.github.com> --- src/documents/tests/test_ai_deletion_manager.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/documents/tests/test_ai_deletion_manager.py b/src/documents/tests/test_ai_deletion_manager.py index 0a5502e2b..00b635ff5 100644 --- a/src/documents/tests/test_ai_deletion_manager.py +++ b/src/documents/tests/test_ai_deletion_manager.py @@ -12,7 +12,6 @@ Tests cover: """ from datetime import datetime -from unittest import mock from django.contrib.auth.models import User from django.test import TestCase