diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b1a3dd..d367990 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] + +## [1.2.3] - 2025-08-22 + +### Added +- Increased test coverage for the `time_log.py` module from 88% to 100%. + + +## [1.2.2] - 2025-08-22 ### Added -- Your new feature here. +- Increased test coverage for the `manual_entry.py` module from 72% to 98%. + + +## [1.2.1] - 2025-08-22 ### Fixed -- Your new fix here. +- Corrected and updated the release comparison links in `CHANGELOG.md`. ## [1.2.0] - 2025-08-07 @@ -137,8 +147,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Flexible database configuration (SQLite/PostgreSQL). -[Unreleased]: https://github.com/PPeitsch/TimeTrack/compare/v1.0.1...HEAD -[1.1.1]: https://github.com/PPeitsch/TimeTrack/compare/v1.1.0....v1.1.1 + +[1.2.3]: https://github.com/PPeitsch/TimeTrack/compare/v1.2.2...v1.2.3 +[1.2.2]: https://github.com/PPeitsch/TimeTrack/compare/v1.2.1...v1.2.2 +[1.2.1]: https://github.com/PPeitsch/TimeTrack/compare/v1.2.0...v1.2.1 +[1.2.0]: https://github.com/PPeitsch/TimeTrack/compare/v1.1.1...v1.2.0 +[1.1.1]: https://github.com/PPeitsch/TimeTrack/compare/v1.1.0...v1.1.1 [1.1.0]: https://github.com/PPeitsch/TimeTrack/compare/v1.0.9...v1.1.0 [1.0.9]: https://github.com/PPeitsch/TimeTrack/compare/v1.0.8...v1.0.9 [1.0.8]: https://github.com/PPeitsch/TimeTrack/compare/v1.0.7...v1.0.8 diff --git a/requirements.txt b/requirements.txt index 9002531..f835f94 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,5 +7,3 @@ requests==2.32.3 psycopg2-binary==2.9.10 python-dotenv==1.0.1 beautifulsoup4==4.13.0b2 -black==24.10.0 -isort==6.0.0b2 diff --git a/tests/test_routes.py b/tests/test_routes.py index e147a6a..c52735e 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -1,9 +1,12 @@ import json import unittest from datetime import date, datetime +from unittest.mock import patch + +from flask import jsonify from app.db.database import db -from app.models.models import Employee, ScheduleEntry +from app.models.models import Employee, ScheduleEntry, Holiday class TestRoutes(unittest.TestCase): @@ -19,6 +22,12 @@ class TestConfig(Config): self.app = create_app(TestConfig) self.client = self.app.test_client() + from werkzeug.exceptions import BadRequest + + @self.app.errorhandler(BadRequest) + def handle_bad_request(e): + return jsonify(error="Bad Request"), 400 + with self.app.app_context(): db.create_all() # Create a default employee @@ -88,6 +97,153 @@ def test_manual_entry_route_post_invalid(self): data = json.loads(response.data) self.assertIn("error", data) + def test_manual_entry_post_bad_json(self): + response = self.client.post("/entry", data="{", content_type="application/json") + self.assertEqual(response.status_code, 400) + data = json.loads(response.data) + self.assertEqual(data["error"], "Bad Request") + + def test_manual_entry_post_missing_fields(self): + # Test missing date + response = self.client.post( + "/entry", + data=json.dumps({"employee_id": 1}), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(json.loads(response.data)["error"], "Date is required") + + # Test missing employee_id + response = self.client.post( + "/entry", + data=json.dumps( + {"date": "2025-03-16", "entries": [{"entry": "09:00", "exit": "17:00"}]} + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(json.loads(response.data)["error"], "Employee ID is required") + + # Test missing entries for work day + response = self.client.post( + "/entry", + data=json.dumps( + {"date": "2025-03-16", "employee_id": 1, "absence_code": None} + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + self.assertEqual( + json.loads(response.data)["error"], "Entries are required for work day" + ) + + def test_manual_entry_post_invalid_date(self): + entry_data = {"date": "invalid-date", "employee_id": 1} + response = self.client.post( + "/entry", data=json.dumps(entry_data), content_type="application/json" + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(json.loads(response.data)["error"], "Invalid date format") + + def test_manual_entry_post_empty_entries(self): + entry_data = { + "date": "2025-03-16", + "employee_id": 1, + "entries": [], + "absence_code": None, + } + response = self.client.post( + "/entry", data=json.dumps(entry_data), content_type="application/json" + ) + self.assertEqual(response.status_code, 400) + self.assertEqual( + json.loads(response.data)["error"], "No time entries provided for work day" + ) + + def test_manual_entry_update_existing(self): + # First, create an entry + entry_data = { + "date": "2025-03-18", + "employee_id": 1, + "entries": [{"entry": "09:00", "exit": "12:00"}], + "absence_code": None, + } + self.client.post( + "/entry", data=json.dumps(entry_data), content_type="application/json" + ) + + # Now, update it + update_data = { + "date": "2025-03-18", + "employee_id": 1, + "entries": [{"entry": "09:00", "exit": "13:00"}], # Changed exit time + "absence_code": None, + } + response = self.client.post( + "/entry", data=json.dumps(update_data), content_type="application/json" + ) + self.assertEqual(response.status_code, 200) + + with self.app.app_context(): + entry = ScheduleEntry.query.filter_by( + date=datetime.strptime("2025-03-18", "%Y-%m-%d").date() + ).first() + self.assertEqual(len(entry.entries), 1) + self.assertEqual(entry.entries[0]["exit"], "13:00") + + def test_get_entry_not_found(self): + response = self.client.get("/entry/2025-01-01") + self.assertEqual(response.status_code, 200) + self.assertEqual(json.loads(response.data), {}) + + def test_get_entry_found(self): + # Create an entry to find + entry_date = "2025-03-19" + entry_data = { + "date": entry_date, + "employee_id": 1, + "entries": [{"entry": "10:00", "exit": "18:00"}], + "absence_code": None, + } + self.client.post( + "/entry", data=json.dumps(entry_data), content_type="application/json" + ) + + response = self.client.get(f"/entry/{entry_date}") + self.assertEqual(response.status_code, 200) + data = json.loads(response.data) + self.assertEqual(len(data["entries"]), 1) + self.assertEqual(data["entries"][0]["entry"], "10:00") + self.assertEqual(data["hours"], 8.0) + + def test_get_entry_invalid_date(self): + response = self.client.get("/entry/invalid-date") + self.assertEqual(response.status_code, 400) + self.assertEqual(json.loads(response.data)["error"], "Invalid date format") + + def test_manual_entry_post_absence(self): + entry_data = { + "date": "2025-03-20", + "employee_id": 1, + "entries": [], + "absence_code": "VAC", + } + response = self.client.post( + "/entry", data=json.dumps(entry_data), content_type="application/json" + ) + self.assertEqual(response.status_code, 200) + data = json.loads(response.data) + self.assertEqual(data["status"], "success") + self.assertNotIn("hours", data) # No hours for absence + + with self.app.app_context(): + entry = ScheduleEntry.query.filter_by( + date=datetime.strptime("2025-03-20", "%Y-%m-%d").date() + ).first() + self.assertIsNotNone(entry) + self.assertEqual(entry.absence_code, "VAC") + self.assertEqual(entry.entries, []) + def test_time_summary_route(self): # Test the time summary route response = self.client.get("/summary/") @@ -154,6 +310,71 @@ def test_monthly_summary_route(self): self.assertIn("required", data) self.assertIn("difference", data) + # More specific check for required hours + # March 2025 has 21 working days. 21 * 8 = 168. + # We added one absence day, so required hours should be 160. + self.assertEqual(data["required"], 160.0) + self.assertEqual(data["total"], 8.0) + + def test_daily_summary_for_absence(self): + # Create an absence entry + with self.app.app_context(): + entry_date = date(2025, 4, 1) # A Tuesday + absence_entry = ScheduleEntry( + employee_id=1, date=entry_date, entries=[], absence_code="SICK" + ) + db.session.add(absence_entry) + db.session.commit() + + response = self.client.get("/summary/daily/2025-04-01") + self.assertEqual(response.status_code, 200) + data = json.loads(response.data) + self.assertEqual(data["type"], "SICK") + self.assertEqual(data["hours"], 0) + self.assertEqual(data["required"], 0) + + def test_daily_summary_for_holiday(self): + # Create a holiday + with self.app.app_context(): + holiday_date = date(2025, 5, 1) # A Thursday + holiday = Holiday(date=holiday_date, description="Labor Day") + db.session.add(holiday) + db.session.commit() + + response = self.client.get("/summary/daily/2025-05-01") + self.assertEqual(response.status_code, 200) + data = json.loads(response.data) + self.assertEqual(data["type"], "Holiday") + self.assertEqual(data["hours"], 0) + self.assertEqual(data["required"], 0) + + def test_daily_summary_for_weekend(self): + # A known weekend date + response = self.client.get("/summary/daily/2025-03-16") # A Sunday + self.assertEqual(response.status_code, 200) + data = json.loads(response.data) + self.assertEqual(data["type"], "Weekend") + self.assertEqual(data["hours"], 0) + self.assertEqual(data["required"], 0) + + def test_daily_summary_exception(self): + with self.app.app_context(): + with patch("app.routes.time_summary.ScheduleEntry.query") as mock_query: + mock_query.filter_by.side_effect = Exception("DB Error") + response = self.client.get("/summary/daily/2025-01-01") + self.assertEqual(response.status_code, 500) + data = json.loads(response.data) + self.assertEqual(data["error"], "DB Error") + + @patch("app.routes.time_summary.date") + def test_monthly_summary_exception(self, mock_date): + with self.app.app_context(): + mock_date.side_effect = Exception("Date Error") + response = self.client.get("/summary/monthly/2025/1") + self.assertEqual(response.status_code, 500) + data = json.loads(response.data) + self.assertEqual(data["error"], "Date Error") + def test_monthly_logs_route(self): # Create a test entry first with self.app.app_context(): @@ -183,6 +404,44 @@ def test_monthly_logs_route(self): self.assertIn("entries", entry) self.assertIn("total_hours", entry) + def test_monthly_logs_with_absence(self): + # Create an absence entry + with self.app.app_context(): + entry_date = date(2025, 4, 15) + absence_entry = ScheduleEntry( + employee_id=1, date=entry_date, entries=[], absence_code="VAC" + ) + db.session.add(absence_entry) + db.session.commit() + + # Test getting monthly logs for the month with the absence + response = self.client.get("/logs/monthly/2025/4") + self.assertEqual(response.status_code, 200) + data = json.loads(response.data) + + # Find the absence entry in the response + absence_found = False + for entry in data: + if entry["date"] == "2025-04-15": + self.assertEqual(entry["type"], "VAC") + self.assertEqual(entry["total_hours"], 0) + absence_found = True + break + self.assertTrue(absence_found, "Absence entry not found in response") + + def test_monthly_logs_exception(self): + with self.app.app_context(): + with patch("app.routes.time_log.ScheduleEntry.query") as mock_query: + # Configure the mock to raise an exception when used + mock_query.filter.side_effect = Exception("Database connection failed") + + # Test that the exception is handled and a 500 error is returned + response = self.client.get("/logs/monthly/2025/5") + self.assertEqual(response.status_code, 500) + data = json.loads(response.data) + self.assertIn("error", data) + self.assertEqual(data["error"], "Database connection failed") + if __name__ == "__main__": unittest.main()