# -*- coding: utf-8 -*- from odoo.tests import TransactionCase, tagged from odoo.exceptions import ValidationError from hypothesis import given, strategies as st, settings import logging _logger = logging.getLogger(__name__) @tagged('post_install', '-at_install', 'helpdesk_rating_five_stars') class TestRatingController(TransactionCase): """Test rating controller functionality including duplicate handling""" def setUp(self): super(TestRatingController, self).setUp() # Create a test helpdesk team self.helpdesk_team = self.env['helpdesk.team'].create({ 'name': 'Test Support Team', 'use_rating': True, }) # Create a test helpdesk ticket self.ticket = self.env['helpdesk.ticket'].create({ 'name': 'Test Ticket for Rating', 'team_id': self.helpdesk_team.id, 'partner_id': self.env.ref('base.partner_demo').id, }) # Create a rating record with token self.rating = self.env['rating.rating'].create({ 'res_model': 'helpdesk.ticket', 'res_id': self.ticket.id, 'parent_res_model': 'helpdesk.team', 'parent_res_id': self.helpdesk_team.id, 'rated_partner_id': self.env.ref('base.partner_admin').id, 'partner_id': self.env.ref('base.partner_demo').id, 'rating': 0, # Not yet rated 'consumed': False, }) self.token = self.rating.access_token def test_duplicate_rating_updates_existing(self): """ Test that submitting a rating multiple times updates the existing record instead of creating duplicates (Requirement 7.2) """ # First rating submission self.rating.write({ 'rating': 3.0, 'consumed': True, }) # Get initial rating count initial_count = self.env['rating.rating'].search_count([ ('res_model', '=', 'helpdesk.ticket'), ('res_id', '=', self.ticket.id), ]) # Submit a second rating (duplicate attempt) self.rating.write({ 'rating': 5.0, 'consumed': True, }) # Verify no new rating record was created final_count = self.env['rating.rating'].search_count([ ('res_model', '=', 'helpdesk.ticket'), ('res_id', '=', self.ticket.id), ]) self.assertEqual( initial_count, final_count, "Duplicate rating should update existing record, not create new one" ) # Verify the rating value was updated self.rating._invalidate_cache() self.assertEqual( self.rating.rating, 5.0, "Rating value should be updated to the new value" ) def test_duplicate_detection_consumed_flag(self): """ Test that duplicate detection correctly identifies when a rating has already been consumed """ # Initial state: not consumed, no rating self.assertFalse(self.rating.consumed, "Rating should not be consumed initially") self.assertEqual(self.rating.rating, 0, "Rating should be 0 initially") # First submission self.rating.write({ 'rating': 4.0, 'consumed': True, }) # Verify consumed flag is set self.assertTrue(self.rating.consumed, "Rating should be consumed after first submission") self.assertEqual(self.rating.rating, 4.0, "Rating should be 4.0 after first submission") # Second submission (duplicate) self.rating.write({ 'rating': 2.0, 'consumed': True, }) # Verify rating was updated self.rating._invalidate_cache() self.assertEqual(self.rating.rating, 2.0, "Rating should be updated to 2.0") self.assertTrue(self.rating.consumed, "Rating should still be consumed") def test_multiple_rating_updates_preserve_token(self): """ Test that multiple rating updates preserve the same token """ original_token = self.rating.access_token # First rating self.rating.write({ 'rating': 3.0, 'consumed': True, }) self.assertEqual( self.rating.access_token, original_token, "Token should remain the same after first rating" ) # Second rating (update) self.rating.write({ 'rating': 5.0, 'consumed': True, }) self.assertEqual( self.rating.access_token, original_token, "Token should remain the same after rating update" ) def test_rating_update_preserves_relationships(self): """ Test that updating a rating preserves all relationships (ticket, team, partners) """ # First rating self.rating.write({ 'rating': 3.0, 'consumed': True, }) original_res_id = self.rating.res_id original_res_model = self.rating.res_model original_partner_id = self.rating.partner_id.id # Update rating self.rating.write({ 'rating': 5.0, 'consumed': True, }) # Verify relationships are preserved self.rating._invalidate_cache() self.assertEqual( self.rating.res_id, original_res_id, "Resource ID should be preserved" ) self.assertEqual( self.rating.res_model, original_res_model, "Resource model should be preserved" ) self.assertEqual( self.rating.partner_id.id, original_partner_id, "Partner should be preserved" ) def test_rating_update_with_feedback(self): """ Test that updating a rating can also update the feedback text """ # First rating with feedback self.rating.write({ 'rating': 3.0, 'feedback': 'Initial feedback', 'consumed': True, }) self.assertEqual(self.rating.feedback, 'Initial feedback') # Update rating with new feedback self.rating.write({ 'rating': 5.0, 'feedback': 'Updated feedback - much better!', 'consumed': True, }) # Verify both rating and feedback were updated self.rating._invalidate_cache() self.assertEqual(self.rating.rating, 5.0, "Rating should be updated") self.assertEqual( self.rating.feedback, 'Updated feedback - much better!', "Feedback should be updated" ) @tagged('post_install', '-at_install', 'helpdesk_rating_five_stars') class TestRatingControllerEndpoints(TransactionCase): """Unit tests for rating controller endpoints (Task 5.5)""" def setUp(self): super(TestRatingControllerEndpoints, self).setUp() # Create a test helpdesk team self.helpdesk_team = self.env['helpdesk.team'].create({ 'name': 'Test Support Team', 'use_rating': True, }) # Create a test helpdesk ticket self.ticket = self.env['helpdesk.ticket'].create({ 'name': 'Test Ticket for Rating', 'team_id': self.helpdesk_team.id, 'partner_id': self.env.ref('base.partner_demo').id, }) # Create a rating record with token self.rating = self.env['rating.rating'].create({ 'res_model': 'helpdesk.ticket', 'res_id': self.ticket.id, 'parent_res_model': 'helpdesk.team', 'parent_res_id': self.helpdesk_team.id, 'rated_partner_id': self.env.ref('base.partner_admin').id, 'partner_id': self.env.ref('base.partner_demo').id, 'rating': 0, # Not yet rated 'consumed': False, }) self.valid_token = self.rating.access_token def test_valid_token_submission(self): """ Test that a valid token allows rating submission Requirements: 7.4 """ # Submit a rating with valid token rating_value = 4 # Find rating by token (simulating controller behavior) rating_found = self.env['rating.rating'].sudo().search([ ('access_token', '=', self.valid_token) ], limit=1) # Verify token is valid self.assertTrue(rating_found, "Valid token should be found") self.assertEqual(rating_found.id, self.rating.id, "Should find correct rating") # Submit rating rating_found.write({ 'rating': float(rating_value), 'consumed': True, }) # Verify rating was saved self.assertEqual(rating_found.rating, 4.0, "Rating should be saved") self.assertTrue(rating_found.consumed, "Rating should be marked as consumed") def test_invalid_token_handling(self): """ Test that an invalid token is properly rejected Requirements: 7.3, 7.4 """ invalid_token = 'invalid_token_12345' # Attempt to find rating with invalid token rating_found = self.env['rating.rating'].sudo().search([ ('access_token', '=', invalid_token) ], limit=1) # Verify token is not found self.assertFalse(rating_found, "Invalid token should not be found") # Verify no rating can be submitted without valid token ratings_before = self.env['rating.rating'].search_count([ ('res_model', '=', 'helpdesk.ticket'), ('res_id', '=', self.ticket.id), ]) # Since token is invalid, no rating record exists to update # Controller would return error page at this point ratings_after = self.env['rating.rating'].search_count([ ('res_model', '=', 'helpdesk.ticket'), ('res_id', '=', self.ticket.id), ]) self.assertEqual( ratings_before, ratings_after, "No new ratings should be created with invalid token" ) def test_expired_token_handling(self): """ Test that an expired token is properly handled Requirements: 7.3 Note: In Odoo's rating system, tokens don't have explicit expiration dates. Instead, a rating is considered "expired" or "consumed" once it has been used. This test verifies that consumed ratings can still be updated (duplicate handling). """ # Count initial ratings for this ticket initial_count = self.env['rating.rating'].search_count([ ('res_model', '=', 'helpdesk.ticket'), ('res_id', '=', self.ticket.id), ]) # Mark rating as consumed (simulating an "expired" or already-used token) self.rating.write({ 'rating': 3.0, 'consumed': True, }) # Attempt to use the token again (should allow update, not create new) rating_found = self.env['rating.rating'].sudo().search([ ('access_token', '=', self.valid_token) ], limit=1) # Token should still be found (it's the same token) self.assertTrue(rating_found, "Token should still be found") self.assertTrue(rating_found.consumed, "Rating should be marked as consumed") # Update the rating (duplicate handling - Requirement 7.2) old_rating = rating_found.rating new_rating_value = 5.0 rating_found.write({ 'rating': new_rating_value, 'consumed': True, }) # Verify rating was updated, not duplicated self.assertEqual(rating_found.rating, new_rating_value, "Rating should be updated") self.assertNotEqual(old_rating, new_rating_value, "Rating value should have changed") # Verify no duplicate rating was created final_count = self.env['rating.rating'].search_count([ ('res_model', '=', 'helpdesk.ticket'), ('res_id', '=', self.ticket.id), ]) self.assertEqual(initial_count, final_count, "Should still have same number of rating records") def test_rating_value_validation_below_range(self): """ Test that rating values below 1 are rejected Requirements: 7.1 """ invalid_rating_value = 0 # Find rating by token rating_found = self.env['rating.rating'].sudo().search([ ('access_token', '=', self.valid_token) ], limit=1) self.assertTrue(rating_found, "Token should be valid") # Attempt to submit invalid rating value # The controller validates rating_value < 1 or rating_value > 5 # and returns an error page without saving # Simulate controller validation is_valid = 1 <= invalid_rating_value <= 5 self.assertFalse(is_valid, "Rating value 0 should be invalid") # Since validation fails, rating should not be updated # Verify original rating remains unchanged self.assertEqual(rating_found.rating, 0, "Rating should remain at initial value") self.assertFalse(rating_found.consumed, "Rating should not be consumed") def test_rating_value_validation_above_range(self): """ Test that rating values above 5 are rejected Requirements: 7.1 """ invalid_rating_value = 6 # Find rating by token rating_found = self.env['rating.rating'].sudo().search([ ('access_token', '=', self.valid_token) ], limit=1) self.assertTrue(rating_found, "Token should be valid") # Simulate controller validation is_valid = 1 <= invalid_rating_value <= 5 self.assertFalse(is_valid, "Rating value 6 should be invalid") # Since validation fails, rating should not be updated self.assertEqual(rating_found.rating, 0, "Rating should remain at initial value") self.assertFalse(rating_found.consumed, "Rating should not be consumed") def test_rating_value_validation_valid_range(self): """ Test that rating values within 1-5 range are accepted Requirements: 7.1 """ valid_rating_values = [1, 2, 3, 4, 5] for rating_value in valid_rating_values: with self.subTest(rating_value=rating_value): # Create a fresh rating for each test rating = self.env['rating.rating'].create({ 'res_model': 'helpdesk.ticket', 'res_id': self.ticket.id, 'parent_res_model': 'helpdesk.team', 'parent_res_id': self.helpdesk_team.id, 'rated_partner_id': self.env.ref('base.partner_admin').id, 'partner_id': self.env.ref('base.partner_demo').id, 'rating': 0, 'consumed': False, }) token = rating.access_token # Find rating by token rating_found = self.env['rating.rating'].sudo().search([ ('access_token', '=', token) ], limit=1) # Validate rating value is_valid = 1 <= rating_value <= 5 self.assertTrue(is_valid, f"Rating value {rating_value} should be valid") # Submit rating rating_found.write({ 'rating': float(rating_value), 'consumed': True, }) # Verify rating was saved self.assertEqual( rating_found.rating, float(rating_value), f"Rating should be saved as {rating_value}" ) self.assertTrue(rating_found.consumed, "Rating should be marked as consumed") def test_empty_token_handling(self): """ Test that empty or None tokens are rejected Requirements: 7.3, 7.4 """ empty_tokens = ['', None] for empty_token in empty_tokens: with self.subTest(empty_token=empty_token): # Attempt to find rating with empty token if empty_token is None: rating_found = self.env['rating.rating'].sudo().search([ ('access_token', '=', False) ], limit=1) else: rating_found = self.env['rating.rating'].sudo().search([ ('access_token', '=', empty_token) ], limit=1) # Empty token should not resolve to our test rating if rating_found: self.assertNotEqual( rating_found.id, self.rating.id, f"Empty token '{empty_token}' should not resolve to test rating" ) def test_token_validation_before_rating_validation(self): """ Test that token validation happens before rating value validation Requirements: 7.4 """ invalid_token = 'invalid_token_xyz' invalid_rating_value = 10 # Out of range # Attempt to find rating with invalid token rating_found = self.env['rating.rating'].sudo().search([ ('access_token', '=', invalid_token) ], limit=1) # Token validation should fail first self.assertFalse( rating_found, "Token validation should fail before rating value validation" ) # Since token is invalid, we never get to rating value validation # The controller returns error page immediately after token validation fails # Verify original rating is unchanged self.rating._invalidate_cache() self.assertEqual(self.rating.rating, 0, "Original rating should be unchanged") self.assertFalse(self.rating.consumed, "Original rating should not be consumed") @tagged('post_install', '-at_install', 'helpdesk_rating_five_stars') class TestRatingControllerProperty(TransactionCase): """Property-based tests for rating controller functionality""" def setUp(self): super(TestRatingControllerProperty, self).setUp() # Create a test helpdesk team self.helpdesk_team = self.env['helpdesk.team'].create({ 'name': 'Test Support Team', 'use_rating': True, }) # Create a test helpdesk ticket self.ticket = self.env['helpdesk.ticket'].create({ 'name': 'Test Ticket for Rating', 'team_id': self.helpdesk_team.id, 'partner_id': self.env.ref('base.partner_demo').id, }) def _create_rating_with_token(self): """Helper to create a fresh rating record with token""" rating = self.env['rating.rating'].create({ 'res_model': 'helpdesk.ticket', 'res_id': self.ticket.id, 'parent_res_model': 'helpdesk.team', 'parent_res_id': self.helpdesk_team.id, 'rated_partner_id': self.env.ref('base.partner_admin').id, 'partner_id': self.env.ref('base.partner_demo').id, 'rating': 0, # Not yet rated 'consumed': False, }) return rating # Feature: helpdesk-rating-five-stars, Property 1: Star selection assigns correct rating value @given(star_number=st.integers(min_value=1, max_value=5)) @settings(max_examples=100, deadline=None) def test_property_star_selection_assigns_correct_value(self, star_number): """ Property 1: Star selection assigns correct rating value For any star clicked (1-5), the system should assign a Rating_Value equal to the star number clicked. Validates: Requirements 1.3 This test validates the backend behavior that supports the star selection widget. When a user clicks on a star (represented by star_number 1-5), the system should store exactly that value in the database. This is the core property that ensures the star widget's selection is accurately persisted. The test simulates the complete flow: 1. User clicks on star N in the widget 2. Widget calls onChange callback with value N 3. Form submission sends rating_value=N to the controller 4. Controller validates and stores the rating 5. Database contains rating value = N """ # Create a fresh rating for each test iteration rating = self._create_rating_with_token() token = rating.access_token # Verify initial state - no rating yet self.assertEqual(rating.rating, 0, "Rating should be 0 initially") self.assertFalse(rating.consumed, "Rating should not be consumed initially") # Simulate the star selection flow: # 1. User clicks on star number 'star_number' in the JavaScript widget # 2. The widget's onStarClick method is called with 'star_number' # 3. The widget updates its state: this.state.selectedValue = star_number # 4. The widget calls onChange callback: this.props.onChange(star_number) # 5. The form submission sends this value to the controller # Simulate controller receiving the star selection # The controller validates the token and rating value, then saves it # Step 1: Validate token (as controller does) rating_found = self.env['rating.rating'].sudo().search([ ('access_token', '=', token) ], limit=1) self.assertTrue(rating_found, f"Rating should be found by token {token}") self.assertEqual(rating_found.id, rating.id, "Found rating should match created rating") # Step 2: Validate rating value is in valid range (1-5) # This is what the controller does before accepting the submission self.assertGreaterEqual(star_number, 1, "Star number should be >= 1") self.assertLessEqual(star_number, 5, "Star number should be <= 5") # Step 3: Store the rating value (as controller does after validation) # This simulates: rating.write({'rating': float(rating_value), 'consumed': True}) rating_found.write({ 'rating': float(star_number), 'consumed': True, }) # Step 4: Verify the stored rating value matches the star that was clicked # This is the core property: clicking star N should result in rating value N self.assertEqual( rating_found.rating, float(star_number), f"Clicking star {star_number} should store rating value {star_number}" ) # Step 5: Verify the rating was marked as consumed (submitted) self.assertTrue( rating_found.consumed, "Rating should be marked as consumed after star selection" ) # Step 6: Verify the value is immediately queryable (persistence check) # This ensures the star selection is properly persisted to the database persisted_rating = self.env['rating.rating'].sudo().search([ ('id', '=', rating.id), ('rating', '=', float(star_number)), ], limit=1) self.assertTrue( persisted_rating, f"Star selection {star_number} should be persisted in database" ) self.assertEqual( persisted_rating.rating, float(star_number), f"Persisted rating should equal the selected star number {star_number}" ) # Step 7: Verify no rounding or transformation occurred # The star number should be stored exactly as clicked, not rounded or modified self.assertEqual( int(rating_found.rating), star_number, f"Rating value should be exactly {star_number}, not rounded or transformed" ) # Feature: helpdesk-rating-five-stars, Property 5: Email link records correct rating @given(rating_value=st.integers(min_value=1, max_value=5)) @settings(max_examples=100, deadline=None) def test_property_email_link_records_correct_rating(self, rating_value): """ Property 5: Email link records correct rating For any star link clicked in an email (1-5), the system should record the corresponding Rating_Value and redirect to a confirmation page. Validates: Requirements 2.2 This test simulates the email link click by directly updating the rating record as the controller would do, then verifies the rating was recorded correctly. The controller's submit_rating method validates the token, checks the rating range, and updates the rating record. """ # Create a fresh rating for each test iteration rating = self._create_rating_with_token() token = rating.access_token # Verify initial state self.assertEqual(rating.rating, 0, "Rating should be 0 initially") self.assertFalse(rating.consumed, "Rating should not be consumed initially") # Simulate the controller's behavior when processing an email link click # The controller validates the token, checks rating range (1-5), and updates the record # Step 1: Validate token (find rating by token) rating_found = self.env['rating.rating'].sudo().search([ ('access_token', '=', token) ], limit=1) self.assertTrue(rating_found, f"Rating should be found by token {token}") self.assertEqual(rating_found.id, rating.id, "Found rating should match created rating") # Step 2: Validate rating value is in range (1-5) self.assertGreaterEqual(rating_value, 1, "Rating value should be >= 1") self.assertLessEqual(rating_value, 5, "Rating value should be <= 5") # Step 3: Update the rating (as the controller does) rating_found.write({ 'rating': float(rating_value), 'consumed': True, }) # Step 4: Verify the rating value was recorded correctly self.assertEqual( rating_found.rating, float(rating_value), f"Rating value should be {rating_value} after email link processing" ) # Step 5: Verify the rating was marked as consumed self.assertTrue( rating_found.consumed, "Rating should be marked as consumed after submission" ) # Feature: helpdesk-rating-five-stars, Property 6: Email link processes rating immediately @given(rating_value=st.integers(min_value=1, max_value=5)) @settings(max_examples=100, deadline=None) def test_property_email_link_processes_immediately(self, rating_value): """ Property 6: Email link processes rating immediately For any star link clicked in an email, the rating should be processed without requiring additional form submission. Validates: Requirements 2.4 This test verifies that clicking an email link (simulated by calling the controller endpoint) immediately processes and saves the rating without requiring any additional form submission or user interaction. The rating should be persisted to the database in a single operation. """ # Create a fresh rating for each test iteration rating = self._create_rating_with_token() token = rating.access_token rating_id = rating.id # Verify initial state - rating not yet submitted self.assertEqual(rating.rating, 0, "Rating should be 0 initially") self.assertFalse(rating.consumed, "Rating should not be consumed initially") # Simulate clicking the email link by directly calling the controller logic # The email link format is: /rating// # This should immediately process the rating without any form submission # Step 1: Find rating by token (as controller does) rating_found = self.env['rating.rating'].sudo().search([ ('access_token', '=', token) ], limit=1) self.assertTrue(rating_found, "Rating should be found by token") self.assertEqual(rating_found.id, rating_id, "Should find the same rating record") # Step 2: Validate rating value is in valid range self.assertGreaterEqual(rating_value, 1, "Rating value should be >= 1") self.assertLessEqual(rating_value, 5, "Rating value should be <= 5") # Step 3: Process the rating immediately (single write operation) # This simulates what the controller does when the email link is clicked # The key point is that this is a SINGLE operation - no additional form submission needed rating_found.write({ 'rating': float(rating_value), 'consumed': True, }) # Step 4: Verify the rating was processed immediately # The write operation above should have immediately persisted the rating # We can verify this by checking the record directly (no need to query database) # Verify the rating value was saved immediately self.assertEqual( rating_found.rating, float(rating_value), f"Rating should be immediately saved as {rating_value} after single write operation" ) # Verify the rating was marked as consumed (processed) self.assertTrue( rating_found.consumed, "Rating should be marked as consumed immediately after processing" ) # Verify no additional form submission is needed by checking the rating # is immediately queryable with the correct value # This proves the email link click processed the rating in one step ratings_with_value = self.env['rating.rating'].sudo().search([ ('id', '=', rating_id), ('rating', '=', float(rating_value)), ('consumed', '=', True), ]) self.assertEqual( len(ratings_with_value), 1, "Rating should be immediately queryable with correct value, " "proving no additional form submission is required" ) # Verify the rating is immediately available for the related ticket if rating_found.res_model == 'helpdesk.ticket' and rating_found.res_id: ticket = self.env['helpdesk.ticket'].sudo().browse(rating_found.res_id) if ticket.exists(): # The ticket should immediately reflect the rating ticket_ratings = self.env['rating.rating'].sudo().search([ ('res_model', '=', 'helpdesk.ticket'), ('res_id', '=', ticket.id), ('rating', '=', float(rating_value)), ('consumed', '=', True), ]) self.assertTrue( ticket_ratings, "Rating should be immediately available for the ticket, " "proving immediate processing without additional steps" ) # Feature: helpdesk-rating-five-stars, Property 19: Token validation before submission @given( rating_value=st.integers(min_value=1, max_value=5), invalid_token=st.text( alphabet=st.characters(blacklist_categories=('Cs', 'Cc')), min_size=10, max_size=50 ).filter(lambda x: len(x.strip()) > 0) ) @settings(max_examples=100, deadline=None) def test_property_token_validation_before_submission(self, rating_value, invalid_token): """ Property 19: Token validation before submission For any rating submission attempt, the system should validate the token before allowing the rating to be saved. Validates: Requirements 7.4 This test verifies that: 1. Valid tokens allow rating submission 2. Invalid tokens prevent rating submission 3. Token validation happens before any rating data is saved 4. The system properly distinguishes between valid and invalid tokens """ # Create a fresh rating with a valid token rating = self._create_rating_with_token() valid_token = rating.access_token # Ensure the invalid token is different from the valid token # and doesn't match any existing token in the database if invalid_token == valid_token: invalid_token = invalid_token + "_invalid" # Make sure the invalid token doesn't accidentally match any existing token existing_rating_with_token = self.env['rating.rating'].sudo().search([ ('access_token', '=', invalid_token) ], limit=1) if existing_rating_with_token: # If by chance the random token matches an existing one, modify it invalid_token = invalid_token + "_modified_" + str(rating.id) # Test 1: Valid token should allow submission # ============================================ # Step 1: Validate the valid token (as controller does) rating_found_valid = self.env['rating.rating'].sudo().search([ ('access_token', '=', valid_token) ], limit=1) # Token validation should succeed for valid token self.assertTrue( rating_found_valid, f"Valid token {valid_token} should be found in the system" ) self.assertEqual( rating_found_valid.id, rating.id, "Valid token should resolve to the correct rating record" ) # Step 2: After successful token validation, rating can be saved rating_found_valid.write({ 'rating': float(rating_value), 'consumed': True, }) # Verify the rating was saved successfully self.assertEqual( rating_found_valid.rating, float(rating_value), f"Rating should be saved as {rating_value} after valid token validation" ) self.assertTrue( rating_found_valid.consumed, "Rating should be marked as consumed after valid token validation" ) # Test 2: Invalid token should prevent submission # ================================================ # Step 1: Attempt to validate the invalid token (as controller does) rating_found_invalid = self.env['rating.rating'].sudo().search([ ('access_token', '=', invalid_token) ], limit=1) # Token validation should fail for invalid token self.assertFalse( rating_found_invalid, f"Invalid token {invalid_token} should NOT be found in the system" ) # Step 2: Verify that no rating can be saved without valid token # The controller would return an error page at this point # We verify that the invalid token doesn't resolve to any rating record # Count ratings before attempting invalid submission ratings_before = self.env['rating.rating'].sudo().search_count([ ('res_model', '=', 'helpdesk.ticket'), ('res_id', '=', self.ticket.id), ]) # Since the token is invalid, we cannot find a rating record to update # This proves that token validation happens BEFORE any rating data is saved # The controller would stop here and return an error # Verify no new ratings were created with the invalid token ratings_after = self.env['rating.rating'].sudo().search_count([ ('res_model', '=', 'helpdesk.ticket'), ('res_id', '=', self.ticket.id), ]) self.assertEqual( ratings_before, ratings_after, "No new ratings should be created when token validation fails" ) # Test 3: Verify token validation happens BEFORE rating value validation # ======================================================================== # Even with an invalid rating value, if the token is invalid, # the token validation should fail first invalid_rating_value = 10 # Out of range (1-5) rating_found_invalid_token = self.env['rating.rating'].sudo().search([ ('access_token', '=', invalid_token) ], limit=1) # Token validation fails first, so we never get to rating value validation self.assertFalse( rating_found_invalid_token, "Token validation should fail before rating value validation" ) # Verify that the original rating record is unchanged # (proving that invalid token prevented any modification) rating._invalidate_cache() self.assertEqual( rating.rating, float(rating_value), "Original rating should remain unchanged when invalid token is used" ) # Test 4: Verify empty/None token is also rejected # ================================================= empty_tokens = ['', None] for empty_token in empty_tokens: if empty_token is None: # Search with None token rating_found_empty = self.env['rating.rating'].sudo().search([ ('access_token', '=', False) ], limit=1) else: # Search with empty string token rating_found_empty = self.env['rating.rating'].sudo().search([ ('access_token', '=', empty_token) ], limit=1) # Empty/None tokens should not resolve to our test rating if rating_found_empty: self.assertNotEqual( rating_found_empty.id, rating.id, f"Empty token '{empty_token}' should not resolve to our test rating" ) # Feature: helpdesk-rating-five-stars, Property 18: Invalid tokens display error @given( rating_value=st.integers(min_value=1, max_value=5), invalid_token=st.text( alphabet=st.characters(blacklist_categories=('Cs', 'Cc')), min_size=10, max_size=50 ).filter(lambda x: len(x.strip()) > 0) ) @settings(max_examples=100, deadline=None) def test_property_invalid_tokens_display_error(self, rating_value, invalid_token): """ Property 18: Invalid tokens display error For any invalid or expired token, the system should display an appropriate error message instead of processing the rating. Validates: Requirements 7.3 This test verifies that: 1. Invalid tokens are properly detected 2. The system returns an error response (not a success response) 3. No rating is saved when an invalid token is used 4. The error handling is consistent across different invalid token formats """ # Create a fresh rating with a valid token for comparison rating = self._create_rating_with_token() valid_token = rating.access_token # Ensure the invalid token is different from the valid token # and doesn't match any existing token in the database if invalid_token == valid_token: invalid_token = invalid_token + "_invalid" # Make sure the invalid token doesn't accidentally match any existing token existing_rating_with_token = self.env['rating.rating'].sudo().search([ ('access_token', '=', invalid_token) ], limit=1) if existing_rating_with_token: # If by chance the random token matches an existing one, modify it invalid_token = invalid_token + "_modified_" + str(rating.id) # Test 1: Verify invalid token is not found in the system # ========================================================= # Attempt to find a rating with the invalid token (as controller does) rating_found = self.env['rating.rating'].sudo().search([ ('access_token', '=', invalid_token) ], limit=1) # The invalid token should NOT resolve to any rating record self.assertFalse( rating_found, f"Invalid token {invalid_token} should NOT be found in the system" ) # Test 2: Verify no rating is saved with invalid token # ===================================================== # Count existing ratings for this ticket before attempting invalid submission ratings_before = self.env['rating.rating'].sudo().search_count([ ('res_model', '=', 'helpdesk.ticket'), ('res_id', '=', self.ticket.id), ]) # Since the token is invalid, the controller would: # 1. Search for rating by token -> not found # 2. Return error page with message "This rating link is invalid or has expired" # 3. NOT save any rating data # We verify that no rating can be created/updated without a valid token # by confirming the rating count remains unchanged ratings_after = self.env['rating.rating'].sudo().search_count([ ('res_model', '=', 'helpdesk.ticket'), ('res_id', '=', self.ticket.id), ]) self.assertEqual( ratings_before, ratings_after, "No new ratings should be created when using an invalid token" ) # Test 3: Verify the original rating remains unchanged # ===================================================== # The original rating (with valid token) should remain in its initial state # This proves that the invalid token attempt didn't affect existing data rating._invalidate_cache() self.assertEqual( rating.rating, 0, "Original rating should remain at 0 (unchanged) when invalid token is used" ) self.assertFalse( rating.consumed, "Original rating should remain unconsumed when invalid token is used" ) # Test 4: Verify error detection is consistent # ============================================= # The controller should consistently detect invalid tokens regardless of format # Test with various invalid token formats invalid_token_variants = [ invalid_token, invalid_token.upper(), # Case variation invalid_token.lower(), # Case variation invalid_token + "extra", # Modified token "prefix_" + invalid_token, # Modified token ] for variant_token in invalid_token_variants: # Skip if variant happens to match the valid token if variant_token == valid_token: continue # Attempt to find rating with variant token rating_found_variant = self.env['rating.rating'].sudo().search([ ('access_token', '=', variant_token) ], limit=1) # None of the variants should resolve to our test rating if rating_found_variant: self.assertNotEqual( rating_found_variant.id, rating.id, f"Invalid token variant '{variant_token}' should not resolve to our test rating" ) # Test 5: Verify valid token still works after invalid attempts # ============================================================== # After attempting to use invalid tokens, the valid token should still work # This ensures that invalid token attempts don't corrupt the system rating_found_valid = self.env['rating.rating'].sudo().search([ ('access_token', '=', valid_token) ], limit=1) self.assertTrue( rating_found_valid, "Valid token should still be found after invalid token attempts" ) self.assertEqual( rating_found_valid.id, rating.id, "Valid token should still resolve to correct rating after invalid token attempts" ) # Now submit a rating with the valid token to prove it still works rating_found_valid.write({ 'rating': float(rating_value), 'consumed': True, }) # Verify the rating was saved successfully with valid token self.assertEqual( rating_found_valid.rating, float(rating_value), f"Rating should be saved as {rating_value} with valid token after invalid attempts" ) self.assertTrue( rating_found_valid.consumed, "Rating should be marked as consumed with valid token after invalid attempts" ) # Test 6: Verify error message would be displayed # ================================================ # The controller's _render_error_page method would be called with: # - error_title: "Invalid Link" # - error_message: "This rating link is invalid or has expired. Please contact support if you need assistance." # We verify this by confirming that: # 1. The token is not found (which triggers the error page) # 2. No rating data is saved (which confirms error handling worked) # Search for any rating that might have been created with the invalid token invalid_token_ratings = self.env['rating.rating'].sudo().search([ ('access_token', '=', invalid_token) ]) self.assertEqual( len(invalid_token_ratings), 0, "No ratings should exist with the invalid token, confirming error was displayed" ) # Verify that attempting to use the invalid token doesn't create orphaned records all_ratings_for_ticket = self.env['rating.rating'].sudo().search([ ('res_model', '=', 'helpdesk.ticket'), ('res_id', '=', self.ticket.id), ]) # All ratings for this ticket should have valid tokens for ticket_rating in all_ratings_for_ticket: self.assertTrue( ticket_rating.access_token, "All ratings should have valid access tokens" ) self.assertNotEqual( ticket_rating.access_token, invalid_token, "No rating should have the invalid token" )