diff --git a/README.md b/README.md index 2507812..67283aa 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Final Project [link to our project](http://161.35.189.70:10000) + An exercise to put to practice software development teamwork, subsystem communication, containers, deployment, and CI/CD pipelines. See [instructions](./instructions.md) for details. # Team Members @@ -18,4 +19,6 @@ Finally, open `http://localhost:10000/` in your local browser to view our web ap # Docker Images - [Docker Hub repo](https://hub.docker.com/repository/docker/sennyy/5-final-project-spring-2024-best-swes/general) - [Machine Learning Client](https://hub.docker.com/layers/sennyy/5-final-project-spring-2024-best-swes/latest/images/sha256-8e6f5dc5c28f64ee5f8deb15ed03da005c09c8933a5b3f9f6d3aca934774eb17?context=repo) -- [Webapp](https://hub.docker.com/layers/sennyy/5-final-project-spring-2024-best-swes/webapp/images/sha256-a2177768a9c1b34b5995fa325076f42fb24d56aeb087b8824f203efdae94cd96?context=repo) \ No newline at end of file +- [Webapp](https://hub.docker.com/layers/sennyy/5-final-project-spring-2024-best-swes/webapp/images/sha256-a2177768a9c1b34b5995fa325076f42fb24d56aeb087b8824f203efdae94cd96?context=repo) + + diff --git a/machine-learning-client/response1.json b/machine-learning-client/response1.json index 2400278..608aaeb 100644 --- a/machine-learning-client/response1.json +++ b/machine-learning-client/response1.json @@ -1,166 +1,706 @@ { - "ocr_type" : "receipts", - "request_id" : "P_72.80.97.228_luxbmz9s_bz6", - "ref_no" : "ocr_python_123", - "file_name" : "RestaurantReceipt1.png", - "request_received_on" : 1712965847825, - "success" : true, - "image_width" : 812, - "image_height" : 1354, - "image_rotation" : 0, - "recognition_completed_on" : 1712965848215, - "receipts" : [ { - "merchant_name" : "HARBOR LANE CAFE", - "merchant_address" : "HARBOR LANE CAFE", - "merchant_phone" : null, - "merchant_website" : null, - "merchant_tax_reg_no" : null, - "merchant_company_reg_no" : null, - "region" : null, - "mall" : null, - "country" : "US", - "state" : "IL", - "city" : "CHICAGO", - "receipt_no" : null, - "date" : "2019-11-20", - "time" : null, - "items" : [ { - "amount" : 14.98, - "category" : null, - "description" : "Tacos Del Mal Shrimp", - "flags" : "", - "qty" : 1, - "remarks" : null, - "tags" : null, - "unitPrice" : null - }, { - "amount" : 12.50, - "category" : null, - "description" : "Especial Salad Chicken", - "flags" : "", - "qty" : 1, - "remarks" : null, - "tags" : null, - "unitPrice" : null - }, { - "amount" : 1.99, - "category" : null, - "description" : "Fountain Beverage", - "flags" : "", - "qty" : 1, - "remarks" : null, - "tags" : null, - "unitPrice" : null - } ], - "currency" : "USD", - "total" : 31.39, - "subtotal" : 29.47, - "tax" : 1.92, - "service_charge" : null, - "tip" : null, - "payment_method" : null, - "payment_details" : null, - "credit_card_type" : null, - "credit_card_number" : null, - "ocr_text" : " HARBOR LANE CAFE\n 3941 GREEN OAKS BLVD\n CHICAGO, IL\n SALE\n 11/20/2019 11:05 AM\n BATCH #: 01A2A\n APPR #: 34362\n TRACE #: 9\n VISA 3483\n 1 Tacos Del Mal Shrimp $14.98\n 1 Especial Salad Chicken $12.50\n 1 Fountain Beverage $1.99\n SUBTOTAL: $29.47\n TAX: $1.92\n TOTAL: $31.39\n TIP:\n TOTAL:\n APPROVED\n THANK YOU\n CUSTOMER COPY", - "ocr_confidence" : 98.50, - "width" : 596, - "height" : 1111, - "avg_char_width" : 16.9954, - "avg_line_height" : 28.3333, - "conf_amount" : 83, - "source_locations" : { - "date" : [ [ { - "x" : 46, - "y" : 216 - }, { - "x" : 240, - "y" : 216 - }, { - "x" : 240, - "y" : 246 - }, { - "x" : 46, - "y" : 246 - } ] ], - "total" : [ [ { - "x" : 275, - "y" : 696 - }, { - "x" : 381, - "y" : 696 - }, { - "x" : 381, - "y" : 726 - }, { - "x" : 275, - "y" : 726 - } ] ], - "subtotal" : [ [ { - "x" : 276, - "y" : 615 - }, { - "x" : 384, - "y" : 615 - }, { - "x" : 384, - "y" : 646 - }, { - "x" : 276, - "y" : 646 - } ] ], - "merchant_name" : [ [ { - "x" : 40, - "y" : 13 - }, { - "x" : 357, - "y" : 15 - }, { - "x" : 357, - "y" : 47 - }, { - "x" : 40, - "y" : 46 - } ] ], - "doc" : [ [ { - "x" : 24, - "y" : -40 - }, { - "x" : 679, - "y" : -40 - }, { - "x" : 679, - "y" : 1181 - }, { - "x" : 24, - "y" : 1181 - } ] ], - "tax" : [ [ { - "x" : 277, - "y" : 656 - }, { - "x" : 361, - "y" : 656 - }, { - "x" : 361, - "y" : 685 - }, { - "x" : 277, - "y" : 685 - } ] ], - "merchant_address" : [ [ { - "x" : 24, - "y" : 11 - }, { - "x" : 373, - "y" : 13 - }, { - "x" : 373, - "y" : 49 - }, { - "x" : 24, - "y" : 48 - } ] ] - } - } ] - } \ No newline at end of file + "api_request": { + "error": {}, + "resources": [ + "document" + ], + "status": "success", + "status_code": 201, + "url": "https://api.mindee.net/v1/products/mindee/expense_receipts/v5/predict" + }, + "document": { + "id": "439f9443-64a9-4cdf-ae08-195942824b61", + "inference": { + "extras": {}, + "finished_at": "2024-05-01T04:46:41.281461", + "is_rotation_applied": true, + "pages": [ + { + "extras": {}, + "id": 0, + "orientation": { + "value": 0 + }, + "prediction": { + "category": { + "confidence": 1, + "value": "food" + }, + "date": { + "confidence": 0.99, + "polygon": [ + [ + 0.275, + 0.201 + ], + [ + 0.49, + 0.201 + ], + [ + 0.49, + 0.225 + ], + [ + 0.275, + 0.225 + ] + ], + "value": "2020-09-25" + }, + "document_type": { + "confidence": 0.92, + "value": "EXPENSE RECEIPT" + }, + "line_items": [ + { + "confidence": 0.97, + "description": "Hendrick Gin & Tonic", + "polygon": [ + [ + 0.01, + 0.266 + ], + [ + 0.915, + 0.266 + ], + [ + 0.915, + 0.29 + ], + [ + 0.01, + 0.29 + ] + ], + "quantity": 1, + "total_amount": 10.5, + "unit_price": "None" + }, + { + "confidence": 0.97, + "description": "Ginger Mule", + "polygon": [ + [ + 0.01, + 0.309 + ], + [ + 0.915, + 0.309 + ], + [ + 0.915, + 0.336 + ], + [ + 0.01, + 0.336 + ] + ], + "quantity": 1, + "total_amount": 9.5, + "unit_price": "None" + }, + { + "confidence": 0.97, + "description": "Glass Camus Zin", + "polygon": [ + [ + 0.01, + 0.353 + ], + [ + 0.915, + 0.353 + ], + [ + 0.915, + 0.378 + ], + [ + 0.01, + 0.378 + ] + ], + "quantity": 1, + "total_amount": 24, + "unit_price": "None" + }, + { + "confidence": 0.97, + "description": "Titos Vodka Soda", + "polygon": [ + [ + 0.01, + 0.398 + ], + [ + 0.915, + 0.398 + ], + [ + 0.915, + 0.422 + ], + [ + 0.01, + 0.422 + ] + ], + "quantity": 1, + "total_amount": 12, + "unit_price": "None" + } + ], + "locale": { + "confidence": 0.74, + "country": "US", + "currency": "USD", + "language": "en", + "value": "en-US" + }, + "orientation": { + "confidence": 0.99, + "degrees": 0 + }, + "subcategory": { + "confidence": 1, + "value": "restaurant" + }, + "supplier_address": { + "confidence": 0.95, + "polygon": [ + [ + 0.326, + 0.062 + ], + [ + 0.673, + 0.062 + ], + [ + 0.673, + 0.121 + ], + [ + 0.326, + 0.121 + ] + ], + "value": "1016 6th ave new york ny" + }, + "supplier_company_registrations": [], + "supplier_name": { + "confidence": 0.94, + "polygon": [ + [ + 0.179, + 0.031 + ], + [ + 0.824, + 0.031 + ], + [ + 0.824, + 0.064 + ], + [ + 0.179, + 0.064 + ] + ], + "raw_value": "Hotel Restaurant and Bar", + "value": "HOTEL RESTAURANT AND BAR" + }, + "supplier_phone_number": { + "confidence": 0.99, + "polygon": [ + [ + 0.371, + 0.117 + ], + [ + 0.727, + 0.117 + ], + [ + 0.727, + 0.152 + ], + [ + 0.371, + 0.152 + ] + ], + "value": "6503091992" + }, + "taxes": [ + { + "base": "None", + "code": "TAX", + "confidence": 0.99, + "polygon": [ + [ + 0.22, + 0.566 + ], + [ + 0.855, + 0.566 + ], + [ + 0.855, + 0.59 + ], + [ + 0.22, + 0.59 + ] + ], + "rate": "None", + "value": 4.76 + } + ], + "time": { + "confidence": 0.99, + "polygon": [ + [ + 0.554, + 0.201 + ], + [ + 0.725, + 0.201 + ], + [ + 0.725, + 0.222 + ], + [ + 0.554, + 0.222 + ] + ], + "value": "12:54" + }, + "tip": { + "confidence": 0, + "polygon": [], + "value": "None" + }, + "total_amount": { + "confidence": 0.99, + "polygon": [ + [ + 0.728, + 0.61 + ], + [ + 0.855, + 0.61 + ], + [ + 0.855, + 0.635 + ], + [ + 0.728, + 0.635 + ] + ], + "value": 60.76 + }, + "total_net": { + "confidence": 0.99, + "polygon": [ + [ + 0.728, + 0.523 + ], + [ + 0.853, + 0.523 + ], + [ + 0.853, + 0.546 + ], + [ + 0.728, + 0.546 + ] + ], + "value": 56 + }, + "total_tax": { + "confidence": 0.99, + "polygon": [], + "value": 4.76 + } + } + } + ], + "prediction": { + "category": { + "confidence": 1, + "value": "food" + }, + "date": { + "confidence": 0.99, + "page_id": 0, + "polygon": [ + [ + 0.275, + 0.201 + ], + [ + 0.49, + 0.201 + ], + [ + 0.49, + 0.225 + ], + [ + 0.275, + 0.225 + ] + ], + "value": "2020-09-25" + }, + "document_type": { + "confidence": 0.92, + "value": "EXPENSE RECEIPT" + }, + "line_items": [ + { + "confidence": 0.97, + "description": "Hendrick Gin & Tonic", + "page_id": 0, + "polygon": [ + [ + 0.01, + 0.266 + ], + [ + 0.915, + 0.266 + ], + [ + 0.915, + 0.29 + ], + [ + 0.01, + 0.29 + ] + ], + "quantity": 1, + "total_amount": 10.5, + "unit_price": "None" + }, + { + "confidence": 0.97, + "description": "Ginger Mule", + "page_id": 0, + "polygon": [ + [ + 0.01, + 0.309 + ], + [ + 0.915, + 0.309 + ], + [ + 0.915, + 0.336 + ], + [ + 0.01, + 0.336 + ] + ], + "quantity": 1, + "total_amount": 9.5, + "unit_price": "None" + }, + { + "confidence": 0.97, + "description": "Glass Camus Zin", + "page_id": 0, + "polygon": [ + [ + 0.01, + 0.353 + ], + [ + 0.915, + 0.353 + ], + [ + 0.915, + 0.378 + ], + [ + 0.01, + 0.378 + ] + ], + "quantity": 1, + "total_amount": 24, + "unit_price": "None" + }, + { + "confidence": 0.97, + "description": "Titos Vodka Soda", + "page_id": 0, + "polygon": [ + [ + 0.01, + 0.398 + ], + [ + 0.915, + 0.398 + ], + [ + 0.915, + 0.422 + ], + [ + 0.01, + 0.422 + ] + ], + "quantity": 1, + "total_amount": 12, + "unit_price": "None" + } + ], + "locale": { + "confidence": 0.74, + "country": "US", + "currency": "USD", + "language": "en", + "value": "en-US" + }, + "subcategory": { + "confidence": 1, + "value": "restaurant" + }, + "supplier_address": { + "confidence": 0.95, + "page_id": 0, + "polygon": [ + [ + 0.326, + 0.062 + ], + [ + 0.673, + 0.062 + ], + [ + 0.673, + 0.121 + ], + [ + 0.326, + 0.121 + ] + ], + "value": "1016 6th ave new york ny" + }, + "supplier_company_registrations": [], + "supplier_name": { + "confidence": 0.94, + "page_id": 0, + "polygon": [ + [ + 0.179, + 0.031 + ], + [ + 0.824, + 0.031 + ], + [ + 0.824, + 0.064 + ], + [ + 0.179, + 0.064 + ] + ], + "raw_value": "Hotel Restaurant and Bar", + "value": "HOTEL RESTAURANT AND BAR" + }, + "supplier_phone_number": { + "confidence": 0.99, + "page_id": 0, + "polygon": [ + [ + 0.371, + 0.117 + ], + [ + 0.727, + 0.117 + ], + [ + 0.727, + 0.152 + ], + [ + 0.371, + 0.152 + ] + ], + "value": "6503091992" + }, + "taxes": [ + { + "base": "None", + "code": "TAX", + "confidence": 0.99, + "page_id": 0, + "polygon": [ + [ + 0.22, + 0.566 + ], + [ + 0.855, + 0.566 + ], + [ + 0.855, + 0.59 + ], + [ + 0.22, + 0.59 + ] + ], + "rate": "None", + "value": 4.76 + } + ], + "time": { + "confidence": 0.99, + "page_id": 0, + "polygon": [ + [ + 0.554, + 0.201 + ], + [ + 0.725, + 0.201 + ], + [ + 0.725, + 0.222 + ], + [ + 0.554, + 0.222 + ] + ], + "value": "12:54" + }, + "tip": { + "confidence": 0, + "page_id": "None", + "polygon": [], + "value": "None" + }, + "total_amount": { + "confidence": 0.99, + "page_id": 0, + "polygon": [ + [ + 0.728, + 0.61 + ], + [ + 0.855, + 0.61 + ], + [ + 0.855, + 0.635 + ], + [ + 0.728, + 0.635 + ] + ], + "value": 60.76 + }, + "total_net": { + "confidence": 0.99, + "page_id": 0, + "polygon": [ + [ + 0.728, + 0.523 + ], + [ + 0.853, + 0.523 + ], + [ + 0.853, + 0.546 + ], + [ + 0.728, + 0.546 + ] + ], + "value": 56 + }, + "total_tax": { + "confidence": 0.99, + "page_id": "None", + "polygon": [], + "value": 4.76 + } + }, + "processing_time": 0.704, + "product": { + "features": [ + "locale", + "category", + "subcategory", + "document_type", + "date", + "time", + "total_amount", + "total_net", + "total_tax", + "tip", + "taxes", + "supplier_name", + "supplier_company_registrations", + "supplier_address", + "supplier_phone_number", + "orientation", + "line_items" + ], + "name": "mindee/expense_receipts", + "type": "standard", + "version": "5.1" + }, + "started_at": "2024-05-01T04:46:40.577339" + }, + "n_pages": 1, + "name": "receipt_6631c92fcf994b91b60712c9.jpg" + } +} \ No newline at end of file diff --git a/web-app/test/_init_.py b/web-app/test/_init_.py new file mode 100644 index 0000000..e69de29 diff --git a/web-app/test/test_app.py b/web-app/test/test_app.py new file mode 100644 index 0000000..d8b8be9 --- /dev/null +++ b/web-app/test/test_app.py @@ -0,0 +1,193 @@ +import pytest +from flask import url_for +import os +import tempfile +import io +from unittest.mock import patch +import sys +from pathlib import Path +import mongomock +import requests_mock +from bson import ObjectId + +# Adjust the Python path to include the directory above the 'test' directory where 'app.py' is located +current_dir = Path(__file__).resolve().parent +parent_dir = current_dir.parent # 'web_app' directory +sys.path.insert(0, str(parent_dir)) + +from app import app # Now you can successfully import app +from app import call_ml_service # Assuming call_ml_service is in app.py + +# Use a valid ObjectId for tests +test_id = str(ObjectId()) + + +@pytest.fixture +def client(): + db_fd, db_path = tempfile.mkstemp() + app.config['TESTING'] = True + app.config['UPLOAD_FOLDER'] = tempfile.mkdtemp() + with app.test_client() as client: + yield client + os.close(db_fd) + os.unlink(db_path) + +@pytest.fixture +def mock_db(monkeypatch): + mock_client = mongomock.MongoClient() + monkeypatch.setattr('pymongo.MongoClient', lambda *args, **kwargs: mock_client) + db = mock_client['test_db'] # simulate the database + # Now simulate collections within this database + db.create_collection("receipts") + db.create_collection("images") + return db + +@pytest.fixture +def mock_requests(monkeypatch): + """ + Create a fixture that mocks requests.post method to prevent actual HTTP calls in tests. + """ + with requests_mock.Mocker() as m: + monkeypatch.setattr("requests.post", m.post) + yield m + +@pytest.fixture +def prepare_data(mock_db): + """Prepare the database with dummy data.""" + receipt_id = ObjectId() + mock_db.receipts.insert_one({ + "_id": receipt_id, + "names": ["Alice", "Bob"], + "items": [ + {"_id": ObjectId(), "description": "Salad", "price": 10.00, "is_appetizer": True}, + {"_id": ObjectId(), "description": "Steak", "price": 25.00, "is_appetizer": False} + ], + "allocations": [ + {"name": "Alice", "items": ["Salad"]}, + {"name": "Bob", "items": ["Steak"]} + ], + "num_of_people": 2 + }) + return receipt_id + +def test_home_page_status(client): + response = client.get('/') + assert response.status_code == 200 + +def test_upload_image_no_file(client): + """Test uploading an image with no file attached.""" + response = client.post('/upload', data={}) + assert response.status_code == 400 + assert 'No image part' in response.data.decode() + +def test_numofpeople_route(client, prepare_data): + """Test the numofpeople page loads correctly.""" + response = client.get(f'/numofpeople/{prepare_data}') + assert response.status_code == 200 + assert 'Enter Number of People and Names' in response.data.decode() + +def test_post_appetizers_selection(client, mock_db, prepare_data): + """Test updating appetizers selection.""" + appetizer_id = str(ObjectId()) # Simulating an appetizer ID + data = {'appetizers': [appetizer_id]} + response = client.post(f'/select_appetizers/{prepare_data}', data=data) + assert response.status_code == 302 # Expect redirection after successful post + assert '/allocateitems/' in response.headers['Location'] + +def test_finalize_allocation(client, mock_db, prepare_data): + """Test finalizing the item allocation updates the database correctly.""" + data = {'item_123456': ['John', 'Jane']} + response = client.post(f'/allocateitems/{prepare_data}', data=data) + assert response.status_code == 302 # Check for redirection + assert '/enter_tip/' in response.headers['Location'] + +def test_enter_tip_page(client, prepare_data): + """Test loading the tip entry page.""" + response = client.get(f'/enter_tip/{prepare_data}') + assert response.status_code == 200 + assert 'Enter Tip Percentage' in response.data.decode() + +def test_post_tip_percentage(client, prepare_data): + """Test posting the tip percentage redirects to bill calculation.""" + data = {'tip_percentage': '15'} + response = client.post(f'/enter_tip/{prepare_data}', data=data) + assert response.status_code == 302 + assert '/calculate_bill/' in response.headers['Location'] + +def test_search_history_page(client): + """Test the search history page loads properly.""" + response = client.get('/search_history') + assert response.status_code == 200 + assert 'Receipt History' in response.data.decode() + +def test_history_search_function(client, prepare_data): + """Test the history search functionality.""" + response = client.get(f'/history?search={prepare_data}') + assert response.status_code == 200 + assert 'Search Results' in response.data.decode() + +def test_allocate_items_no_selection(client, prepare_data): + """Test posting allocate items with no selection leads to no change.""" + response = client.post(f'/allocateitems/{prepare_data}', data={}) + assert response.status_code == 302 + assert '/enter_tip/' in response.headers['Location'] + +def test_history_with_no_results(client): + """Test the history page with no matching results.""" + response = client.get('/history?search=nonexistent') + assert response.status_code == 200 + assert 'No results found.' in response.data.decode() + +def test_server_status(client): + """Test the server status endpoint.""" + response = client.get('/test_connection') + assert response.status_code == 200 + assert 'Machine Learning Client is reachable' in response.data.decode() + +def test_calculate_bill_invalid_input(client, prepare_data): + """Test handling invalid tip input.""" + data = {'tip_percentage': 'invalid'} + response = client.post(f'/calculate_bill/{prepare_data}', data=data) + assert response.status_code == 400 + assert 'Invalid tip percentage provided' in response.data.decode() + +def test_history_page_empty_search(client): + """Test history page functionality with an empty search query.""" + response = client.get('/history?search=') + assert response.status_code == 200 + assert 'No results found.' not in response.data.decode() + +def test_test_connection_endpoint(client): + """Test the test connection endpoint for expected success response.""" + response = client.get('/test_connection') + assert response.status_code == 200 + assert 'Machine Learning Client is reachable' in response.data.decode() + +def test_allocate_items_submit_without_changes(client, prepare_data): + """Test submitting the allocation form without any changes to allocations.""" + response = client.post(f'/allocateitems/{prepare_data}', data={}) + assert response.status_code == 302 + assert '/enter_tip/' in response.headers['Location'] + +def test_allocate_items_submit_with_changes(client, mock_db, prepare_data): + """Test updating the allocation of items to people.""" + data = {'item_1': ['Alice'], 'item_2': ['Bob']} + response = client.post(f'/allocateitems/{prepare_data}', data=data) + assert response.status_code == 302 + assert '/enter_tip/' in response.headers['Location'] + # Check that allocations are updated in the database + updated_receipt = mock_db.receipts.find_one({"_id": prepare_data}) + assert 'Alice' in updated_receipt['allocations'][0]['name'] + assert 'Bob' in updated_receipt['allocations'][1]['name'] + +def test_enter_tip_redirects_correctly(client, prepare_data): + """Test that entering a tip redirects to the calculate bill page correctly.""" + response = client.post(f'/enter_tip/{prepare_data}', data={'tip_percentage': '15'}) + assert response.status_code == 302 + assert 'calculate_bill' in response.headers['Location'] + +def test_allocate_items_no_selection(client, prepare_data): + """Test posting allocate items with no selection leads to no change.""" + response = client.post(f'/allocateitems/{prepare_data}', data={}) + assert response.status_code == 302 + assert '/enter_tip/' in response.headers['Location']