From 8d44a051f3dd8ff7c0f0a857b515afc88a548acc Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 15 Aug 2025 22:04:33 +0000 Subject: [PATCH 1/6] feat: add document upload and analysis functionality This PR adds the ability for users to upload pictures of documents (like eviction notices) and uses Google Gemini's Vision API to analyze them. ## Features - Users can upload images (png, jpg, jpeg) or PDF documents - Google Gemini Vision API analyzes the documents for eviction notice details - Analysis focuses on identifying deficiencies and issues with notices - Results are seamlessly integrated into the chat history - Files are securely stored temporarily and cleaned up after analysis ## Implementation - Added frontend file upload component in DocumentUpload.tsx - Created backend upload.py module for handling file uploads - Added API route for file upload endpoint in app.py - Added auto-creation of uploads directory in persistent storage - Integrated with existing Google Gemini infrastructure Closes #39 Co-authored-by: yangm2 --- backend/tenantfirstaid/app.py | 6 +- backend/tenantfirstaid/upload.py | 188 ++++++++++++++++++ .../pages/Chat/components/DocumentUpload.tsx | 180 +++++++++++++++++ .../pages/Chat/components/MessageWindow.tsx | 6 + 4 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 backend/tenantfirstaid/upload.py create mode 100644 frontend/src/pages/Chat/components/DocumentUpload.tsx diff --git a/backend/tenantfirstaid/app.py b/backend/tenantfirstaid/app.py index b9cf5e7..2251e53 100644 --- a/backend/tenantfirstaid/app.py +++ b/backend/tenantfirstaid/app.py @@ -14,7 +14,7 @@ load_dotenv(override=True) from .chat import ChatView - +from .upload import UploadView from .session import InitSessionView, TenantSession from .citations import get_citation from .feedback import send_feedback @@ -99,6 +99,10 @@ def clear_session(): "/api/query", view_func=ChatView.as_view("chat", tenant_session), methods=["POST"] ) +app.add_url_rule( + "/api/upload", view_func=UploadView.as_view("upload", tenant_session), methods=["POST"] +) + app.add_url_rule( "/api/citation", endpoint="citation", view_func=get_citation, methods=["GET"] ) diff --git a/backend/tenantfirstaid/upload.py b/backend/tenantfirstaid/upload.py new file mode 100644 index 0000000..2414b62 --- /dev/null +++ b/backend/tenantfirstaid/upload.py @@ -0,0 +1,188 @@ +import os +import uuid +from pathlib import Path +from flask import request, stream_with_context, Response, session, abort +from flask.views import View +from werkzeug.utils import secure_filename +import vertexai +from vertexai.generative_models import GenerativeModel, Part +from google.oauth2 import service_account + +# Constants +UPLOAD_FOLDER = Path(os.getenv("DATA_DIR", "data")) / "uploads" +ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "pdf"} +MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB + +# Create uploads directory if it doesn't exist +UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True) + +DOCUMENT_ANALYSIS_PROMPT = """ +You are a legal expert analyzing a document that may be related to housing law in Oregon. + +Please analyze this document thoroughly and provide: + +1. **Document Type**: What type of document this appears to be (e.g., eviction notice, lease agreement, court summons, etc.) + +2. **Key Information**: Extract and summarize the most important details from the document, such as: + - Property address + - Tenant/landlord names + - Important dates (notice dates, compliance deadlines, court dates) + - Amounts owed (if any) + - Reason for action (if applicable) + +3. **Legal Analysis**: Look for any potential legal issues or deficiencies in the document, especially: + - Missing required information + - Incorrect formatting or language + - Improper service methods mentioned + - Violations of Oregon housing law requirements + - Procedural errors + +4. **Recommendations**: Based on your analysis, provide specific advice on: + - What the tenant should do next + - Any deadlines they need to be aware of + - Potential defenses or challenges to consider + - Whether they should seek legal assistance + +5. **Citations**: Reference relevant Oregon housing laws (ORS statutes) where applicable, and mention if this should comply with any local city ordinances (Portland, Eugene, etc.) + +Focus particularly on finding any technical deficiencies that might invalidate the notice or document. Be thorough but concise in your analysis. + +If you cannot clearly read the document or if it's not related to housing law, please explain what you can see and suggest the user provide a clearer image or confirm the document type. +""" + + +def allowed_file(filename: str) -> bool: + return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS + + +class DocumentAnalyzer: + def __init__(self): + creds = service_account.Credentials.from_service_account_file( + os.getenv( + "GOOGLE_SERVICE_ACCOUNT_CREDENTIALS_FILE", "google-service-account.json" + ) + ) + vertexai.init( + project="tenantfirstaid", + location="us-west1", + credentials=creds, + ) + self.model = GenerativeModel( + model_name=os.getenv("MODEL_NAME", "gemini-2.5-pro"), + system_instruction=DOCUMENT_ANALYSIS_PROMPT, + ) + + def analyze_document(self, file_path: str, stream: bool = False): + """Analyze a document using Google Gemini Vision API.""" + file_path_obj = Path(file_path) + + if not file_path_obj.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + + # Read the file + with open(file_path, "rb") as f: + file_data = f.read() + + # Determine the MIME type based on file extension + file_extension = file_path_obj.suffix.lower() + mime_type_map = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".pdf": "application/pdf", + } + mime_type = mime_type_map.get(file_extension, "application/octet-stream") + + # Create the part for the multimodal input + file_part = Part.from_data(file_data, mime_type=mime_type) + + # Generate content with the document + response = self.model.generate_content([file_part], stream=stream) + + return response + + +class UploadView(View): + def __init__(self, tenant_session): + self.tenant_session = tenant_session + self.analyzer = DocumentAnalyzer() + + def dispatch_request(self, *args, **kwargs) -> Response: + # Check if user has a valid session + if not session.get("site_user"): + abort(403, "Unauthorized: session missing") + + # Check if file is in request + if "file" not in request.files: + abort(400, "No file uploaded") + + file = request.files["file"] + if file.filename == "": + abort(400, "No file selected") + + # Validate file + if not file or not allowed_file(file.filename): + abort(400, "Invalid file type. Please upload PNG, JPG, JPEG, or PDF files.") + + # Check file size + file.seek(0, os.SEEK_END) + file_size = file.tell() + file.seek(0) # Reset file pointer + + if file_size > MAX_FILE_SIZE: + abort(400, "File size exceeds 10MB limit") + + try: + # Save file with unique name + filename = secure_filename(file.filename) + file_id = str(uuid.uuid4()) + file_extension = Path(filename).suffix + unique_filename = f"{file_id}{file_extension}" + file_path = UPLOAD_FOLDER / unique_filename + + file.save(str(file_path)) + + # Analyze the document and stream the response + def generate(): + try: + response_stream = self.analyzer.analyze_document(str(file_path), stream=True) + + assistant_chunks = [] + for event in response_stream: + chunk_text = event.candidates[0].content.parts[0].text + assistant_chunks.append(chunk_text) + yield chunk_text + + # Save the complete response to session + assistant_msg = "".join(assistant_chunks) + current_session = self.tenant_session.get() + current_session["messages"].append( + {"role": "model", "content": assistant_msg} + ) + self.tenant_session.set(current_session) + + except Exception as e: + error_msg = f"Error analyzing document: {str(e)}" + yield error_msg + + finally: + # Clean up: remove the uploaded file + try: + if file_path.exists(): + file_path.unlink() + except Exception as cleanup_error: + print(f"Warning: Could not delete uploaded file: {cleanup_error}") + + return Response( + stream_with_context(generate()), + mimetype="text/plain", + ) + + except Exception as e: + # Clean up file if it was saved + try: + if 'file_path' in locals() and file_path.exists(): + file_path.unlink() + except: + pass + abort(500, f"Upload processing failed: {str(e)}") \ No newline at end of file diff --git a/frontend/src/pages/Chat/components/DocumentUpload.tsx b/frontend/src/pages/Chat/components/DocumentUpload.tsx new file mode 100644 index 0000000..cdab3c3 --- /dev/null +++ b/frontend/src/pages/Chat/components/DocumentUpload.tsx @@ -0,0 +1,180 @@ +import { useRef, useState } from "react"; +import type { IMessage } from "../../../hooks/useMessages"; + +interface Props { + setMessages: React.Dispatch>; + isLoading: boolean; + setIsLoading: React.Dispatch>; +} + +export default function DocumentUpload({ + setMessages, + isLoading, + setIsLoading, +}: Props) { + const [selectedFile, setSelectedFile] = useState(null); + const fileInputRef = useRef(null); + + const handleFileSelect = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + // Validate file type + const allowedTypes = [ + "image/png", + "image/jpeg", + "image/jpg", + "application/pdf", + ]; + if (!allowedTypes.includes(file.type)) { + alert("Please select a PNG, JPG, JPEG, or PDF file."); + return; + } + + // Validate file size (10MB limit) + const maxSize = 10 * 1024 * 1024; // 10MB + if (file.size > maxSize) { + alert("File size must be less than 10MB."); + return; + } + + setSelectedFile(file); + } + }; + + const handleUploadAndAnalyze = async () => { + if (!selectedFile) return; + + const userMessageId = Date.now().toString(); + const botMessageId = (Date.now() + 1).toString(); + + setIsLoading(true); + + // Add user message about document upload + setMessages((prev) => [ + ...prev, + { + role: "user", + content: `I've uploaded a document: ${selectedFile.name}`, + messageId: userMessageId, + }, + ]); + + // Add empty bot message that will be updated + setMessages((prev) => [ + ...prev, + { + role: "model", + content: "", + messageId: botMessageId, + }, + ]); + + try { + const formData = new FormData(); + formData.append("file", selectedFile); + + const response = await fetch("/api/upload", { + method: "POST", + body: formData, + }); + + if (!response.ok) { + throw new Error(`Upload failed: ${response.statusText}`); + } + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error("No response body"); + } + + const decoder = new TextDecoder(); + let fullText = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const chunk = decoder.decode(value); + fullText += chunk; + + // Update only the bot's message + setMessages((prev) => + prev.map((msg) => + msg.messageId === botMessageId + ? { ...msg, content: fullText } + : msg, + ), + ); + } + } catch (error) { + console.error("Upload error:", error); + setMessages((prev) => + prev.map((msg) => + msg.messageId === botMessageId + ? { + ...msg, + content: + "Sorry, I encountered an error while analyzing your document. Please try again.", + } + : msg, + ), + ); + } finally { + setIsLoading(false); + setSelectedFile(null); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } + }; + + return ( +
+
+ + + {selectedFile && ( + + {selectedFile.name} + + )} +
+ + {selectedFile && ( +
+ + +
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/Chat/components/MessageWindow.tsx b/frontend/src/pages/Chat/components/MessageWindow.tsx index 7949af7..f134924 100644 --- a/frontend/src/pages/Chat/components/MessageWindow.tsx +++ b/frontend/src/pages/Chat/components/MessageWindow.tsx @@ -7,6 +7,7 @@ import ExportMessagesButton from "./ExportMessagesButton"; import CitySelectField from "./CitySelectField"; import SuggestedPrompts from "./SuggestedPrompts"; import FeedbackModal from "./FeedbackModal"; +import DocumentUpload from "./DocumentUpload"; interface Props { messages: IMessage[]; @@ -116,6 +117,11 @@ export default function MessageWindow({ value={inputValue} onChange={(e) => setInputValue(e.target.value)} /> +
); -} \ No newline at end of file +} From 12d4eb11f16b675d3041bf0e42f3cd856827573e Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 15 Aug 2025 22:15:33 +0000 Subject: [PATCH 3/6] feat: add comprehensive test suite for upload.py - Test file validation and allowed extensions - Test DocumentAnalyzer initialization and document analysis - Test UploadView request handling, validation, and streaming responses - Test session management and error handling scenarios - Test file cleanup and security measures - Mock Google Cloud dependencies for isolated testing Co-authored-by: yangm2 --- backend/tests/test_upload.py | 387 +++++++++++++++++++++++++++++++++++ 1 file changed, 387 insertions(+) create mode 100644 backend/tests/test_upload.py diff --git a/backend/tests/test_upload.py b/backend/tests/test_upload.py new file mode 100644 index 0000000..748cfb9 --- /dev/null +++ b/backend/tests/test_upload.py @@ -0,0 +1,387 @@ +import os +import tempfile +import uuid +from pathlib import Path +from unittest.mock import mock_open, patch, Mock, MagicMock +from io import BytesIO +import pytest +from flask import Flask +from werkzeug.datastructures import FileStorage +from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError + +from tenantfirstaid.upload import ( + allowed_file, + DocumentAnalyzer, + UploadView, + ALLOWED_EXTENSIONS, + MAX_FILE_SIZE, + UPLOAD_FOLDER, +) +from tenantfirstaid.session import TenantSession + + +class TestAllowedFile: + """Test the allowed_file function.""" + + def test_allowed_file_returns_true_for_valid_extensions(self): + """Test that allowed_file returns True for valid file extensions.""" + for ext in ALLOWED_EXTENSIONS: + filename = f"test.{ext}" + assert allowed_file(filename) is True + + def test_allowed_file_returns_true_for_uppercase_extensions(self): + """Test that allowed_file handles uppercase extensions.""" + for ext in ALLOWED_EXTENSIONS: + filename = f"test.{ext.upper()}" + assert allowed_file(filename) is True + + def test_allowed_file_returns_false_for_invalid_extensions(self): + """Test that allowed_file returns False for invalid extensions.""" + invalid_files = ["test.txt", "test.doc", "test.exe", "test.py"] + for filename in invalid_files: + assert allowed_file(filename) is False + + def test_allowed_file_returns_false_for_no_extension(self): + """Test that allowed_file returns False for files without extensions.""" + assert allowed_file("test") is False + + def test_allowed_file_returns_false_for_empty_filename(self): + """Test that allowed_file handles empty filename.""" + assert allowed_file("") is False + + def test_allowed_file_handles_multiple_dots(self): + """Test that allowed_file correctly handles filenames with multiple dots.""" + assert allowed_file("test.backup.png") is True + assert allowed_file("test.backup.txt") is False + + +class TestDocumentAnalyzer: + """Test the DocumentAnalyzer class.""" + + @pytest.fixture + def mock_service_account(self): + """Mock Google service account credentials.""" + with patch('tenantfirstaid.upload.service_account') as mock_sa: + mock_credentials = Mock() + mock_sa.Credentials.from_service_account_file.return_value = mock_credentials + yield mock_sa, mock_credentials + + @pytest.fixture + def mock_vertexai(self): + """Mock VertexAI initialization.""" + with patch('tenantfirstaid.upload.vertexai') as mock_vertex: + yield mock_vertex + + @pytest.fixture + def mock_generative_model(self): + """Mock GenerativeModel.""" + with patch('tenantfirstaid.upload.GenerativeModel') as mock_model_class: + mock_model = Mock() + mock_model_class.return_value = mock_model + yield mock_model + + @pytest.fixture + def document_analyzer(self, mock_service_account, mock_vertexai, mock_generative_model): + """Create a DocumentAnalyzer instance with mocked dependencies.""" + return DocumentAnalyzer() + + def test_document_analyzer_initialization(self, mock_service_account, mock_vertexai, mock_generative_model): + """Test DocumentAnalyzer initialization.""" + analyzer = DocumentAnalyzer() + + # Verify service account credentials were loaded + mock_sa, _ = mock_service_account + mock_sa.Credentials.from_service_account_file.assert_called_once() + + # Verify VertexAI was initialized + mock_vertexai.init.assert_called_once() + + # Verify model was created + assert analyzer.model is not None + + def test_analyze_document_file_not_found(self, document_analyzer): + """Test analyze_document raises FileNotFoundError for non-existent file.""" + with pytest.raises(FileNotFoundError, match="File not found"): + document_analyzer.analyze_document("/nonexistent/file.png") + + def test_analyze_document_successful_analysis(self, document_analyzer): + """Test successful document analysis.""" + # Create a temporary file + with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as temp_file: + temp_file.write(b'fake image data') + temp_file_path = temp_file.name + + try: + # Mock the model response + mock_response = Mock() + document_analyzer.model.generate_content.return_value = mock_response + + # Test analysis + result = document_analyzer.analyze_document(temp_file_path, stream=False) + + # Verify the model was called + document_analyzer.model.generate_content.assert_called_once() + assert result == mock_response + + finally: + # Clean up + os.unlink(temp_file_path) + + def test_analyze_document_streaming_analysis(self, document_analyzer): + """Test document analysis with streaming enabled.""" + with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as temp_file: + temp_file.write(b'fake pdf data') + temp_file_path = temp_file.name + + try: + mock_response = Mock() + document_analyzer.model.generate_content.return_value = mock_response + + result = document_analyzer.analyze_document(temp_file_path, stream=True) + + # Verify streaming was enabled + call_args = document_analyzer.model.generate_content.call_args + assert call_args[1]['stream'] is True + assert result == mock_response + + finally: + os.unlink(temp_file_path) + + def test_analyze_document_mime_type_mapping(self, document_analyzer): + """Test that correct MIME types are used for different file extensions.""" + test_cases = [ + ('.png', 'image/png'), + ('.jpg', 'image/jpeg'), + ('.jpeg', 'image/jpeg'), + ('.pdf', 'application/pdf'), + ] + + for ext, expected_mime in test_cases: + with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as temp_file: + temp_file.write(b'fake data') + temp_file_path = temp_file.name + + try: + document_analyzer.analyze_document(temp_file_path) + + # Check that Part.from_data was called with correct mime type + call_args = document_analyzer.model.generate_content.call_args + # The Part object would be in the first argument + assert len(call_args[0]) == 1 # Should contain the Part object + + finally: + os.unlink(temp_file_path) + + +class TestUploadView: + """Test the UploadView class.""" + + @pytest.fixture + def app(self): + """Create a Flask app for testing.""" + app = Flask(__name__) + app.secret_key = 'test-secret-key' + app.config['TESTING'] = True + return app + + @pytest.fixture + def mock_tenant_session(self): + """Mock TenantSession.""" + session = Mock(spec=TenantSession) + session.get.return_value = {"messages": []} + return session + + @pytest.fixture + def mock_document_analyzer(self): + """Mock DocumentAnalyzer.""" + with patch('tenantfirstaid.upload.DocumentAnalyzer') as mock_class: + mock_analyzer = Mock() + mock_class.return_value = mock_analyzer + yield mock_analyzer + + @pytest.fixture + def upload_view(self, mock_tenant_session, mock_document_analyzer): + """Create UploadView instance with mocked dependencies.""" + return UploadView(mock_tenant_session) + + def test_upload_view_no_session_returns_403(self, app, upload_view): + """Test that requests without valid session return 403.""" + with app.test_request_context('/upload', method='POST'): + with pytest.raises(Forbidden): + upload_view.dispatch_request() + + def test_upload_view_no_file_returns_400(self, app, upload_view): + """Test that requests without file return 400.""" + with app.test_request_context('/upload', method='POST') as ctx: + ctx.session['site_user'] = 'test_user' + with pytest.raises(BadRequest, match="No file uploaded"): + upload_view.dispatch_request() + + def test_upload_view_empty_filename_returns_400(self, app, upload_view): + """Test that requests with empty filename return 400.""" + with app.test_request_context('/upload', method='POST', data={'file': (BytesIO(b''), '')}) as ctx: + ctx.session['site_user'] = 'test_user' + with pytest.raises(BadRequest, match="No file selected"): + upload_view.dispatch_request() + + def test_upload_view_invalid_file_type_returns_400(self, app, upload_view): + """Test that invalid file types return 400.""" + with app.test_request_context('/upload', method='POST', + data={'file': (BytesIO(b'content'), 'test.txt')}) as ctx: + ctx.session['site_user'] = 'test_user' + with pytest.raises(BadRequest, match="Invalid file type"): + upload_view.dispatch_request() + + def test_upload_view_file_too_large_returns_400(self, app, upload_view): + """Test that files exceeding size limit return 400.""" + # Create file larger than MAX_FILE_SIZE + large_content = b'x' * (MAX_FILE_SIZE + 1) + + with app.test_request_context('/upload', method='POST', + data={'file': (BytesIO(large_content), 'test.png')}) as ctx: + ctx.session['site_user'] = 'test_user' + with pytest.raises(BadRequest, match="File size exceeds 10MB limit"): + upload_view.dispatch_request() + + @patch('tenantfirstaid.upload.UPLOAD_FOLDER') + def test_upload_view_successful_upload_and_analysis(self, mock_upload_folder, app, upload_view, mock_document_analyzer): + """Test successful file upload and analysis.""" + import tempfile + + # Mock upload folder + with tempfile.TemporaryDirectory() as temp_dir: + mock_upload_folder.__truediv__ = lambda self, other: Path(temp_dir) / other + mock_upload_folder.mkdir = Mock() + + # Mock analyzer response + mock_candidate = Mock() + mock_candidate.content.parts = [Mock()] + mock_candidate.content.parts[0].text = "Analysis result chunk" + + mock_event = Mock() + mock_event.candidates = [mock_candidate] + + mock_document_analyzer.analyze_document.return_value = iter([mock_event]) + + file_content = b'fake image data' + + with app.test_request_context('/upload', method='POST', + data={'file': (BytesIO(file_content), 'test.png')}) as ctx: + ctx.session['site_user'] = 'test_user' + + response = upload_view.dispatch_request() + + # Verify response properties + assert response.status_code == 200 + assert response.mimetype == 'text/plain' + + # Verify analyzer was called + mock_document_analyzer.analyze_document.assert_called_once() + + def test_upload_view_analysis_error_handling(self, app, upload_view, mock_document_analyzer): + """Test error handling during document analysis.""" + + # Mock analyzer to raise exception + mock_document_analyzer.analyze_document.side_effect = Exception("Analysis failed") + + with app.test_request_context('/upload', method='POST', + data={'file': (BytesIO(b'content'), 'test.png')}) as ctx: + ctx.session['site_user'] = 'test_user' + + response = upload_view.dispatch_request() + + # Should still return 200 but with error content in stream + assert response.status_code == 200 + + @patch('tenantfirstaid.upload.secure_filename') + @patch('tenantfirstaid.upload.uuid.uuid4') + def test_upload_view_file_naming_and_cleanup(self, mock_uuid, mock_secure_filename, + app, upload_view, mock_document_analyzer): + """Test that files are properly named and cleaned up.""" + + # Mock file naming + mock_uuid.return_value = Mock() + mock_uuid.return_value.__str__ = Mock(return_value='test-uuid-123') + mock_secure_filename.return_value = 'test.png' + + # Mock analyzer + mock_candidate = Mock() + mock_candidate.content.parts = [Mock()] + mock_candidate.content.parts[0].text = "Test analysis" + + mock_event = Mock() + mock_event.candidates = [mock_candidate] + mock_document_analyzer.analyze_document.return_value = iter([mock_event]) + + with app.test_request_context('/upload', method='POST', + data={'file': (BytesIO(b'content'), 'test.png')}) as ctx: + ctx.session['site_user'] = 'test_user' + + response = upload_view.dispatch_request() + + # Verify secure_filename was called + mock_secure_filename.assert_called_once_with('test.png') + + # Verify UUID was generated + mock_uuid.assert_called_once() + + def test_upload_view_session_update(self, app, upload_view, mock_document_analyzer, mock_tenant_session): + """Test that session is properly updated with analysis results.""" + + # Mock session data + session_data = {"messages": []} + mock_tenant_session.get.return_value = session_data + + # Mock analyzer response + mock_candidate = Mock() + mock_candidate.content.parts = [Mock()] + mock_candidate.content.parts[0].text = "Complete analysis text" + + mock_event = Mock() + mock_event.candidates = [mock_candidate] + mock_document_analyzer.analyze_document.return_value = iter([mock_event]) + + with app.test_request_context('/upload', method='POST', + data={'file': (BytesIO(b'content'), 'test.png')}) as ctx: + ctx.session['site_user'] = 'test_user' + + response = upload_view.dispatch_request() + + # Consume the stream to trigger session update + list(response.response) + + # Verify session was updated + mock_tenant_session.set.assert_called_once() + updated_session = mock_tenant_session.set.call_args[0][0] + assert len(updated_session["messages"]) == 1 + assert updated_session["messages"][0]["role"] == "model" + assert updated_session["messages"][0]["content"] == "Complete analysis text" + + +class TestUploadModule: + """Test module-level functionality.""" + + def test_upload_folder_creation(self): + """Test that upload folder is created on module import.""" + # The folder should be created when the module is imported + # This is tested by checking the UPLOAD_FOLDER exists + assert UPLOAD_FOLDER is not None + + def test_constants_are_properly_defined(self): + """Test that module constants are properly defined.""" + assert isinstance(ALLOWED_EXTENSIONS, set) + assert len(ALLOWED_EXTENSIONS) > 0 + assert MAX_FILE_SIZE > 0 + assert isinstance(UPLOAD_FOLDER, Path) + + def test_document_analysis_prompt_is_comprehensive(self): + """Test that the document analysis prompt contains key elements.""" + from tenantfirstaid.upload import DOCUMENT_ANALYSIS_PROMPT + + # Check for key sections + assert "Document Type" in DOCUMENT_ANALYSIS_PROMPT + assert "Key Information" in DOCUMENT_ANALYSIS_PROMPT + assert "Legal Analysis" in DOCUMENT_ANALYSIS_PROMPT + assert "Recommendations" in DOCUMENT_ANALYSIS_PROMPT + assert "Citations" in DOCUMENT_ANALYSIS_PROMPT + assert "Oregon" in DOCUMENT_ANALYSIS_PROMPT \ No newline at end of file From 905954a9b3b662b50e78bcb379203aff914ed15f Mon Sep 17 00:00:00 2001 From: yangm2 Date: Fri, 15 Aug 2025 22:26:56 +0000 Subject: [PATCH 4/6] fix format and lint in generated code --- backend/tests/test_upload.py | 221 +++++++++++++++++++---------------- 1 file changed, 123 insertions(+), 98 deletions(-) diff --git a/backend/tests/test_upload.py b/backend/tests/test_upload.py index 748cfb9..3e05380 100644 --- a/backend/tests/test_upload.py +++ b/backend/tests/test_upload.py @@ -1,13 +1,11 @@ import os import tempfile -import uuid from pathlib import Path -from unittest.mock import mock_open, patch, Mock, MagicMock +from unittest.mock import patch, Mock from io import BytesIO import pytest from flask import Flask -from werkzeug.datastructures import FileStorage -from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError +from werkzeug.exceptions import BadRequest, Forbidden from tenantfirstaid.upload import ( allowed_file, @@ -61,41 +59,47 @@ class TestDocumentAnalyzer: @pytest.fixture def mock_service_account(self): """Mock Google service account credentials.""" - with patch('tenantfirstaid.upload.service_account') as mock_sa: + with patch("tenantfirstaid.upload.service_account") as mock_sa: mock_credentials = Mock() - mock_sa.Credentials.from_service_account_file.return_value = mock_credentials + mock_sa.Credentials.from_service_account_file.return_value = ( + mock_credentials + ) yield mock_sa, mock_credentials @pytest.fixture def mock_vertexai(self): """Mock VertexAI initialization.""" - with patch('tenantfirstaid.upload.vertexai') as mock_vertex: + with patch("tenantfirstaid.upload.vertexai") as mock_vertex: yield mock_vertex @pytest.fixture def mock_generative_model(self): """Mock GenerativeModel.""" - with patch('tenantfirstaid.upload.GenerativeModel') as mock_model_class: + with patch("tenantfirstaid.upload.GenerativeModel") as mock_model_class: mock_model = Mock() mock_model_class.return_value = mock_model yield mock_model @pytest.fixture - def document_analyzer(self, mock_service_account, mock_vertexai, mock_generative_model): + def document_analyzer( + self, mock_service_account, mock_vertexai, mock_generative_model + ): """Create a DocumentAnalyzer instance with mocked dependencies.""" return DocumentAnalyzer() - def test_document_analyzer_initialization(self, mock_service_account, mock_vertexai, mock_generative_model): + def test_document_analyzer_initialization( + self, mock_service_account, mock_vertexai, mock_generative_model + ): """Test DocumentAnalyzer initialization.""" analyzer = DocumentAnalyzer() - + # Verify service account credentials were loaded mock_sa, _ = mock_service_account mock_sa.Credentials.from_service_account_file.assert_called_once() - + # Verify VertexAI was initialized mock_vertexai.init.assert_called_once() - + # Verify model was created assert analyzer.model is not None @@ -107,8 +111,8 @@ def test_analyze_document_file_not_found(self, document_analyzer): def test_analyze_document_successful_analysis(self, document_analyzer): """Test successful document analysis.""" # Create a temporary file - with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as temp_file: - temp_file.write(b'fake image data') + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp_file: + temp_file.write(b"fake image data") temp_file_path = temp_file.name try: @@ -118,7 +122,7 @@ def test_analyze_document_successful_analysis(self, document_analyzer): # Test analysis result = document_analyzer.analyze_document(temp_file_path, stream=False) - + # Verify the model was called document_analyzer.model.generate_content.assert_called_once() assert result == mock_response @@ -129,8 +133,8 @@ def test_analyze_document_successful_analysis(self, document_analyzer): def test_analyze_document_streaming_analysis(self, document_analyzer): """Test document analysis with streaming enabled.""" - with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as temp_file: - temp_file.write(b'fake pdf data') + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as temp_file: + temp_file.write(b"fake pdf data") temp_file_path = temp_file.name try: @@ -138,10 +142,10 @@ def test_analyze_document_streaming_analysis(self, document_analyzer): document_analyzer.model.generate_content.return_value = mock_response result = document_analyzer.analyze_document(temp_file_path, stream=True) - + # Verify streaming was enabled call_args = document_analyzer.model.generate_content.call_args - assert call_args[1]['stream'] is True + assert call_args[1]["stream"] is True assert result == mock_response finally: @@ -150,20 +154,20 @@ def test_analyze_document_streaming_analysis(self, document_analyzer): def test_analyze_document_mime_type_mapping(self, document_analyzer): """Test that correct MIME types are used for different file extensions.""" test_cases = [ - ('.png', 'image/png'), - ('.jpg', 'image/jpeg'), - ('.jpeg', 'image/jpeg'), - ('.pdf', 'application/pdf'), + (".png", "image/png"), + (".jpg", "image/jpeg"), + (".jpeg", "image/jpeg"), + (".pdf", "application/pdf"), ] for ext, expected_mime in test_cases: with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as temp_file: - temp_file.write(b'fake data') + temp_file.write(b"fake data") temp_file_path = temp_file.name try: document_analyzer.analyze_document(temp_file_path) - + # Check that Part.from_data was called with correct mime type call_args = document_analyzer.model.generate_content.call_args # The Part object would be in the first argument @@ -180,8 +184,8 @@ class TestUploadView: def app(self): """Create a Flask app for testing.""" app = Flask(__name__) - app.secret_key = 'test-secret-key' - app.config['TESTING'] = True + app.secret_key = "test-secret-key" + app.config["TESTING"] = True return app @pytest.fixture @@ -194,7 +198,7 @@ def mock_tenant_session(self): @pytest.fixture def mock_document_analyzer(self): """Mock DocumentAnalyzer.""" - with patch('tenantfirstaid.upload.DocumentAnalyzer') as mock_class: + with patch("tenantfirstaid.upload.DocumentAnalyzer") as mock_class: mock_analyzer = Mock() mock_class.return_value = mock_analyzer yield mock_analyzer @@ -206,150 +210,171 @@ def upload_view(self, mock_tenant_session, mock_document_analyzer): def test_upload_view_no_session_returns_403(self, app, upload_view): """Test that requests without valid session return 403.""" - with app.test_request_context('/upload', method='POST'): + with app.test_request_context("/upload", method="POST"): with pytest.raises(Forbidden): upload_view.dispatch_request() def test_upload_view_no_file_returns_400(self, app, upload_view): """Test that requests without file return 400.""" - with app.test_request_context('/upload', method='POST') as ctx: - ctx.session['site_user'] = 'test_user' + with app.test_request_context("/upload", method="POST") as ctx: + ctx.session["site_user"] = "test_user" with pytest.raises(BadRequest, match="No file uploaded"): upload_view.dispatch_request() def test_upload_view_empty_filename_returns_400(self, app, upload_view): """Test that requests with empty filename return 400.""" - with app.test_request_context('/upload', method='POST', data={'file': (BytesIO(b''), '')}) as ctx: - ctx.session['site_user'] = 'test_user' + with app.test_request_context( + "/upload", method="POST", data={"file": (BytesIO(b""), "")} + ) as ctx: + ctx.session["site_user"] = "test_user" with pytest.raises(BadRequest, match="No file selected"): upload_view.dispatch_request() def test_upload_view_invalid_file_type_returns_400(self, app, upload_view): """Test that invalid file types return 400.""" - with app.test_request_context('/upload', method='POST', - data={'file': (BytesIO(b'content'), 'test.txt')}) as ctx: - ctx.session['site_user'] = 'test_user' + with app.test_request_context( + "/upload", method="POST", data={"file": (BytesIO(b"content"), "test.txt")} + ) as ctx: + ctx.session["site_user"] = "test_user" with pytest.raises(BadRequest, match="Invalid file type"): upload_view.dispatch_request() def test_upload_view_file_too_large_returns_400(self, app, upload_view): """Test that files exceeding size limit return 400.""" # Create file larger than MAX_FILE_SIZE - large_content = b'x' * (MAX_FILE_SIZE + 1) - - with app.test_request_context('/upload', method='POST', - data={'file': (BytesIO(large_content), 'test.png')}) as ctx: - ctx.session['site_user'] = 'test_user' + large_content = b"x" * (MAX_FILE_SIZE + 1) + + with app.test_request_context( + "/upload", + method="POST", + data={"file": (BytesIO(large_content), "test.png")}, + ) as ctx: + ctx.session["site_user"] = "test_user" with pytest.raises(BadRequest, match="File size exceeds 10MB limit"): upload_view.dispatch_request() - @patch('tenantfirstaid.upload.UPLOAD_FOLDER') - def test_upload_view_successful_upload_and_analysis(self, mock_upload_folder, app, upload_view, mock_document_analyzer): + @patch("tenantfirstaid.upload.UPLOAD_FOLDER") + def test_upload_view_successful_upload_and_analysis( + self, mock_upload_folder, app, upload_view, mock_document_analyzer + ): """Test successful file upload and analysis.""" import tempfile - + # Mock upload folder with tempfile.TemporaryDirectory() as temp_dir: mock_upload_folder.__truediv__ = lambda self, other: Path(temp_dir) / other mock_upload_folder.mkdir = Mock() - + # Mock analyzer response mock_candidate = Mock() mock_candidate.content.parts = [Mock()] mock_candidate.content.parts[0].text = "Analysis result chunk" - + mock_event = Mock() mock_event.candidates = [mock_candidate] - + mock_document_analyzer.analyze_document.return_value = iter([mock_event]) - - file_content = b'fake image data' - - with app.test_request_context('/upload', method='POST', - data={'file': (BytesIO(file_content), 'test.png')}) as ctx: - ctx.session['site_user'] = 'test_user' - + + file_content = b"fake image data" + + with app.test_request_context( + "/upload", + method="POST", + data={"file": (BytesIO(file_content), "test.png")}, + ) as ctx: + ctx.session["site_user"] = "test_user" + response = upload_view.dispatch_request() - + # Verify response properties assert response.status_code == 200 - assert response.mimetype == 'text/plain' - + assert response.mimetype == "text/plain" + # Verify analyzer was called mock_document_analyzer.analyze_document.assert_called_once() - def test_upload_view_analysis_error_handling(self, app, upload_view, mock_document_analyzer): + def test_upload_view_analysis_error_handling( + self, app, upload_view, mock_document_analyzer + ): """Test error handling during document analysis.""" - + # Mock analyzer to raise exception - mock_document_analyzer.analyze_document.side_effect = Exception("Analysis failed") - - with app.test_request_context('/upload', method='POST', - data={'file': (BytesIO(b'content'), 'test.png')}) as ctx: - ctx.session['site_user'] = 'test_user' - + mock_document_analyzer.analyze_document.side_effect = Exception( + "Analysis failed" + ) + + with app.test_request_context( + "/upload", method="POST", data={"file": (BytesIO(b"content"), "test.png")} + ) as ctx: + ctx.session["site_user"] = "test_user" + response = upload_view.dispatch_request() - + # Should still return 200 but with error content in stream assert response.status_code == 200 - @patch('tenantfirstaid.upload.secure_filename') - @patch('tenantfirstaid.upload.uuid.uuid4') - def test_upload_view_file_naming_and_cleanup(self, mock_uuid, mock_secure_filename, - app, upload_view, mock_document_analyzer): + @patch("tenantfirstaid.upload.secure_filename") + @patch("tenantfirstaid.upload.uuid.uuid4") + def test_upload_view_file_naming_and_cleanup( + self, mock_uuid, mock_secure_filename, app, upload_view, mock_document_analyzer + ): """Test that files are properly named and cleaned up.""" - + # Mock file naming mock_uuid.return_value = Mock() - mock_uuid.return_value.__str__ = Mock(return_value='test-uuid-123') - mock_secure_filename.return_value = 'test.png' - + mock_uuid.return_value.__str__ = Mock(return_value="test-uuid-123") + mock_secure_filename.return_value = "test.png" + # Mock analyzer mock_candidate = Mock() mock_candidate.content.parts = [Mock()] mock_candidate.content.parts[0].text = "Test analysis" - + mock_event = Mock() mock_event.candidates = [mock_candidate] mock_document_analyzer.analyze_document.return_value = iter([mock_event]) - - with app.test_request_context('/upload', method='POST', - data={'file': (BytesIO(b'content'), 'test.png')}) as ctx: - ctx.session['site_user'] = 'test_user' - - response = upload_view.dispatch_request() - + + with app.test_request_context( + "/upload", method="POST", data={"file": (BytesIO(b"content"), "test.png")} + ) as ctx: + ctx.session["site_user"] = "test_user" + + _response = upload_view.dispatch_request() + # Verify secure_filename was called - mock_secure_filename.assert_called_once_with('test.png') - + mock_secure_filename.assert_called_once_with("test.png") + # Verify UUID was generated mock_uuid.assert_called_once() - def test_upload_view_session_update(self, app, upload_view, mock_document_analyzer, mock_tenant_session): + def test_upload_view_session_update( + self, app, upload_view, mock_document_analyzer, mock_tenant_session + ): """Test that session is properly updated with analysis results.""" - + # Mock session data session_data = {"messages": []} mock_tenant_session.get.return_value = session_data - + # Mock analyzer response mock_candidate = Mock() mock_candidate.content.parts = [Mock()] mock_candidate.content.parts[0].text = "Complete analysis text" - + mock_event = Mock() mock_event.candidates = [mock_candidate] mock_document_analyzer.analyze_document.return_value = iter([mock_event]) - - with app.test_request_context('/upload', method='POST', - data={'file': (BytesIO(b'content'), 'test.png')}) as ctx: - ctx.session['site_user'] = 'test_user' - + + with app.test_request_context( + "/upload", method="POST", data={"file": (BytesIO(b"content"), "test.png")} + ) as ctx: + ctx.session["site_user"] = "test_user" + response = upload_view.dispatch_request() - + # Consume the stream to trigger session update list(response.response) - + # Verify session was updated mock_tenant_session.set.assert_called_once() updated_session = mock_tenant_session.set.call_args[0][0] @@ -377,11 +402,11 @@ def test_constants_are_properly_defined(self): def test_document_analysis_prompt_is_comprehensive(self): """Test that the document analysis prompt contains key elements.""" from tenantfirstaid.upload import DOCUMENT_ANALYSIS_PROMPT - + # Check for key sections assert "Document Type" in DOCUMENT_ANALYSIS_PROMPT assert "Key Information" in DOCUMENT_ANALYSIS_PROMPT assert "Legal Analysis" in DOCUMENT_ANALYSIS_PROMPT assert "Recommendations" in DOCUMENT_ANALYSIS_PROMPT assert "Citations" in DOCUMENT_ANALYSIS_PROMPT - assert "Oregon" in DOCUMENT_ANALYSIS_PROMPT \ No newline at end of file + assert "Oregon" in DOCUMENT_ANALYSIS_PROMPT From a9c092c6d900d63ad968ecd9439f5db4383e7822 Mon Sep 17 00:00:00 2001 From: yangm2 Date: Sat, 16 Aug 2025 00:19:41 +0000 Subject: [PATCH 5/6] fix up generated tests; skip 2 broken generated tests --- backend/tests/test_import.py | 9 +- backend/tests/test_upload.py | 225 +++++++++++++++++++---------------- 2 files changed, 129 insertions(+), 105 deletions(-) diff --git a/backend/tests/test_import.py b/backend/tests/test_import.py index 4c37c12..e231fe6 100644 --- a/backend/tests/test_import.py +++ b/backend/tests/test_import.py @@ -1,4 +1,11 @@ -def test_flask_app_startup(): + +def test_flask_app_startup(monkeypatch, tmpdir): + + # in testing environment, DATA_DIR (uploads) is set to a pytest provided + # tmpdir (otherwise importing upload.py fails with a write-permission + # error) + monkeypatch.setenv("DATA_DIR", str(tmpdir)) + from tenantfirstaid.app import app assert app is not None diff --git a/backend/tests/test_upload.py b/backend/tests/test_upload.py index 3e05380..dcf9f07 100644 --- a/backend/tests/test_upload.py +++ b/backend/tests/test_upload.py @@ -6,99 +6,127 @@ import pytest from flask import Flask from werkzeug.exceptions import BadRequest, Forbidden +from collections import namedtuple +from json import dumps +import vertexai -from tenantfirstaid.upload import ( - allowed_file, - DocumentAnalyzer, - UploadView, - ALLOWED_EXTENSIONS, - MAX_FILE_SIZE, - UPLOAD_FOLDER, -) from tenantfirstaid.session import TenantSession +@pytest.fixture +def mock_environ(monkeypatch, tmpdir): + monkeypatch.setenv("DATA_DIR", str(tmpdir)) + + from tenantfirstaid.upload import ( + allowed_file, + DocumentAnalyzer, + UploadView, + ALLOWED_EXTENSIONS, + MAX_FILE_SIZE, + UPLOAD_FOLDER + ) + my_dict = { + "ALLOWED_EXTENSIONS": ALLOWED_EXTENSIONS, + "MAX_FILE_SIZE": MAX_FILE_SIZE, + "allowed_file": allowed_file, + "DocumentAnalyzer": DocumentAnalyzer, + "UploadView": UploadView, + "UPLOAD_FOLDER": UPLOAD_FOLDER, + } + return namedtuple('DictAsObject', my_dict.keys())(**my_dict) + + class TestAllowedFile: """Test the allowed_file function.""" - def test_allowed_file_returns_true_for_valid_extensions(self): + def test_allowed_file_returns_true_for_valid_extensions(self, mock_environ): """Test that allowed_file returns True for valid file extensions.""" - for ext in ALLOWED_EXTENSIONS: + for ext in mock_environ.ALLOWED_EXTENSIONS: filename = f"test.{ext}" - assert allowed_file(filename) is True + assert mock_environ.allowed_file(filename) is True - def test_allowed_file_returns_true_for_uppercase_extensions(self): + def test_allowed_file_returns_true_for_uppercase_extensions(self, mock_environ): """Test that allowed_file handles uppercase extensions.""" - for ext in ALLOWED_EXTENSIONS: + for ext in mock_environ.ALLOWED_EXTENSIONS: filename = f"test.{ext.upper()}" - assert allowed_file(filename) is True + assert mock_environ.allowed_file(filename) is True - def test_allowed_file_returns_false_for_invalid_extensions(self): + def test_allowed_file_returns_false_for_invalid_extensions(self, mock_environ): """Test that allowed_file returns False for invalid extensions.""" invalid_files = ["test.txt", "test.doc", "test.exe", "test.py"] for filename in invalid_files: - assert allowed_file(filename) is False + assert mock_environ.allowed_file(filename) is False - def test_allowed_file_returns_false_for_no_extension(self): + def test_allowed_file_returns_false_for_no_extension(sel, mock_environ): """Test that allowed_file returns False for files without extensions.""" - assert allowed_file("test") is False + assert mock_environ.allowed_file("test") is False - def test_allowed_file_returns_false_for_empty_filename(self): + def test_allowed_file_returns_false_for_empty_filename(self, mock_environ): """Test that allowed_file handles empty filename.""" - assert allowed_file("") is False + assert mock_environ.allowed_file("") is False - def test_allowed_file_handles_multiple_dots(self): + def test_allowed_file_handles_multiple_dots(self, mock_environ): """Test that allowed_file correctly handles filenames with multiple dots.""" - assert allowed_file("test.backup.png") is True - assert allowed_file("test.backup.txt") is False + assert mock_environ.allowed_file("test.backup.png") is True + assert mock_environ.allowed_file("test.backup.txt") is False +@pytest.fixture +def mock_service_account(): + """Mock Google service account credentials.""" + with patch("tenantfirstaid.upload.service_account") as mock_sa: + mock_credentials = Mock() + mock_sa.Credentials.from_service_account_file.return_value = ( + mock_credentials + ) + yield mock_sa, mock_credentials + +@pytest.fixture +def mock_vertexai(mocker, mock_environ): + mock_vertexai_init = mocker.Mock(spec=vertexai) + mocker.patch("tenantfirstaid.chat.vertexai.init", return_value=mock_vertexai_init) + return mock_vertexai_init + +@pytest.fixture +def mock_generative_model(): + """Mock GenerativeModel.""" + with patch("tenantfirstaid.upload.GenerativeModel") as mock_model_class: + mock_model = Mock() + mock_model_class.return_value = mock_model + yield mock_model + +@pytest.fixture +def document_analyzer( + mock_service_account, mock_vertexai, mock_generative_model, mock_environ, tmp_path, monkeypatch +): + """Create a DocumentAnalyzer instance with mocked dependencies.""" + tmp_cred = tmp_path / "service_account.json" + tmp_cred.write_text(dumps({"client_email": "nonsense@nonsense.org", "token_uri": "abc123"})) # Mock service account credentials + monkeypatch.setenv("GOOGLE_SERVICE_ACCOUNT_CREDENTIALS_FILE", str(tmp_cred)) + return mock_environ.DocumentAnalyzer() + +@pytest.fixture +def mock_document_analyzer(mocker, mock_environ): + """Mock DocumentAnalyzer.""" + mock_analyzer = Mock(spec=mock_environ.DocumentAnalyzer) + mock_analyzer.analyze_document = mocker.Mock() + return mock_analyzer + class TestDocumentAnalyzer: """Test the DocumentAnalyzer class.""" - @pytest.fixture - def mock_service_account(self): - """Mock Google service account credentials.""" - with patch("tenantfirstaid.upload.service_account") as mock_sa: - mock_credentials = Mock() - mock_sa.Credentials.from_service_account_file.return_value = ( - mock_credentials - ) - yield mock_sa, mock_credentials - - @pytest.fixture - def mock_vertexai(self): - """Mock VertexAI initialization.""" - with patch("tenantfirstaid.upload.vertexai") as mock_vertex: - yield mock_vertex - - @pytest.fixture - def mock_generative_model(self): - """Mock GenerativeModel.""" - with patch("tenantfirstaid.upload.GenerativeModel") as mock_model_class: - mock_model = Mock() - mock_model_class.return_value = mock_model - yield mock_model - - @pytest.fixture - def document_analyzer( - self, mock_service_account, mock_vertexai, mock_generative_model - ): - """Create a DocumentAnalyzer instance with mocked dependencies.""" - return DocumentAnalyzer() - def test_document_analyzer_initialization( - self, mock_service_account, mock_vertexai, mock_generative_model + document_analyzer, mock_service_account, mock_vertexai, mock_generative_model, mock_environ ): """Test DocumentAnalyzer initialization.""" - analyzer = DocumentAnalyzer() + analyzer = mock_environ.DocumentAnalyzer() # Verify service account credentials were loaded mock_sa, _ = mock_service_account mock_sa.Credentials.from_service_account_file.assert_called_once() - # Verify VertexAI was initialized - mock_vertexai.init.assert_called_once() + # # Verify VertexAI was initialized + # mock_vertexai.init.assert_called_once() # Verify model was created assert analyzer.model is not None @@ -196,17 +224,9 @@ def mock_tenant_session(self): return session @pytest.fixture - def mock_document_analyzer(self): - """Mock DocumentAnalyzer.""" - with patch("tenantfirstaid.upload.DocumentAnalyzer") as mock_class: - mock_analyzer = Mock() - mock_class.return_value = mock_analyzer - yield mock_analyzer - - @pytest.fixture - def upload_view(self, mock_tenant_session, mock_document_analyzer): + def upload_view(self, mock_tenant_session, mock_environ, document_analyzer): """Create UploadView instance with mocked dependencies.""" - return UploadView(mock_tenant_session) + return mock_environ.UploadView(mock_tenant_session) def test_upload_view_no_session_returns_403(self, app, upload_view): """Test that requests without valid session return 403.""" @@ -239,10 +259,10 @@ def test_upload_view_invalid_file_type_returns_400(self, app, upload_view): with pytest.raises(BadRequest, match="Invalid file type"): upload_view.dispatch_request() - def test_upload_view_file_too_large_returns_400(self, app, upload_view): + def test_upload_view_file_too_large_returns_400(self, app, upload_view, mock_environ): """Test that files exceeding size limit return 400.""" # Create file larger than MAX_FILE_SIZE - large_content = b"x" * (MAX_FILE_SIZE + 1) + large_content = b"x" * (mock_environ.MAX_FILE_SIZE + 1) with app.test_request_context( "/upload", @@ -253,45 +273,40 @@ def test_upload_view_file_too_large_returns_400(self, app, upload_view): with pytest.raises(BadRequest, match="File size exceeds 10MB limit"): upload_view.dispatch_request() + @pytest.mark.skip(reason="this test is broken") @patch("tenantfirstaid.upload.UPLOAD_FOLDER") def test_upload_view_successful_upload_and_analysis( - self, mock_upload_folder, app, upload_view, mock_document_analyzer + self, app, upload_view, mock_environ ): """Test successful file upload and analysis.""" - import tempfile - # Mock upload folder - with tempfile.TemporaryDirectory() as temp_dir: - mock_upload_folder.__truediv__ = lambda self, other: Path(temp_dir) / other - mock_upload_folder.mkdir = Mock() - - # Mock analyzer response - mock_candidate = Mock() - mock_candidate.content.parts = [Mock()] - mock_candidate.content.parts[0].text = "Analysis result chunk" + # Mock analyzer response + mock_candidate = Mock() + mock_candidate.content.parts = [Mock()] + mock_candidate.content.parts[0].text = "Analysis result chunk" - mock_event = Mock() - mock_event.candidates = [mock_candidate] + mock_event = Mock() + mock_event.candidates = [mock_candidate] - mock_document_analyzer.analyze_document.return_value = iter([mock_event]) + # mock_document_analyzer.analyze_document.return_value = iter([mock_event]) - file_content = b"fake image data" + file_content = b"fake image data" - with app.test_request_context( - "/upload", - method="POST", - data={"file": (BytesIO(file_content), "test.png")}, - ) as ctx: - ctx.session["site_user"] = "test_user" + with app.test_request_context( + "/upload", + method="POST", + data={"file": (BytesIO(file_content), "test.png")}, + ) as ctx: + ctx.session["site_user"] = "test_user" - response = upload_view.dispatch_request() + response = upload_view.dispatch_request() - # Verify response properties - assert response.status_code == 200 - assert response.mimetype == "text/plain" + # Verify response properties + assert response.status_code == 200 + assert response.mimetype == "text/plain" - # Verify analyzer was called - mock_document_analyzer.analyze_document.assert_called_once() + # Verify analyzer was called + mock_environ.document_analyzer.analyze_document.assert_called_once() def test_upload_view_analysis_error_handling( self, app, upload_view, mock_document_analyzer @@ -347,6 +362,7 @@ def test_upload_view_file_naming_and_cleanup( # Verify UUID was generated mock_uuid.assert_called_once() + @pytest.mark.skip(reason="this test is broken") def test_upload_view_session_update( self, app, upload_view, mock_document_analyzer, mock_tenant_session ): @@ -383,21 +399,22 @@ def test_upload_view_session_update( assert updated_session["messages"][0]["content"] == "Complete analysis text" + class TestUploadModule: """Test module-level functionality.""" - def test_upload_folder_creation(self): + def test_upload_folder_creation(self, mock_environ): """Test that upload folder is created on module import.""" # The folder should be created when the module is imported # This is tested by checking the UPLOAD_FOLDER exists - assert UPLOAD_FOLDER is not None + assert mock_environ.UPLOAD_FOLDER is not None - def test_constants_are_properly_defined(self): + def test_constants_are_properly_defined(self, mock_environ): """Test that module constants are properly defined.""" - assert isinstance(ALLOWED_EXTENSIONS, set) - assert len(ALLOWED_EXTENSIONS) > 0 - assert MAX_FILE_SIZE > 0 - assert isinstance(UPLOAD_FOLDER, Path) + assert isinstance(mock_environ.ALLOWED_EXTENSIONS, set) + assert len(mock_environ.ALLOWED_EXTENSIONS) > 0 + assert mock_environ.MAX_FILE_SIZE > 0 + assert isinstance(mock_environ.UPLOAD_FOLDER, Path) def test_document_analysis_prompt_is_comprehensive(self): """Test that the document analysis prompt contains key elements.""" From 5143537b029af7dced265e86ef558900d8b430c6 Mon Sep 17 00:00:00 2001 From: yangm2 Date: Sat, 16 Aug 2025 00:22:44 +0000 Subject: [PATCH 6/6] fix formatting --- backend/tests/test_import.py | 4 +--- backend/tests/test_upload.py | 38 +++++++++++++++++++++++++----------- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/backend/tests/test_import.py b/backend/tests/test_import.py index e231fe6..0e43de5 100644 --- a/backend/tests/test_import.py +++ b/backend/tests/test_import.py @@ -1,11 +1,9 @@ - def test_flask_app_startup(monkeypatch, tmpdir): - # in testing environment, DATA_DIR (uploads) is set to a pytest provided # tmpdir (otherwise importing upload.py fails with a write-permission # error) monkeypatch.setenv("DATA_DIR", str(tmpdir)) - + from tenantfirstaid.app import app assert app is not None diff --git a/backend/tests/test_upload.py b/backend/tests/test_upload.py index dcf9f07..99114d5 100644 --- a/backend/tests/test_upload.py +++ b/backend/tests/test_upload.py @@ -12,6 +12,7 @@ from tenantfirstaid.session import TenantSession + @pytest.fixture def mock_environ(monkeypatch, tmpdir): monkeypatch.setenv("DATA_DIR", str(tmpdir)) @@ -22,8 +23,9 @@ def mock_environ(monkeypatch, tmpdir): UploadView, ALLOWED_EXTENSIONS, MAX_FILE_SIZE, - UPLOAD_FOLDER + UPLOAD_FOLDER, ) + my_dict = { "ALLOWED_EXTENSIONS": ALLOWED_EXTENSIONS, "MAX_FILE_SIZE": MAX_FILE_SIZE, @@ -32,8 +34,7 @@ def mock_environ(monkeypatch, tmpdir): "UploadView": UploadView, "UPLOAD_FOLDER": UPLOAD_FOLDER, } - return namedtuple('DictAsObject', my_dict.keys())(**my_dict) - + return namedtuple("DictAsObject", my_dict.keys())(**my_dict) class TestAllowedFile: @@ -76,17 +77,17 @@ def mock_service_account(): """Mock Google service account credentials.""" with patch("tenantfirstaid.upload.service_account") as mock_sa: mock_credentials = Mock() - mock_sa.Credentials.from_service_account_file.return_value = ( - mock_credentials - ) + mock_sa.Credentials.from_service_account_file.return_value = mock_credentials yield mock_sa, mock_credentials + @pytest.fixture def mock_vertexai(mocker, mock_environ): mock_vertexai_init = mocker.Mock(spec=vertexai) mocker.patch("tenantfirstaid.chat.vertexai.init", return_value=mock_vertexai_init) return mock_vertexai_init + @pytest.fixture def mock_generative_model(): """Mock GenerativeModel.""" @@ -95,16 +96,25 @@ def mock_generative_model(): mock_model_class.return_value = mock_model yield mock_model + @pytest.fixture def document_analyzer( - mock_service_account, mock_vertexai, mock_generative_model, mock_environ, tmp_path, monkeypatch + mock_service_account, + mock_vertexai, + mock_generative_model, + mock_environ, + tmp_path, + monkeypatch, ): """Create a DocumentAnalyzer instance with mocked dependencies.""" tmp_cred = tmp_path / "service_account.json" - tmp_cred.write_text(dumps({"client_email": "nonsense@nonsense.org", "token_uri": "abc123"})) # Mock service account credentials + tmp_cred.write_text( + dumps({"client_email": "nonsense@nonsense.org", "token_uri": "abc123"}) + ) # Mock service account credentials monkeypatch.setenv("GOOGLE_SERVICE_ACCOUNT_CREDENTIALS_FILE", str(tmp_cred)) return mock_environ.DocumentAnalyzer() + @pytest.fixture def mock_document_analyzer(mocker, mock_environ): """Mock DocumentAnalyzer.""" @@ -112,11 +122,16 @@ def mock_document_analyzer(mocker, mock_environ): mock_analyzer.analyze_document = mocker.Mock() return mock_analyzer + class TestDocumentAnalyzer: """Test the DocumentAnalyzer class.""" def test_document_analyzer_initialization( - document_analyzer, mock_service_account, mock_vertexai, mock_generative_model, mock_environ + document_analyzer, + mock_service_account, + mock_vertexai, + mock_generative_model, + mock_environ, ): """Test DocumentAnalyzer initialization.""" analyzer = mock_environ.DocumentAnalyzer() @@ -259,7 +274,9 @@ def test_upload_view_invalid_file_type_returns_400(self, app, upload_view): with pytest.raises(BadRequest, match="Invalid file type"): upload_view.dispatch_request() - def test_upload_view_file_too_large_returns_400(self, app, upload_view, mock_environ): + def test_upload_view_file_too_large_returns_400( + self, app, upload_view, mock_environ + ): """Test that files exceeding size limit return 400.""" # Create file larger than MAX_FILE_SIZE large_content = b"x" * (mock_environ.MAX_FILE_SIZE + 1) @@ -399,7 +416,6 @@ def test_upload_view_session_update( assert updated_session["messages"][0]["content"] == "Complete analysis text" - class TestUploadModule: """Test module-level functionality."""