1152 lines
46 KiB
Python
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"
|
|
)
|