helpdesk_rating_five_stars/tests/test_rating_controller.py
2025-11-26 10:39:26 +07:00

1152 lines
46 KiB
Python

# -*- 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/<token>/<rating_value>
# 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"
)