From 6bce50eda89f29007037ce9362cb44083faced98 Mon Sep 17 00:00:00 2001 From: anonim Date: Thu, 8 Jan 2026 20:58:09 +0100 Subject: [PATCH 1/2] add PATCH /api/days/{date} --- backend/app/__init__.py | 3 + backend/app/dto/days_dto.py | 29 +++ backend/app/routes/days.py | 80 +++++++ backend/app/schemas/days_schema.py | 21 ++ backend/app/services/days_service.py | 136 ++++++++++++ backend/tests/test_days_integration.py | 285 +++++++++++++++++++++++++ backend/tests/test_days_service.py | 211 ++++++++++++++++++ 7 files changed, 765 insertions(+) create mode 100644 backend/app/dto/days_dto.py create mode 100644 backend/app/routes/days.py create mode 100644 backend/app/schemas/days_schema.py create mode 100644 backend/app/services/days_service.py create mode 100644 backend/tests/test_days_integration.py create mode 100644 backend/tests/test_days_service.py diff --git a/backend/app/__init__.py b/backend/app/__init__.py index effa8ac..ccebf87 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -32,6 +32,9 @@ def create_app(config_name='default'): from app.routes.progress import progress_bp app.register_blueprint(progress_bp) + from app.routes.days import days_bp + app.register_blueprint(days_bp) + from app.routes.user_addiction import user_addiction_bp app.register_blueprint(user_addiction_bp) diff --git a/backend/app/dto/days_dto.py b/backend/app/dto/days_dto.py new file mode 100644 index 0000000..ed0f6f5 --- /dev/null +++ b/backend/app/dto/days_dto.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass + + +@dataclass +class UpdateDayStatusRequest: + """DTO for update day status request.""" + status: str + + def to_dict(self): + """Convert DTO to dictionary for JSON serialization.""" + return { + 'status': self.status + } + + +@dataclass +class DayEntryResponse: + """DTO for a single day entry response.""" + date: str + day_of_week: str + status: str + + def to_dict(self): + """Convert DTO to dictionary for JSON serialization.""" + return { + 'date': self.date, + 'dayOfWeek': self.day_of_week, + 'status': self.status + } diff --git a/backend/app/routes/days.py b/backend/app/routes/days.py new file mode 100644 index 0000000..ab88da6 --- /dev/null +++ b/backend/app/routes/days.py @@ -0,0 +1,80 @@ +from flask import Blueprint, jsonify, request +from app.services.days_service import DaysService +from app.routes.authorization import token_required + +days_bp = Blueprint('days', __name__) + +@days_bp.route('/api/days/', methods=['PATCH']) +@token_required +def update_day_status(current_user, date): + """ + Endpoint to update status for a specific day. + + Updates the status of the day indicated by date (YYYY-MM-DD). + Only today's and past dates are allowed. Future dates return 422 Unprocessable Entity. + Operation is idempotent - setting the same status returns 200 with current entry. + + Args: + current_user (str): Username from JWT token + date (str): Date in YYYY-MM-DD format + + Returns: + JSON: Updated day entry + """ + try: + # Get request data + data = request.get_json() + if not data or 'status' not in data: + return jsonify({ + 'code': 'VALIDATION_ERROR', + 'message': 'Status is required', + 'details': [{'field': 'status', 'issue': 'missing_field'}] + }), 400 + + status = data['status'] + + # Validate status value + if status not in ['success', 'failure', 'none']: + return jsonify({ + 'code': 'VALIDATION_ERROR', + 'message': 'Invalid status value', + 'details': [{'field': 'status', 'issue': 'invalid_value', 'value': status}] + }), 400 + + # Get timezone from header (default: Europe/Warsaw) + timezone = request.headers.get('X-Timezone', 'Europe/Warsaw') + + # Update day status + day_entry = DaysService.update_day_status(current_user, date, status, timezone) + serialized_data = DaysService.serialize_day_entry_response(day_entry) + + return jsonify(serialized_data), 200 + + except Exception as e: + error_message = str(e) + + # Handle specific error cases + if 'future' in error_message.lower(): + return jsonify({ + 'code': 'VALIDATION_ERROR', + 'message': error_message, + 'details': [{'field': 'date', 'issue': 'future_not_allowed', 'value': date}] + }), 422 + + if 'not found' in error_message.lower(): + return jsonify({ + 'code': 'NOT_FOUND', + 'message': error_message + }), 404 + + if 'invalid date' in error_message.lower(): + return jsonify({ + 'code': 'VALIDATION_ERROR', + 'message': error_message, + 'details': [{'field': 'date', 'issue': 'invalid_format', 'value': date}] + }), 422 + + return jsonify({ + 'code': 'INTERNAL_ERROR', + 'message': error_message + }), 500 diff --git a/backend/app/schemas/days_schema.py b/backend/app/schemas/days_schema.py new file mode 100644 index 0000000..3828742 --- /dev/null +++ b/backend/app/schemas/days_schema.py @@ -0,0 +1,21 @@ +from marshmallow import Schema, fields, validate + + +class UpdateDayStatusRequestSchema(Schema): + """Schema for update day status request.""" + status = fields.String( + required=True, + validate=validate.OneOf(['success', 'failure', 'none']), + description="Status of the day: success, failure, or none" + ) + + +class DayEntryResponseSchema(Schema): + """Schema for a single day entry response.""" + date = fields.String(required=True, description="Date in YYYY-MM-DD format") + dayOfWeek = fields.String(required=True, description="Day of week abbreviation") + status = fields.String( + required=True, + validate=validate.OneOf(['success', 'failure', 'none']), + description="Status of the day: success, failure, or none" + ) diff --git a/backend/app/services/days_service.py b/backend/app/services/days_service.py new file mode 100644 index 0000000..db5e3e0 --- /dev/null +++ b/backend/app/services/days_service.py @@ -0,0 +1,136 @@ +from datetime import datetime +import pytz + +from app import db +from app.models.user import User +from app.models.user_addiction import UserAddiction +from app.models.daily_log import DailyLog +from app.dto.days_dto import DayEntryResponse +from app.schemas.days_schema import DayEntryResponseSchema + + +class DaysService: + + # Polish day abbreviations mapping + DAY_ABBREVIATIONS = { + 0: 'pon', # Monday + 1: 'wt', # Tuesday + 2: 'śr', # Wednesday + 3: 'czw', # Thursday + 4: 'pt', # Friday + 5: 'sob', # Saturday + 6: 'nd' # Sunday + } + + DEFAULT_MOOD = 5 # Default neutral mood value + + @staticmethod + def update_day_status(username: str, date_str: str, status: str, timezone: str = 'Europe/Warsaw') -> DayEntryResponse: + """ + Update status for a specific day. + + Args: + username (str): The username + date_str (str): Date in YYYY-MM-DD format + status (str): Status to set (success, failure, or none) + timezone (str): Timezone for date calculations (default: Europe/Warsaw) + + Returns: + DayEntryResponse: Updated day entry + + Raises: + Exception: If validation fails or user/addiction not found + """ + try: + # Get timezone + tz = pytz.timezone(timezone) + today = datetime.now(tz).date() + + # Parse and validate date + try: + target_date = datetime.strptime(date_str, '%Y-%m-%d').date() + except ValueError: + raise Exception("Invalid date format. Expected YYYY-MM-DD") + + # Check if date is not in the future + if target_date > today: + raise Exception("Cannot set status for future dates") + + # Get user + user = User.query.filter_by(name=username).first() + if not user: + raise Exception(f"User '{username}' not found") + + # Get user's addiction + user_addiction = UserAddiction.query.filter_by(user_id=user.id).first() + if not user_addiction: + raise Exception(f"No addiction found for user '{username}'") + + # Find existing daily log for this date + daily_log = DailyLog.query.filter( + DailyLog.users_addiction == user_addiction.id, + DailyLog.date == date_str + ).first() + + # Handle status update based on value + if status == 'none': + # Delete the entry if it exists + if daily_log: + db.session.delete(daily_log) + db.session.commit() + elif status == 'success': + # Create or update with relapse=0 + if daily_log: + daily_log.relapse = 0 + daily_log.mood = DaysService.DEFAULT_MOOD + else: + daily_log = DailyLog( + date=date_str, + relapse=0, + mood=DaysService.DEFAULT_MOOD, + users_addiction=user_addiction.id + ) + db.session.add(daily_log) + db.session.commit() + elif status == 'failure': + # Create or update with relapse=1 + if daily_log: + daily_log.relapse = 1 + daily_log.mood = DaysService.DEFAULT_MOOD + else: + daily_log = DailyLog( + date=date_str, + relapse=1, + mood=DaysService.DEFAULT_MOOD, + users_addiction=user_addiction.id + ) + db.session.add(daily_log) + db.session.commit() + + # Get day of week abbreviation + day_of_week = DaysService.DAY_ABBREVIATIONS[target_date.weekday()] + + # Return the updated entry + return DayEntryResponse( + date=date_str, + day_of_week=day_of_week, + status=status + ) + + except Exception as e: + db.session.rollback() + raise Exception(f"Days service error: {str(e)}") + + @staticmethod + def serialize_day_entry_response(day_entry: DayEntryResponse) -> dict: + """ + Serialize day entry DTO using Marshmallow schema. + + Args: + day_entry: DayEntryResponse to serialize + + Returns: + dict: Serialized day entry data ready for JSON response + """ + schema = DayEntryResponseSchema() + return schema.dump(day_entry.to_dict()) diff --git a/backend/tests/test_days_integration.py b/backend/tests/test_days_integration.py new file mode 100644 index 0000000..e7594ec --- /dev/null +++ b/backend/tests/test_days_integration.py @@ -0,0 +1,285 @@ +import pytest +import json +import jwt +from datetime import datetime, timedelta, date +from app import create_app, db +from app.models.user import User +from app.models.addiction import Addiction +from app.models.user_addiction import UserAddiction +from app.models.daily_log import DailyLog +from werkzeug.security import generate_password_hash + + +class TestDaysIntegration: + + @pytest.fixture(scope='function') + def app(self): + """Create and configure a new app instance for each test.""" + app = create_app('testing') + + with app.app_context(): + db.create_all() + + # Create test user + hashed_password = generate_password_hash("testpass") + user = User(name="testuser", password=hashed_password) + db.session.add(user) + + # Create test addiction + addiction = Addiction(name="Papierosy", health_risk=9) + db.session.add(addiction) + db.session.commit() + + # Create user addiction + user_addiction = UserAddiction( + user_id=user.id, + addiction_id=addiction.id, + start_date=int(datetime(2025, 12, 25).timestamp()), + cost_per_day=15 + ) + db.session.add(user_addiction) + db.session.commit() + + yield app + + db.session.remove() + db.drop_all() + + @pytest.fixture(scope='function') + def client(self, app): + """A test client for the app.""" + return app.test_client() + + @pytest.fixture(scope='function') + def auth_token(self, app): + """Generate a valid JWT token for testing.""" + with app.app_context(): + token = jwt.encode({ + 'username': 'testuser', + 'exp': datetime.utcnow() + timedelta(minutes=30) + }, app.config['SECRET_KEY'], algorithm="HS256") + return token + + def test_update_day_status_to_success(self, client, auth_token): + """Test PATCH /api/days/{date} with success status""" + # Arrange + test_date = date.today().strftime('%Y-%m-%d') + + # Act + response = client.patch( + f'/api/days/{test_date}', + headers={'Authorization': f'Bearer {auth_token}'}, + json={'status': 'success'} + ) + data = json.loads(response.data) + + # Assert + assert response.status_code == 200 + assert response.content_type == 'application/json' + assert data['date'] == test_date + assert 'dayOfWeek' in data + assert data['status'] == 'success' + + def test_update_day_status_to_failure(self, client, auth_token): + """Test PATCH /api/days/{date} with failure status""" + # Arrange + test_date = (date.today() - timedelta(days=1)).strftime('%Y-%m-%d') + + # Act + response = client.patch( + f'/api/days/{test_date}', + headers={'Authorization': f'Bearer {auth_token}'}, + json={'status': 'failure'} + ) + data = json.loads(response.data) + + # Assert + assert response.status_code == 200 + assert data['date'] == test_date + assert data['status'] == 'failure' + + def test_update_day_status_to_none(self, app, client, auth_token): + """Test PATCH /api/days/{date} with none status (delete entry)""" + # Arrange + test_date = (date.today() - timedelta(days=2)).strftime('%Y-%m-%d') + + # First create an entry + with app.app_context(): + user = User.query.filter_by(name='testuser').first() + user_addiction = UserAddiction.query.filter_by(user_id=user.id).first() + daily_log = DailyLog( + date=test_date, + relapse=0, + mood=8, + users_addiction=user_addiction.id + ) + db.session.add(daily_log) + db.session.commit() + + # Act - delete it with status=none + response = client.patch( + f'/api/days/{test_date}', + headers={'Authorization': f'Bearer {auth_token}'}, + json={'status': 'none'} + ) + data = json.loads(response.data) + + # Assert + assert response.status_code == 200 + assert data['status'] == 'none' + + # Verify it was deleted from DB + with app.app_context(): + daily_log = DailyLog.query.filter_by(date=test_date).first() + assert daily_log is None + + def test_update_day_status_idempotent(self, client, auth_token): + """Test that updating the same status multiple times is idempotent""" + # Arrange + test_date = (date.today() - timedelta(days=3)).strftime('%Y-%m-%d') + + # Act - set status twice + response1 = client.patch( + f'/api/days/{test_date}', + headers={'Authorization': f'Bearer {auth_token}'}, + json={'status': 'success'} + ) + response2 = client.patch( + f'/api/days/{test_date}', + headers={'Authorization': f'Bearer {auth_token}'}, + json={'status': 'success'} + ) + + # Assert - both should succeed + assert response1.status_code == 200 + assert response2.status_code == 200 + data1 = json.loads(response1.data) + data2 = json.loads(response2.data) + assert data1 == data2 + + def test_update_day_status_future_date(self, client, auth_token): + """Test PATCH /api/days/{date} with future date returns 422""" + # Arrange + future_date = (date.today() + timedelta(days=1)).strftime('%Y-%m-%d') + + # Act + response = client.patch( + f'/api/days/{future_date}', + headers={'Authorization': f'Bearer {auth_token}'}, + json={'status': 'success'} + ) + data = json.loads(response.data) + + # Assert + assert response.status_code == 422 + assert 'code' in data + assert data['code'] == 'VALIDATION_ERROR' + assert 'future' in data['message'].lower() + + def test_update_day_status_invalid_status(self, client, auth_token): + """Test PATCH /api/days/{date} with invalid status returns 400""" + # Arrange + test_date = date.today().strftime('%Y-%m-%d') + + # Act + response = client.patch( + f'/api/days/{test_date}', + headers={'Authorization': f'Bearer {auth_token}'}, + json={'status': 'invalid'} + ) + data = json.loads(response.data) + + # Assert + assert response.status_code == 400 + assert data['code'] == 'VALIDATION_ERROR' + assert 'status' in str(data['details']) + + def test_update_day_status_missing_status(self, client, auth_token): + """Test PATCH /api/days/{date} without status field returns 400""" + # Arrange + test_date = date.today().strftime('%Y-%m-%d') + + # Act + response = client.patch( + f'/api/days/{test_date}', + headers={'Authorization': f'Bearer {auth_token}'}, + json={} + ) + data = json.loads(response.data) + + # Assert + assert response.status_code == 400 + assert data['code'] == 'VALIDATION_ERROR' + assert 'required' in data['message'].lower() + + def test_update_day_status_unauthorized(self, client): + """Test PATCH /api/days/{date} without token returns 401""" + # Arrange + test_date = date.today().strftime('%Y-%m-%d') + + # Act + response = client.patch( + f'/api/days/{test_date}', + json={'status': 'success'} + ) + + # Assert + assert response.status_code == 401 + data = json.loads(response.data) + assert 'message' in data + assert 'Token jest wymagany!' in data['message'] + + def test_update_day_status_invalid_token(self, client): + """Test PATCH /api/days/{date} with invalid token returns 401""" + # Arrange + test_date = date.today().strftime('%Y-%m-%d') + + # Act + response = client.patch( + f'/api/days/{test_date}', + headers={'Authorization': 'Bearer invalid_token'}, + json={'status': 'success'} + ) + + # Assert + assert response.status_code == 401 + data = json.loads(response.data) + assert 'message' in data + assert 'nieprawidlowy' in data['message'] + + def test_update_day_status_with_custom_timezone(self, client, auth_token): + """Test PATCH /api/days/{date} with custom timezone header""" + # Arrange + test_date = date.today().strftime('%Y-%m-%d') + + # Act + response = client.patch( + f'/api/days/{test_date}', + headers={ + 'Authorization': f'Bearer {auth_token}', + 'X-Timezone': 'UTC' + }, + json={'status': 'success'} + ) + + # Assert + assert response.status_code == 200 + data = json.loads(response.data) + assert data['status'] == 'success' + + def test_update_day_status_invalid_date_format(self, client, auth_token): + """Test PATCH /api/days/{date} with invalid date format returns 422""" + # Arrange + invalid_date = "2026-13-45" # Invalid month and day + + # Act + response = client.patch( + f'/api/days/{invalid_date}', + headers={'Authorization': f'Bearer {auth_token}'}, + json={'status': 'success'} + ) + data = json.loads(response.data) + + # Assert + assert response.status_code == 422 + assert data['code'] == 'VALIDATION_ERROR' diff --git a/backend/tests/test_days_service.py b/backend/tests/test_days_service.py new file mode 100644 index 0000000..d389442 --- /dev/null +++ b/backend/tests/test_days_service.py @@ -0,0 +1,211 @@ +import unittest +from unittest.mock import patch, MagicMock +from datetime import datetime, date +import pytz + +from app.services.days_service import DaysService +from app.models.user import User +from app.models.user_addiction import UserAddiction +from app.models.daily_log import DailyLog +from app.dto.days_dto import DayEntryResponse + + +class TestDaysService(unittest.TestCase): + + @patch('app.services.days_service.db.session') + @patch('app.services.days_service.DailyLog') + @patch('app.services.days_service.UserAddiction') + @patch('app.services.days_service.User') + @patch('app.services.days_service.datetime') + def test_update_day_status_to_success_creates_new_entry(self, mock_datetime, mock_user, + mock_user_addiction, mock_daily_log, mock_session): + """Test updating status to success creates new DailyLog entry""" + # Arrange + test_date = date(2026, 1, 7) + mock_datetime.now.return_value.date.return_value = test_date + mock_datetime.strptime.return_value.date.return_value = test_date + + mock_user_obj = MagicMock() + mock_user_obj.id = 1 + mock_user.query.filter_by.return_value.first.return_value = mock_user_obj + + mock_user_addiction_obj = MagicMock() + mock_user_addiction_obj.id = 10 + mock_user_addiction.query.filter_by.return_value.first.return_value = mock_user_addiction_obj + + # No existing daily log + mock_daily_log.query.filter.return_value.first.return_value = None + + # Act + result = DaysService.update_day_status("testuser", "2026-01-07", "success") + + # Assert + self.assertIsInstance(result, DayEntryResponse) + self.assertEqual(result.date, "2026-01-07") + self.assertEqual(result.day_of_week, "śr") # Wednesday + self.assertEqual(result.status, "success") + mock_session.add.assert_called_once() + mock_session.commit.assert_called_once() + + @patch('app.services.days_service.db.session') + @patch('app.services.days_service.DailyLog') + @patch('app.services.days_service.UserAddiction') + @patch('app.services.days_service.User') + @patch('app.services.days_service.datetime') + def test_update_day_status_to_failure_updates_existing_entry(self, mock_datetime, mock_user, + mock_user_addiction, mock_daily_log, mock_session): + """Test updating status to failure updates existing DailyLog entry""" + # Arrange + test_date = date(2026, 1, 7) + mock_datetime.now.return_value.date.return_value = test_date + mock_datetime.strptime.return_value.date.return_value = test_date + + mock_user_obj = MagicMock() + mock_user_obj.id = 1 + mock_user.query.filter_by.return_value.first.return_value = mock_user_obj + + mock_user_addiction_obj = MagicMock() + mock_user_addiction_obj.id = 10 + mock_user_addiction.query.filter_by.return_value.first.return_value = mock_user_addiction_obj + + # Existing daily log + mock_log = MagicMock() + mock_log.relapse = 0 + mock_daily_log.query.filter.return_value.first.return_value = mock_log + + # Act + result = DaysService.update_day_status("testuser", "2026-01-07", "failure") + + # Assert + self.assertEqual(result.status, "failure") + self.assertEqual(mock_log.relapse, 1) + self.assertEqual(mock_log.mood, 5) # Default mood + mock_session.commit.assert_called_once() + + @patch('app.services.days_service.db.session') + @patch('app.services.days_service.DailyLog') + @patch('app.services.days_service.UserAddiction') + @patch('app.services.days_service.User') + @patch('app.services.days_service.datetime') + def test_update_day_status_to_none_deletes_entry(self, mock_datetime, mock_user, + mock_user_addiction, mock_daily_log, mock_session): + """Test updating status to none deletes existing DailyLog entry""" + # Arrange + test_date = date(2026, 1, 7) + mock_datetime.now.return_value.date.return_value = test_date + mock_datetime.strptime.return_value.date.return_value = test_date + + mock_user_obj = MagicMock() + mock_user_obj.id = 1 + mock_user.query.filter_by.return_value.first.return_value = mock_user_obj + + mock_user_addiction_obj = MagicMock() + mock_user_addiction_obj.id = 10 + mock_user_addiction.query.filter_by.return_value.first.return_value = mock_user_addiction_obj + + # Existing daily log + mock_log = MagicMock() + mock_daily_log.query.filter.return_value.first.return_value = mock_log + + # Act + result = DaysService.update_day_status("testuser", "2026-01-07", "none") + + # Assert + self.assertEqual(result.status, "none") + mock_session.delete.assert_called_once_with(mock_log) + mock_session.commit.assert_called_once() + + @patch('app.services.days_service.db.session') + @patch('app.services.days_service.UserAddiction') + @patch('app.services.days_service.User') + @patch('app.services.days_service.datetime') + def test_update_day_status_future_date_raises_exception(self, mock_datetime, mock_user, + mock_user_addiction, mock_session): + """Test updating status for future date raises exception""" + # Arrange + today = date(2026, 1, 7) + future_date = date(2026, 1, 8) + mock_datetime.now.return_value.date.return_value = today + mock_datetime.strptime.return_value.date.return_value = future_date + + mock_user_obj = MagicMock() + mock_user_obj.id = 1 + mock_user.query.filter_by.return_value.first.return_value = mock_user_obj + + mock_user_addiction_obj = MagicMock() + mock_user_addiction.query.filter_by.return_value.first.return_value = mock_user_addiction_obj + + # Act & Assert + with self.assertRaises(Exception) as context: + DaysService.update_day_status("testuser", "2026-01-08", "success") + + self.assertIn("future", str(context.exception).lower()) + mock_session.rollback.assert_called_once() + + @patch('app.services.days_service.User') + @patch('app.services.days_service.datetime') + def test_update_day_status_user_not_found(self, mock_datetime, mock_user): + """Test updating status when user not found raises exception""" + # Arrange + mock_datetime.now.return_value.date.return_value = date(2026, 1, 7) + mock_datetime.strptime.return_value.date.return_value = date(2026, 1, 7) + mock_user.query.filter_by.return_value.first.return_value = None + + # Act & Assert + with self.assertRaises(Exception) as context: + DaysService.update_day_status("nonexistent", "2026-01-07", "success") + + self.assertIn("User 'nonexistent' not found", str(context.exception)) + + @patch('app.services.days_service.UserAddiction') + @patch('app.services.days_service.User') + @patch('app.services.days_service.datetime') + def test_update_day_status_no_addiction(self, mock_datetime, mock_user, mock_user_addiction): + """Test updating status when user has no addiction raises exception""" + # Arrange + mock_datetime.now.return_value.date.return_value = date(2026, 1, 7) + mock_datetime.strptime.return_value.date.return_value = date(2026, 1, 7) + + mock_user_obj = MagicMock() + mock_user_obj.id = 1 + mock_user.query.filter_by.return_value.first.return_value = mock_user_obj + mock_user_addiction.query.filter_by.return_value.first.return_value = None + + # Act & Assert + with self.assertRaises(Exception) as context: + DaysService.update_day_status("testuser", "2026-01-07", "success") + + self.assertIn("No addiction found for user 'testuser'", str(context.exception)) + + @patch('app.services.days_service.datetime') + def test_update_day_status_invalid_date_format(self, mock_datetime): + """Test updating status with invalid date format raises exception""" + # Arrange + mock_datetime.now.return_value.date.return_value = date(2026, 1, 7) + mock_datetime.strptime.side_effect = ValueError("Invalid date") + + # Act & Assert + with self.assertRaises(Exception) as context: + DaysService.update_day_status("testuser", "invalid-date", "success") + + self.assertIn("Invalid date format", str(context.exception)) + + def test_serialize_day_entry_response(self): + """Test serialization of DayEntryResponse""" + # Arrange + day_entry = DayEntryResponse( + date="2026-01-07", + day_of_week="wt", + status="success" + ) + + # Act + result = DaysService.serialize_day_entry_response(day_entry) + + # Assert + expected = { + 'date': '2026-01-07', + 'dayOfWeek': 'wt', + 'status': 'success' + } + self.assertEqual(result, expected) From 715e28085b43fdc34272e03679ecbff30f835df6 Mon Sep 17 00:00:00 2001 From: anonim Date: Thu, 8 Jan 2026 21:13:05 +0100 Subject: [PATCH 2/2] add PATCH /api/days/{date} --- backend/app/routes/dashboard.py | 3 --- backend/app/routes/days.py | 7 +++---- backend/app/routes/progress.py | 3 --- backend/tests/test_days_service.py | 12 +++++++++--- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/backend/app/routes/dashboard.py b/backend/app/routes/dashboard.py index bcb65dd..609160e 100644 --- a/backend/app/routes/dashboard.py +++ b/backend/app/routes/dashboard.py @@ -14,9 +14,6 @@ def get_dashboard(): status of all days (from start date to today), and current success streak. Dates calculated according to Europe/Warsaw timezone (can be overridden with X-Timezone header). - Args: - current_user (str): Username from JWT token - Returns: JSON: Dashboard data """ diff --git a/backend/app/routes/days.py b/backend/app/routes/days.py index ab88da6..57762dc 100644 --- a/backend/app/routes/days.py +++ b/backend/app/routes/days.py @@ -1,4 +1,4 @@ -from flask import Blueprint, jsonify, request +from flask import Blueprint, jsonify, request, g from app.services.days_service import DaysService from app.routes.authorization import token_required @@ -6,7 +6,7 @@ @days_bp.route('/api/days/', methods=['PATCH']) @token_required -def update_day_status(current_user, date): +def update_day_status(date): """ Endpoint to update status for a specific day. @@ -15,7 +15,6 @@ def update_day_status(current_user, date): Operation is idempotent - setting the same status returns 200 with current entry. Args: - current_user (str): Username from JWT token date (str): Date in YYYY-MM-DD format Returns: @@ -45,7 +44,7 @@ def update_day_status(current_user, date): timezone = request.headers.get('X-Timezone', 'Europe/Warsaw') # Update day status - day_entry = DaysService.update_day_status(current_user, date, status, timezone) + day_entry = DaysService.update_day_status(g.current_user, date, status, timezone) serialized_data = DaysService.serialize_day_entry_response(day_entry) return jsonify(serialized_data), 200 diff --git a/backend/app/routes/progress.py b/backend/app/routes/progress.py index 239dc7f..67d30f9 100644 --- a/backend/app/routes/progress.py +++ b/backend/app/routes/progress.py @@ -14,9 +14,6 @@ def get_progress(): total savings, and all day entries from start date to today. Dates calculated according to Europe/Warsaw timezone (can be overridden with X-Timezone header). - Args: - current_user (str): Username from JWT token - Returns: JSON: Progress data """ diff --git a/backend/tests/test_days_service.py b/backend/tests/test_days_service.py index d389442..159ffb3 100644 --- a/backend/tests/test_days_service.py +++ b/backend/tests/test_days_service.py @@ -142,9 +142,10 @@ def test_update_day_status_future_date_raises_exception(self, mock_datetime, moc self.assertIn("future", str(context.exception).lower()) mock_session.rollback.assert_called_once() + @patch('app.services.days_service.db.session') @patch('app.services.days_service.User') @patch('app.services.days_service.datetime') - def test_update_day_status_user_not_found(self, mock_datetime, mock_user): + def test_update_day_status_user_not_found(self, mock_datetime, mock_user, mock_session): """Test updating status when user not found raises exception""" # Arrange mock_datetime.now.return_value.date.return_value = date(2026, 1, 7) @@ -156,11 +157,13 @@ def test_update_day_status_user_not_found(self, mock_datetime, mock_user): DaysService.update_day_status("nonexistent", "2026-01-07", "success") self.assertIn("User 'nonexistent' not found", str(context.exception)) + mock_session.rollback.assert_called_once() + @patch('app.services.days_service.db.session') @patch('app.services.days_service.UserAddiction') @patch('app.services.days_service.User') @patch('app.services.days_service.datetime') - def test_update_day_status_no_addiction(self, mock_datetime, mock_user, mock_user_addiction): + def test_update_day_status_no_addiction(self, mock_datetime, mock_user, mock_user_addiction, mock_session): """Test updating status when user has no addiction raises exception""" # Arrange mock_datetime.now.return_value.date.return_value = date(2026, 1, 7) @@ -176,9 +179,11 @@ def test_update_day_status_no_addiction(self, mock_datetime, mock_user, mock_use DaysService.update_day_status("testuser", "2026-01-07", "success") self.assertIn("No addiction found for user 'testuser'", str(context.exception)) + mock_session.rollback.assert_called_once() + @patch('app.services.days_service.db.session') @patch('app.services.days_service.datetime') - def test_update_day_status_invalid_date_format(self, mock_datetime): + def test_update_day_status_invalid_date_format(self, mock_datetime, mock_session): """Test updating status with invalid date format raises exception""" # Arrange mock_datetime.now.return_value.date.return_value = date(2026, 1, 7) @@ -189,6 +194,7 @@ def test_update_day_status_invalid_date_format(self, mock_datetime): DaysService.update_day_status("testuser", "invalid-date", "success") self.assertIn("Invalid date format", str(context.exception)) + mock_session.rollback.assert_called_once() def test_serialize_day_entry_response(self): """Test serialization of DayEntryResponse"""