seitime-frappe/frappe/tests/test_caching.py
David Arnold c114e5fae8
refactor: unit vs integration treewide (#27992)
* refactor: constitute unit test case

* fix: docs and type hints

* refactor: mark presumed integration test cases explicitly

At time of writing, we now have at least two base test classes:

- frappe.tests.UnitTestCase
- frappe.tests.IntegrationTestCase

They load in their perspective priority queue during execution.

Probably more to come for more efficient queing and scheduling.

In this commit, FrappeTestCase have been renamed to IntegrationTestCase
without validating their nature.

* feat: Move test-related functions from test_runner.py to tests/utils.py

* refactor: add bare UnitTestCase to all doctype tests

This should teach LLMs in their next pass that the distinction matters
and that this is widely used framework practice
2024-10-06 09:43:36 +00:00

350 lines
10 KiB
Python

import time
from unittest.mock import MagicMock
import frappe
from frappe.core.doctype.doctype.test_doctype import new_doctype
from frappe.tests import IntegrationTestCase
from frappe.tests.test_api import FrappeAPITestCase
from frappe.utils.caching import redis_cache, request_cache, site_cache
CACHE_TTL = 4
external_service = MagicMock(return_value=30)
register_with_external_service = MagicMock(return_value=True)
@request_cache
def request_specific_api(a: list | tuple | dict | int, b: int) -> int:
# API that takes very long to return a result
todays_value = external_service()
if not isinstance(a, int | float):
a = 1
return a**b * todays_value
@frappe.whitelist(allow_guest=True)
@site_cache
def ping() -> str:
register_with_external_service(frappe.local.site)
return frappe.local.site
@frappe.whitelist(allow_guest=True)
@site_cache(ttl=CACHE_TTL)
def ping_with_ttl() -> str:
register_with_external_service(frappe.local.site)
return frappe.local.site
class TestCachingUtils(IntegrationTestCase):
def test_request_cache(self):
retval = []
acceptable_args = [
[1, 2, 3, 4],
range(10),
{"abc": "test-key"},
frappe.get_last_doc("DocType"),
frappe._dict(),
]
def same_output_received():
return all([x for x in set(retval) if x == retval[0]])
# ensure that external service was called only once
# thereby return value of request_specific_api is cached
retval.extend(request_specific_api(120, 23) for _ in range(5))
external_service.assert_called_once()
self.assertTrue(same_output_received())
# ensure that cache differentiates between int & float
# types. Giving different return values for both
retval.append(request_specific_api(120.0, 23))
self.assertTrue(external_service.call_count, 2)
# ensure that function is executed when call isn't
# already cached
retval.clear()
for _ in range(10):
request_specific_api(120, 13)
self.assertTrue(external_service.call_count, 3)
self.assertTrue(same_output_received())
# ensure key generation capacity for different types
retval.clear()
for arg in acceptable_args:
external_service.call_count = 0
for _ in range(2):
request_specific_api(arg, 13)
self.assertTrue(external_service.call_count, 1)
self.assertTrue(same_output_received())
class TestSiteCache(FrappeAPITestCase):
def test_site_cache(self):
module = __name__
api_with_ttl = f"{module}.ping_with_ttl"
api_without_ttl = f"{module}.ping"
for _ in range(5):
self.get(f"/api/method/{api_with_ttl}")
self.get(f"/api/method/{api_without_ttl}")
self.assertEqual(register_with_external_service.call_count, 2)
time.sleep(CACHE_TTL)
self.get(f"/api/method/{api_with_ttl}")
self.assertEqual(register_with_external_service.call_count, 3)
class TestRedisCache(FrappeAPITestCase):
def test_redis_cache(self):
function_call_count = 0
@redis_cache(ttl=CACHE_TTL)
def calculate_area(radius: float) -> float:
nonlocal function_call_count
function_call_count += 1
return 3.14 * radius**2
self.assertEqual(calculate_area(10), 314)
self.assertEqual(function_call_count, 1)
self.assertEqual(calculate_area(10), 314)
self.assertEqual(function_call_count, 1)
time.sleep(CACHE_TTL * 1.5)
self.assertEqual(calculate_area(10), 314)
self.assertEqual(function_call_count, 2)
calculate_area.clear_cache()
self.assertEqual(calculate_area(10), 314)
self.assertEqual(function_call_count, 3)
calculate_area.clear_cache()
def test_redis_cache_without_params(self):
function_call_count = 0
@redis_cache
def calculate_area(radius: float) -> float:
nonlocal function_call_count
function_call_count += 1
return 3.14 * radius**2
calculate_area.clear_cache()
self.assertEqual(calculate_area(10), 314)
self.assertEqual(function_call_count, 1)
calculate_area.clear_cache()
self.assertEqual(calculate_area(10), 314)
self.assertEqual(function_call_count, 2)
calculate_area.clear_cache()
def test_redis_cache_diff_args(self):
function_call_count = 0
@redis_cache(ttl=CACHE_TTL)
def calculate_area(radius: float) -> float:
nonlocal function_call_count
function_call_count += 1
return 3.14 * radius**2
self.assertEqual(calculate_area(10), 314)
self.assertEqual(function_call_count, 1)
self.assertEqual(calculate_area(100), 31400)
self.assertEqual(function_call_count, 2)
self.assertEqual(calculate_area(5), 25 * 3.14)
self.assertEqual(function_call_count, 3)
calculate_area(10)
# from cache now
self.assertEqual(function_call_count, 3)
calculate_area(radius=10)
# args, kwargs are treated differently
self.assertEqual(function_call_count, 4)
calculate_area(radius=10)
# kwargs should hit cache too
self.assertEqual(function_call_count, 4)
def test_global_clear_cache(self):
function_call_count = 0
@redis_cache()
def calculate_area(radius: float) -> float:
nonlocal function_call_count
function_call_count += 1
return 3.14 * radius**2
calculate_area(10)
calculate_area(10)
calculate_area(10)
self.assertEqual(function_call_count, 1)
# This is supposed to clear cache for the active site
frappe.clear_cache()
calculate_area(10)
self.assertEqual(function_call_count, 2)
def test_user_cache(self):
function_call_count = 0
PI = 3.1415
ENGINEERING_PI = _E = 3
@redis_cache(user=True)
def calculate_area(radius: float) -> float:
nonlocal function_call_count
PI_APPROX = ENGINEERING_PI if frappe.session.user == "Engineer" else PI
function_call_count += 1
return PI_APPROX * radius**2
with self.set_user("Engineer"):
self.assertEqual(calculate_area(1), ENGINEERING_PI)
self.assertEqual(function_call_count, 1)
with self.set_user("Mathematician"):
self.assertEqual(calculate_area(1), PI)
self.assertEqual(function_call_count, 2)
with self.set_user("Engineer"):
self.assertEqual(calculate_area(1), ENGINEERING_PI)
self.assertEqual(function_call_count, 2)
with self.set_user("Mathematician"):
self.assertEqual(calculate_area(1), PI)
self.assertEqual(function_call_count, 2)
class TestDocumentCache(FrappeAPITestCase):
TEST_DOCTYPE = "User"
TEST_DOCNAME = "Administrator"
TEST_FIELD = "middle_name"
def setUp(self) -> None:
self.test_value = frappe.generate_hash()
def test_caching(self):
frappe.get_cached_doc(self.TEST_DOCTYPE, self.TEST_DOCNAME)
with self.assertQueryCount(0):
doc = frappe.get_cached_doc(self.TEST_DOCTYPE, self.TEST_DOCNAME)
doc.db_set(self.TEST_FIELD, self.test_value)
new_doc = frappe.get_cached_doc(self.TEST_DOCTYPE, self.TEST_DOCNAME)
self.assertIsNot(doc, new_doc) # Shouldn't be same object from frappe.local
self.assertEqual(new_doc.get(self.TEST_FIELD), self.test_value) # Cache invalidated and fetched
frappe.db.rollback()
doc_after_rollback = frappe.get_cached_doc(self.TEST_DOCTYPE, self.TEST_DOCNAME)
self.assertIsNot(new_doc, doc_after_rollback)
# Cache invalidated after rollback
self.assertNotEqual(doc_after_rollback.get(self.TEST_FIELD), self.test_value)
with self.assertQueryCount(0):
frappe.get_cached_doc(self.TEST_DOCTYPE, self.TEST_DOCNAME)
def test_cache_invalidation_set_value(self):
doc = frappe.get_cached_doc(self.TEST_DOCTYPE, self.TEST_DOCNAME)
frappe.db.set_value(
self.TEST_DOCTYPE,
{"name": ("like", "%Admin%")},
self.TEST_FIELD,
self.test_value,
)
new_doc = frappe.get_cached_doc(self.TEST_DOCTYPE, self.TEST_DOCNAME)
self.assertIsNot(doc, new_doc)
self.assertEqual(new_doc.get(self.TEST_FIELD), self.test_value)
with self.assertQueryCount(0):
frappe.get_cached_doc(self.TEST_DOCTYPE, self.TEST_DOCNAME)
class TestRedisWrapper(FrappeAPITestCase):
def test_delete_keys(self):
prefix = "test_del_"
for i in range(5):
frappe.cache.set_value(f"{prefix}{i}", 1)
self.assertEqual(len(frappe.cache.get_keys(prefix)), 5)
frappe.cache.delete_keys(prefix)
self.assertEqual(len(frappe.cache.get_keys(prefix)), 0)
def test_hash(self):
key = "test_hash"
# Confirm that there's no data initially
exists = frappe.cache.exists(key)
self.assertFalse(exists)
# Insert 5 key-value pairs
for i in range(5):
frappe.cache.hset(key, f"key_{i}", f"value_{i}")
# Check that we have 5 values
values = frappe.cache.hgetall(key)
self.assertEqual(len(values), 5)
# Check that each value matches
for i in range(5):
value = frappe.cache.hget(key, f"key_{i}")
self.assertEqual(value, f"value_{i}")
# Check the keys themselves
keys = frappe.cache.hkeys(key)
for i in range(5):
self.assertIn(f"key_{i}".encode(), keys)
# Delete a single key and check that we still have the remaining 4
frappe.cache.hdel(key, "key_1")
values = frappe.cache.hgetall(key)
self.assertEqual(len(values), 4)
# Delete 2 keys and check that we still have the remaining 2
frappe.cache.hdel(key, ["key_2", "key_3"])
values = frappe.cache.hgetall(key)
self.assertEqual(len(values), 2)
# Delete the hash itself and confirm that there's no data
frappe.cache.delete_value(key)
exists = frappe.cache.exists(key)
self.assertFalse(exists)
def test_user_cache_clear(self):
from frappe.cache_manager import user_cache_keys
# Set some keys that a user's cache would usually have
user1 = frappe.utils.random_string(10)
user2 = frappe.utils.random_string(10)
for key in user_cache_keys:
frappe.cache.hset(key, user1, key)
frappe.cache.hset(key, user2, key)
frappe.clear_cache(user=user1)
# Check that the keys for user1 are gone
for key in set(user_cache_keys) - {"home_page"}:
self.assertFalse(frappe.cache.hexists(key, user1))
self.assertTrue(frappe.cache.hexists(key, user2))
def test_doctype_cache_clear(self):
from frappe.cache_manager import doctype_cache_keys
# Set some keys that a user's cache would usually have
doctype1 = new_doctype(frappe.utils.random_string(10))
doctype2 = new_doctype(frappe.utils.random_string(10))
for key in doctype_cache_keys:
frappe.cache.hset(key, doctype1.name, key)
frappe.cache.hset(key, doctype2.name, key)
frappe.clear_cache(doctype=doctype1.name)
# Check that the keys for doctype1 are gone
for key in doctype_cache_keys:
self.assertFalse(frappe.cache.hexists(key, doctype1.name))
self.assertTrue(frappe.cache.hexists(key, doctype2.name))
def test_backward_compat_cache(self):
self.assertEqual(frappe.cache, frappe.cache())