From 988d99bdb8c3489ccd172146601f4692d87cbc91 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Fri, 27 Feb 2026 11:19:22 +0100 Subject: [PATCH 1/5] Add v3 Locations performance test and update script support - Add TestDojoImporterPerformanceSmallLocations with V3_FEATURE_LOCATIONS - Update update_performance_test_counts.py to run both v2 and v3 test classes - Add --no-keepdb and EXTRA_ARGS to run-unittest.sh for test flexibility --- run-unittest.sh | 10 +- scripts/update_performance_test_counts.py | 215 +++++++++++++--------- unittests/test_importers_performance.py | 145 ++++++++++++++- 3 files changed, 277 insertions(+), 93 deletions(-) diff --git a/run-unittest.sh b/run-unittest.sh index 6073d4ff582..9ffeeeb20b0 100755 --- a/run-unittest.sh +++ b/run-unittest.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash unset TEST_CASE unset FAIL_FAST +unset KEEP_DB +EXTRA_ARGS=() bash ./docker/docker-compose-check.sh if [[ $? -eq 1 ]]; then exit 1; fi @@ -12,6 +14,7 @@ usage() { echo "Options:" echo " --test-case -t {YOUR_FULLY_QUALIFIED_TEST_CASE}" echo " --fail-fast -f - stop on first test failure" + echo " --no-keepdb - recreate the test database (don't reuse existing)" echo " --help -h - prints this dialogue." echo echo "You must specify a test case (arg)!" @@ -41,6 +44,10 @@ while [[ $# -gt 0 ]]; do FAIL_FAST="--failfast" shift # past argument ;; + --no-keepdb) + KEEP_DB="" + shift # past argument + ;; -h|--help) usage exit 0 @@ -66,4 +73,5 @@ echo "Running docker compose unit tests with test case $TEST_CASE ..." # Compose V2 integrates compose functions into the Docker platform, continuing to support # most of the previous docker-compose features and flags. You can run Compose V2 by # replacing the hyphen (-) with a space, using docker compose, instead of docker-compose. -docker compose exec uwsgi bash -c "python manage.py test $TEST_CASE -v2 --keepdb $FAIL_FAST" +KEEP_DB="${KEEP_DB:---keepdb}" +docker compose exec uwsgi bash -c "python manage.py test $TEST_CASE -v2 $KEEP_DB $FAIL_FAST ${EXTRA_ARGS[*]}" diff --git a/scripts/update_performance_test_counts.py b/scripts/update_performance_test_counts.py index bc99e108da8..9b406539e6e 100644 --- a/scripts/update_performance_test_counts.py +++ b/scripts/update_performance_test_counts.py @@ -11,20 +11,18 @@ How to run: - # Default: Update the test file (uses TestDojoImporterPerformanceSmall by default) + # Default: Update both v2 and v3 test classes python3 scripts/update_performance_test_counts.py - # Or specify a different test class: - python3 scripts/update_performance_test_counts.py --test-class TestDojoImporterPerformanceSmall - # Step 1: Run tests and generate report only (without updating) python3 scripts/update_performance_test_counts.py --report-only # Step 2: Verify all tests pass python3 scripts/update_performance_test_counts.py --verify -The script defaults to TestDojoImporterPerformanceSmall if --test-class is not provided. -The script defaults to --update behavior if no action flag is provided. +The script always runs and updates both TestDojoImporterPerformanceSmall (v2) and +TestDojoImporterPerformanceSmallLocations (v3). The script defaults to --update +behavior if no action flag is provided. """ import argparse @@ -36,6 +34,12 @@ # Path to the test file TEST_FILE = Path(__file__).parent.parent / "unittests" / "test_importers_performance.py" +# Both v2 and v3 performance test classes - script always updates/verifies both +TEST_CLASSES = ( + "TestDojoImporterPerformanceSmall", + "TestDojoImporterPerformanceSmallLocations", +) + class TestCount: @@ -64,9 +68,9 @@ def extract_test_methods(test_class: str) -> list[str]: content = TEST_FILE.read_text() - # Find the test class definition + # Find the test class definition (use (? tuple[int, int] | "second_import_async_tasks": "expected_num_async_tasks2", } + # Restrict search to the specified test class if given + search_content = content + search_offset = 0 + if test_class: + class_pattern = re.compile( + rf"class {re.escape(test_class)}.*?(?=(?&1 | less + Then search for `expected` to find the lines where the expected number of queries is printed. + Or you can use `grep` to filter the output: + ./run-unittest.sh --test-case unittests.test_importers_performance.TestDojoImporterPerformanceSmallLocations 2>&1 | grep expected -B 10 + """ + return super()._import_reimport_performance( + expected_num_queries1, + expected_num_async_tasks1, + expected_num_queries2, + expected_num_async_tasks2, + expected_num_queries3, + expected_num_async_tasks3, + scan_file1=STACK_HAWK_SUBSET_FILENAME, + scan_file2=STACK_HAWK_FILENAME, + scan_file3=STACK_HAWK_SUBSET_FILENAME, + scan_type=STACK_HAWK_SCAN_TYPE, + product_name="TestDojoDefaultImporterLocations", + engagement_name="Test Create Engagement Locations", + ) + + @override_settings(ENABLE_AUDITLOG=True) + def test_import_reimport_reimport_performance_pghistory_async(self): + """ + This test checks the performance of the importers when using django-pghistory with async enabled. + Query counts will need to be determined by running the test initially. + """ + configure_audit_system() + configure_pghistory_triggers() + + self._import_reimport_performance( + expected_num_queries1=1091, + expected_num_async_tasks1=6, + expected_num_queries2=1286, + expected_num_async_tasks2=17, + expected_num_queries3=874, + expected_num_async_tasks3=16, + ) + + @override_settings(ENABLE_AUDITLOG=True) + def test_import_reimport_reimport_performance_pghistory_no_async(self): + """ + This test checks the performance of the importers when using django-pghistory with async disabled. + Query counts will need to be determined by running the test initially. + """ + configure_audit_system() + configure_pghistory_triggers() + + testuser = User.objects.get(username="admin") + testuser.usercontactinfo.block_execution = True + testuser.usercontactinfo.save() + + self._import_reimport_performance( + expected_num_queries1=1098, + expected_num_async_tasks1=6, + expected_num_queries2=1293, + expected_num_async_tasks2=17, + expected_num_queries3=881, + expected_num_async_tasks3=16, + ) + + @override_settings(ENABLE_AUDITLOG=True) + def test_import_reimport_reimport_performance_pghistory_no_async_with_product_grading(self): + """ + This test checks the performance of the importers when using django-pghistory with async disabled and product grading enabled. + Query counts will need to be determined by running the test initially. + """ + configure_audit_system() + configure_pghistory_triggers() + + testuser = User.objects.get(username="admin") + testuser.usercontactinfo.block_execution = True + testuser.usercontactinfo.save() + self.system_settings(enable_product_grade=True) + + self._import_reimport_performance( + expected_num_queries1=1105, + expected_num_async_tasks1=8, + expected_num_queries2=1300, + expected_num_async_tasks2=19, + expected_num_queries3=885, + expected_num_async_tasks3=18, + ) + + @override_settings(ENABLE_AUDITLOG=True) + def test_deduplication_performance_pghistory_async(self): + """Test deduplication performance with django-pghistory and async tasks enabled.""" + configure_audit_system() + configure_pghistory_triggers() + + self.system_settings(enable_deduplication=True) + + self._deduplication_performance( + expected_num_queries1=264, + expected_num_async_tasks1=7, + expected_num_queries2=175, + expected_num_async_tasks2=7, + check_duplicates=False, # Async mode - deduplication happens later + ) + + @override_settings(ENABLE_AUDITLOG=True) + def test_deduplication_performance_pghistory_no_async(self): + """Test deduplication performance with django-pghistory and async tasks disabled.""" + configure_audit_system() + configure_pghistory_triggers() + + self.system_settings(enable_deduplication=True) + + testuser = User.objects.get(username="admin") + testuser.usercontactinfo.block_execution = True + testuser.usercontactinfo.save() + + self._deduplication_performance( + expected_num_queries1=271, + expected_num_async_tasks1=7, + expected_num_queries2=236, + expected_num_async_tasks2=7, + ) From b5582d9942656dccdf7e5810c0ef9e9e10d0504c Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Fri, 27 Feb 2026 11:24:42 +0100 Subject: [PATCH 2/5] Add performance test query count update instructions to CONTRIBUTING.md --- readme-docs/CONTRIBUTING.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/readme-docs/CONTRIBUTING.md b/readme-docs/CONTRIBUTING.md index 4c0b8a2210c..39992f7f772 100644 --- a/readme-docs/CONTRIBUTING.md +++ b/readme-docs/CONTRIBUTING.md @@ -55,6 +55,24 @@ Please use [these test scripts](../tests) to test your changes. These are the sc For changes that require additional settings, you can now use local_settings.py file. See the logging section below for more information. +## Updating Performance Test Query Counts + +The importer performance tests in `unittests/test_importers_performance.py` assert on expected database query and async task counts. If your changes affect import behavior (e.g., adding queries or changing celery task usage), these counts may need to be updated. + +Run the update script to refresh expected counts: + +```bash +python3 scripts/update_performance_test_counts.py +``` + +The script runs both `TestDojoImporterPerformanceSmall` (v2 endpoints) and `TestDojoImporterPerformanceSmallLocations` (v3 locations), captures actual counts, and updates the test file when they differ from expectations. + +To verify all tests pass after updating: + +```bash +python3 scripts/update_performance_test_counts.py --verify +``` + ## Python3 Version For compatibility reasons, the code in dev branch should be python3.13 compliant. From 849258eaea2190e99dc56486da8dc1ec4c87905e Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sat, 28 Feb 2026 23:48:53 +0100 Subject: [PATCH 3/5] Add _deduplication_performance to v3 Locations test and update expected counts --- unittests/test_importers_performance.py | 91 +++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 5 deletions(-) diff --git a/unittests/test_importers_performance.py b/unittests/test_importers_performance.py index f8da76d1433..cc9c238335b 100644 --- a/unittests/test_importers_performance.py +++ b/unittests/test_importers_performance.py @@ -474,7 +474,7 @@ class TestDojoImporterPerformanceSmallLocations(TestDojoImporterPerformanceBase) Query counts are specific to the locations code path and will differ from the v2 endpoint counts. To determine or update the expected counts, run: - python3 scripts/update_performance_test_counts.py --test-class TestDojoImporterPerformanceSmallLocations + python3 scripts/update_performance_test_counts.py """ def setUp(self): @@ -571,6 +571,87 @@ def test_import_reimport_reimport_performance_pghistory_no_async_with_product_gr expected_num_async_tasks3=18, ) + def _deduplication_performance(self, expected_num_queries1, expected_num_async_tasks1, expected_num_queries2, expected_num_async_tasks2, *, check_duplicates=True): + """ + Test method to measure deduplication performance by importing the same scan twice. + Mirrors TestDojoImporterPerformanceSmall._deduplication_performance but uses + Locations-specific product/engagement names for test isolation. + """ + _, engagement, lead, environment = self._create_test_objects( + "TestDojoDeduplicationPerformanceLocations", + "Test Deduplication Performance Engagement Locations", + ) + + with ( # noqa: SIM117 + self.subTest("first_import"), impersonate(Dojo_User.objects.get(username="admin")), + STACK_HAWK_FILENAME.open(encoding="utf-8") as scan, + ): + with self.subTest(step="first_import", metric="queries"): + with self.assertNumQueries(expected_num_queries1): + with self.subTest(step="first_import", metric="async_tasks"): + with self._assertNumAsyncTask(expected_num_async_tasks1): + import_options = { + "user": lead, + "lead": lead, + "scan_date": None, + "environment": environment, + "minimum_severity": "Info", + "active": True, + "verified": True, + "scan_type": STACK_HAWK_SCAN_TYPE, + "engagement": engagement, + } + importer = DefaultImporter(**import_options) + _, _, len_new_findings1, len_closed_findings1, _, _, _ = importer.process_scan(scan) + + with ( # noqa: SIM117 + self.subTest("second_import"), impersonate(Dojo_User.objects.get(username="admin")), + STACK_HAWK_FILENAME.open(encoding="utf-8") as scan, + ): + with self.subTest(step="second_import", metric="queries"): + with self.assertNumQueries(expected_num_queries2): + with self.subTest(step="second_import", metric="async_tasks"): + with self._assertNumAsyncTask(expected_num_async_tasks2): + import_options = { + "user": lead, + "lead": lead, + "scan_date": None, + "environment": environment, + "minimum_severity": "Info", + "active": True, + "verified": True, + "scan_type": STACK_HAWK_SCAN_TYPE, + "engagement": engagement, + } + importer = DefaultImporter(**import_options) + _, _, len_new_findings2, len_closed_findings2, _, _, _ = importer.process_scan(scan) + + logger.debug(f"First import: {len_new_findings1} new findings, {len_closed_findings1} closed findings") + logger.debug(f"Second import: {len_new_findings2} new findings, {len_closed_findings2} closed findings") + + self.assertEqual(len_new_findings1, 6, "First import should create 6 new findings") + self.assertEqual(len_closed_findings1, 0, "First import should not close any findings") + self.assertEqual(len_new_findings2, 6, "Second import should report 6 new findings initially (before deduplication)") + self.assertEqual(len_closed_findings2, 0, "Second import should not close any findings") + + if check_duplicates: + active_findings = Finding.objects.filter( + test__engagement=engagement, + active=True, + duplicate=False, + ).count() + duplicate_findings = Finding.objects.filter( + test__engagement=engagement, + duplicate=True, + ).count() + self.assertEqual(active_findings, 6, f"Expected 6 active findings, got {active_findings}") + self.assertEqual(duplicate_findings, 6, f"Expected 6 duplicate findings, got {duplicate_findings}") + total_findings = Finding.objects.filter(test__engagement=engagement).count() + self.assertEqual(total_findings, 12, f"Expected 12 total findings, got {total_findings}") + else: + total_findings = Finding.objects.filter(test__engagement=engagement).count() + self.assertEqual(total_findings, 12, f"Expected 12 total findings, got {total_findings}") + @override_settings(ENABLE_AUDITLOG=True) def test_deduplication_performance_pghistory_async(self): """Test deduplication performance with django-pghistory and async tasks enabled.""" @@ -580,9 +661,9 @@ def test_deduplication_performance_pghistory_async(self): self.system_settings(enable_deduplication=True) self._deduplication_performance( - expected_num_queries1=264, + expected_num_queries1=1356, expected_num_async_tasks1=7, - expected_num_queries2=175, + expected_num_queries2=1165, expected_num_async_tasks2=7, check_duplicates=False, # Async mode - deduplication happens later ) @@ -600,8 +681,8 @@ def test_deduplication_performance_pghistory_no_async(self): testuser.usercontactinfo.save() self._deduplication_performance( - expected_num_queries1=271, + expected_num_queries1=1363, expected_num_async_tasks1=7, - expected_num_queries2=236, + expected_num_queries2=1438, expected_num_async_tasks2=7, ) From 9ad75d0612d4a19716acdb62b2b10fd04a996f55 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 1 Mar 2026 10:19:38 +0100 Subject: [PATCH 4/5] update --- scripts/update_performance_test_counts.py | 71 ++++++++++++----------- 1 file changed, 38 insertions(+), 33 deletions(-) diff --git a/scripts/update_performance_test_counts.py b/scripts/update_performance_test_counts.py index 9b406539e6e..9b5a678ab63 100644 --- a/scripts/update_performance_test_counts.py +++ b/scripts/update_performance_test_counts.py @@ -14,15 +14,19 @@ # Default: Update both v2 and v3 test classes python3 scripts/update_performance_test_counts.py + # Or update a specific test class: + python3 scripts/update_performance_test_counts.py --test-class TestDojoImporterPerformanceSmall + python3 scripts/update_performance_test_counts.py --test-class TestDojoImporterPerformanceSmallLocations + # Step 1: Run tests and generate report only (without updating) python3 scripts/update_performance_test_counts.py --report-only # Step 2: Verify all tests pass python3 scripts/update_performance_test_counts.py --verify -The script always runs and updates both TestDojoImporterPerformanceSmall (v2) and -TestDojoImporterPerformanceSmallLocations (v3). The script defaults to --update -behavior if no action flag is provided. +By default (no --test-class) the script runs and updates both +TestDojoImporterPerformanceSmall (v2) and TestDojoImporterPerformanceSmallLocations (v3). +The script defaults to --update behavior if no action flag is provided. """ import argparse @@ -34,11 +38,11 @@ # Path to the test file TEST_FILE = Path(__file__).parent.parent / "unittests" / "test_importers_performance.py" -# Both v2 and v3 performance test classes - script always updates/verifies both -TEST_CLASSES = ( +# All performance test classes, in run order +TEST_CLASSES = [ "TestDojoImporterPerformanceSmall", "TestDojoImporterPerformanceSmallLocations", -) +] class TestCount: @@ -68,9 +72,9 @@ def extract_test_methods(test_class: str) -> list[str]: content = TEST_FILE.read_text() - # Find the test class definition (use (? tuple[int, int] | "second_import_async_tasks": "expected_num_async_tasks2", } - # Restrict search to the specified test class if given + # Restrict method search to the specified class to avoid updating the wrong + # class when v2 and v3 share identical method names. search_content = content search_offset = 0 if test_class: class_pattern = re.compile( - rf"class {re.escape(test_class)}.*?(?=(? Date: Sun, 1 Mar 2026 10:44:20 +0100 Subject: [PATCH 5/5] cleanup --- run-unittest.sh | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/run-unittest.sh b/run-unittest.sh index 9ffeeeb20b0..6073d4ff582 100755 --- a/run-unittest.sh +++ b/run-unittest.sh @@ -1,8 +1,6 @@ #!/usr/bin/env bash unset TEST_CASE unset FAIL_FAST -unset KEEP_DB -EXTRA_ARGS=() bash ./docker/docker-compose-check.sh if [[ $? -eq 1 ]]; then exit 1; fi @@ -14,7 +12,6 @@ usage() { echo "Options:" echo " --test-case -t {YOUR_FULLY_QUALIFIED_TEST_CASE}" echo " --fail-fast -f - stop on first test failure" - echo " --no-keepdb - recreate the test database (don't reuse existing)" echo " --help -h - prints this dialogue." echo echo "You must specify a test case (arg)!" @@ -44,10 +41,6 @@ while [[ $# -gt 0 ]]; do FAIL_FAST="--failfast" shift # past argument ;; - --no-keepdb) - KEEP_DB="" - shift # past argument - ;; -h|--help) usage exit 0 @@ -73,5 +66,4 @@ echo "Running docker compose unit tests with test case $TEST_CASE ..." # Compose V2 integrates compose functions into the Docker platform, continuing to support # most of the previous docker-compose features and flags. You can run Compose V2 by # replacing the hyphen (-) with a space, using docker compose, instead of docker-compose. -KEEP_DB="${KEEP_DB:---keepdb}" -docker compose exec uwsgi bash -c "python manage.py test $TEST_CASE -v2 $KEEP_DB $FAIL_FAST ${EXTRA_ARGS[*]}" +docker compose exec uwsgi bash -c "python manage.py test $TEST_CASE -v2 --keepdb $FAIL_FAST"