import http.client
import logging
import os
import shutil
import tempfile
import unittest
from copy import deepcopy
from distutils.version import StrictVersion
from unittest.mock import MagicMock, Mock, patch

import pytest
import responses
from simple_salesforce import SalesforceGeneralError

from cumulusci.core import exceptions as exc
from cumulusci.core.config import (
    BaseProjectConfig,
    OrgConfig,
    TaskConfig,
    UniversalConfig,
)
from cumulusci.core.exceptions import (
    ApexCompilationException,
    ApexException,
    ApexTestException,
    CumulusCIException,
    SalesforceException,
    TaskOptionsError,
)
from cumulusci.core.keychain import BaseProjectKeychain
from cumulusci.core.tests.utils import MockLoggerMixin
from cumulusci.tasks.apex.anon import AnonymousApexTask
from cumulusci.tasks.apex.batch import BatchApexWait
from cumulusci.tasks.apex.testrunner import RunApexTests


@patch(
    "cumulusci.tasks.salesforce.BaseSalesforceTask._update_credentials",
    MagicMock(return_value=None),
)
class TestRunApexTests(MockLoggerMixin, unittest.TestCase):
    def setUp(self):
        self._task_log_handler.reset()
        self.task_log = self._task_log_handler.messages
        self.api_version = 38.0
        self.universal_config = UniversalConfig(
            {"project": {"api_version": self.api_version}}
        )
        self.task_config = TaskConfig()
        self.task_config.config["options"] = {
            "junit_output": "results_junit.xml",
            "poll_interval": 1,
            "test_name_match": "%_TEST",
        }
        self.project_config = BaseProjectConfig(
            self.universal_config, config={"noyaml": True}
        )
        self.project_config.config["project"] = {
            "package": {"api_version": self.api_version}
        }
        keychain = BaseProjectKeychain(self.project_config, "")
        self.project_config.set_keychain(keychain)
        self.org_config = OrgConfig(
            {
                "id": "foo/1",
                "instance_url": "https://example.com",
                "access_token": "abc123",
            },
            "test",
        )
        self.base_tooling_url = "{}/services/data/v{}/tooling/".format(
            self.org_config.instance_url, self.api_version
        )

    def _mock_apex_class_query(self, name="TestClass_TEST", namespace=None):
        namespace_param = "null" if namespace is None else f"%27{namespace}%27"
        url = (
            self.base_tooling_url
            + "query/?q=SELECT+Id%2C+Name+"
            + f"FROM+ApexClass+WHERE+NamespacePrefix+%3D+{namespace_param}"
            + "+AND+%28Name+LIKE+%27%25_TEST%27%29"
        )
        expected_response = {
            "done": True,
            "records": [{"Id": 1, "Name": name}],
            "totalSize": 1,
        }
        responses.add(
            responses.GET, url, match_querystring=True, json=expected_response
        )

    def _get_mock_test_query_results(self, methodnames, outcomes, messages):
        record_base = {
            "attributes": {
                "type": "ApexTestResult",
                "url": "/services/data/v40.0/tooling/sobjects/ApexTestResult/07M41000009gbT3EAI",
            },
            "ApexClass": {
                "attributes": {
                    "type": "ApexClass",
                    "url": "/services/data/v40.0/tooling/sobjects/ApexClass/01p4100000Fu4Z0AAJ",
                },
                "Name": "EP_TaskDependency_TEST",
            },
            "ApexClassId": 1,
            "ApexLogId": 1,
            "TestTimestamp": "2017-07-18T20:36:04.000+0000",
            "Id": "07M41000009gbT3EAI",
            "Message": None,
            "MethodName": None,
            "Outcome": None,
            "QueueItem": {
                "attributes": {
                    "type": "ApexTestQueueItem",
                    "url": "/services/data/v40.0/tooling/sobjects/ApexTestQueueItem/70941000000q7VsAAI",
                },
                "Status": "Completed",
                "ExtendedStatus": "(4/4)",
            },
            "RunTime": 1707,
            "StackTrace": "1. ParentFunction\n2. ChildFunction",
            "Status": "Completed",
            "Name": "TestClass_TEST",
            "ApexTestResults": {
                "size": 1,
                "totalSize": 1,
                "done": True,
                "queryLocator": None,
                "entityTypeName": "ApexTestResultLimits",
                "records": [
                    {
                        "attributes": {
                            "type": "ApexTestResultLimits",
                            "url": "/services/data/v40.0/tooling/sobjects/ApexTestResultLimits/05n41000002Y7OQAA0",
                        },
                        "Id": "05n41000002Y7OQAA0",
                        "Callouts": 0,
                        "AsyncCalls": 0,
                        "DmlRows": 5,
                        "Email": 0,
                        "LimitContext": "SYNC",
                        "LimitExceptions": None,
                        "MobilePush": 0,
                        "QueryRows": 20,
                        "Sosl": 0,
                        "Cpu": 471,
                        "Dml": 4,
                        "Soql": 5,
                    }
                ],
            },
        }

        return_value = {"done": True, "records": []}

        for (method_name, outcome, message) in zip(methodnames, outcomes, messages):
            this_result = deepcopy(record_base)
            this_result["Message"] = message
            this_result["Outcome"] = outcome
            this_result["MethodName"] = method_name
            return_value["records"].append(this_result)

        return return_value

    def _get_mock_test_query_url(self, job_id):
        return (
            self.base_tooling_url
            + "query/?q=%0ASELECT+Id%2CApexClassId%2CTestTimestamp%2C%0A+++++++Message%2CMethodName%2COutcome%2C%0A+++++++RunTime%2CStackTrace%2C%0A+++++++%28SELECT%0A++++++++++Id%2CCallouts%2CAsyncCalls%2CDmlRows%2CEmail%2C%0A++++++++++LimitContext%2CLimitExceptions%2CMobilePush%2C%0A++++++++++QueryRows%2CSosl%2CCpu%2CDml%2CSoql%0A++++++++FROM+ApexTestResults%29%0AFROM+ApexTestResult%0AWHERE+AsyncApexJobId%3D%27{}%27%0A".format(
                job_id
            )
        )

    def _get_mock_testqueueitem_status_query_url(self, job_id):
        return (
            self.base_tooling_url
            + f"query/?q=SELECT+Id%2C+Status%2C+ExtendedStatus%2C+ApexClassId+FROM+ApexTestQueueItem+WHERE+ParentJobId+%3D+%27{job_id}%27+AND+Status+%3D+%27Failed%27"
        )

    def _mock_get_test_results(
        self, outcome="Pass", message="Test Passed", job_id="JOB_ID1234567"
    ):
        url = self._get_mock_test_query_url(job_id)

        expected_response = self._get_mock_test_query_results(
            ["TestMethod"], [outcome], [message]
        )
        responses.add(
            responses.GET, url, match_querystring=True, json=expected_response
        )

    def _mock_get_test_results_multiple(
        self, method_names, outcomes, messages, job_id="JOB_ID1234567"
    ):
        url = self._get_mock_test_query_url(job_id)

        expected_response = self._get_mock_test_query_results(
            method_names, outcomes, messages
        )
        responses.add(
            responses.GET, url, match_querystring=True, json=expected_response
        )

    def _mock_get_failed_test_classes(self, job_id="JOB_ID1234567"):
        url = self._get_mock_testqueueitem_status_query_url(job_id)

        responses.add(
            responses.GET,
            url,
            match_querystring=True,
            json={"totalSize": 0, "records": [], "done": True},
        )

    def _mock_get_failed_test_classes_failure(self, job_id="JOB_ID1234567"):
        url = self._get_mock_testqueueitem_status_query_url(job_id)

        responses.add(
            responses.GET,
            url,
            match_querystring=True,
            json={
                "totalSize": 1,
                "records": [
                    {
                        "Id": "0000000000000000",
                        "ApexClassId": 1,
                        "Status": "Failed",
                        "ExtendedStatus": "Double-plus ungood",
                    }
                ],
                "done": True,
            },
        )

    def _mock_get_symboltable(self):
        url = (
            self.base_tooling_url
            + "query/?q=SELECT+SymbolTable+FROM+ApexClass+WHERE+Name%3D%27TestClass_TEST%27"
        )

        responses.add(
            responses.GET,
            url,
            json={
                "records": [
                    {
                        "SymbolTable": {
                            "methods": [
                                {"name": "test1", "annotations": [{"name": "isTest"}]}
                            ]
                        }
                    }
                ]
            },
        )

    def _mock_get_symboltable_failure(self):
        url = (
            self.base_tooling_url
            + "query/?q=SELECT+SymbolTable+FROM+ApexClass+WHERE+Name%3D%27TestClass_TEST%27"
        )

        responses.add(responses.GET, url, json={"records": []})

    def _mock_tests_complete(self, job_id="JOB_ID1234567"):
        url = (
            self.base_tooling_url
            + "query/?q=SELECT+Id%2C+Status%2C+"
            + "ApexClassId+FROM+ApexTestQueueItem+WHERE+ParentJobId+%3D+%27"
            + "{}%27".format(job_id)
        )
        expected_response = {
            "done": True,
            "totalSize": 1,
            "records": [{"Status": "Completed"}],
        }
        responses.add(
            responses.GET, url, match_querystring=True, json=expected_response
        )

    def _mock_tests_processing(self, job_id="JOB_ID1234567"):
        url = (
            self.base_tooling_url
            + "query/?q=SELECT+Id%2C+Status%2C+"
            + "ApexClassId+FROM+ApexTestQueueItem+WHERE+ParentJobId+%3D+%27"
            + "{}%27".format(job_id)
        )
        expected_response = {
            "done": True,
            "totalSize": 1,
            "records": [{"Status": "Processing", "ApexClassId": 1}],
        }
        responses.add(
            responses.GET, url, match_querystring=True, json=expected_response
        )

    def _mock_run_tests(self, success=True, body="JOB_ID1234567"):
        url = self.base_tooling_url + "runTestsAsynchronous"
        if success:
            responses.add(responses.POST, url, json=body)
        else:
            responses.add(responses.POST, url, status=http.client.SERVICE_UNAVAILABLE)

    @responses.activate
    def test_run_task(self):
        self._mock_apex_class_query()
        self._mock_run_tests()
        self._mock_get_failed_test_classes()
        self._mock_tests_complete()
        self._mock_get_test_results()
        task = RunApexTests(self.project_config, self.task_config, self.org_config)
        task()
        self.assertEqual(len(responses.calls), 5)

    @responses.activate
    def test_run_task__server_error(self):
        self._mock_apex_class_query()
        self._mock_run_tests(success=False)
        task = RunApexTests(self.project_config, self.task_config, self.org_config)
        with self.assertRaises(SalesforceGeneralError):
            task()

    @responses.activate
    def test_run_task__failed(self):
        self._mock_apex_class_query()
        self._mock_run_tests()
        self._mock_get_failed_test_classes()
        self._mock_tests_complete()
        self._mock_get_test_results("Fail")
        task = RunApexTests(self.project_config, self.task_config, self.org_config)
        with self.assertRaises(ApexTestException):
            task()

    @responses.activate
    def test_run_task__failed_class_level(self):
        self._mock_apex_class_query()
        self._mock_run_tests()
        self._mock_get_failed_test_classes_failure()
        self._mock_tests_complete()
        self._mock_get_test_results()
        self._mock_get_symboltable()
        task = RunApexTests(self.project_config, self.task_config, self.org_config)
        with self.assertRaises(ApexTestException):
            task()

    @responses.activate
    def test_run_task__failed_class_level_no_symboltable(self):
        self._mock_apex_class_query()
        self._mock_run_tests()
        self._mock_get_failed_test_classes_failure()
        self._mock_tests_complete()
        self._mock_get_test_results()
        self._mock_get_symboltable_failure()
        task = RunApexTests(self.project_config, self.task_config, self.org_config)
        with self.assertRaises(CumulusCIException):
            task()

    @responses.activate
    def test_run_task__failed_class_level_no_symboltable__spring20_managed(self):
        self._mock_apex_class_query(name="ns__Test_TEST", namespace="ns")
        self._mock_run_tests()
        self._mock_get_failed_test_classes_failure()
        self._mock_tests_complete()
        self._mock_get_test_results()
        self._mock_get_symboltable_failure()
        task_config = TaskConfig()
        task_config.config["options"] = {
            "junit_output": "results_junit.xml",
            "poll_interval": 1,
            "test_name_match": "%_TEST",
            "managed": True,
            "namespace": "ns",
        }

        task = RunApexTests(self.project_config, task_config, self.org_config)
        task._get_test_methods_for_class = Mock()

        task()

        task._get_test_methods_for_class.assert_not_called()

    @responses.activate
    def test_run_task__retry_tests(self):
        self._mock_apex_class_query()
        self._mock_run_tests()
        self._mock_run_tests(body="JOBID_9999")
        self._mock_get_failed_test_classes()
        self._mock_get_failed_test_classes(job_id="JOBID_9999")
        self._mock_tests_complete()
        self._mock_tests_complete(job_id="JOBID_9999")
        self._mock_get_test_results("Fail", "UNABLE_TO_LOCK_ROW")
        self._mock_get_test_results(job_id="JOBID_9999")

        task_config = TaskConfig()
        task_config.config["options"] = {
            "junit_output": "results_junit.xml",
            "poll_interval": 1,
            "test_name_match": "%_TEST",
            "retry_failures": ["UNABLE_TO_LOCK_ROW"],
        }
        task = RunApexTests(self.project_config, task_config, self.org_config)
        task()
        self.assertEqual(len(responses.calls), 9)

    @responses.activate
    def test_run_task__retry_tests_with_retry_always(self):
        self._mock_apex_class_query()
        self._mock_run_tests()
        self._mock_run_tests(body="JOBID_9999")
        self._mock_run_tests(body="JOBID_9990")
        self._mock_get_failed_test_classes()
        self._mock_get_failed_test_classes(job_id="JOBID_9999")
        self._mock_get_failed_test_classes(job_id="JOBID_9990")
        self._mock_tests_complete()
        self._mock_tests_complete(job_id="JOBID_9999")
        self._mock_tests_complete(job_id="JOBID_9990")
        self._mock_get_test_results_multiple(
            ["TestOne", "TestTwo"],
            ["Fail", "Fail"],
            ["UNABLE_TO_LOCK_ROW", "LimitException"],
        )
        self._mock_get_test_results_multiple(
            ["TestOne"], ["Pass"], [""], job_id="JOBID_9999"
        )
        self._mock_get_test_results_multiple(
            ["TestTwo"], ["Fail"], ["LimitException"], job_id="JOBID_9990"
        )
        task_config = TaskConfig()
        task_config.config["options"] = {
            "junit_output": "results_junit.xml",
            "poll_interval": 1,
            "test_name_match": "%_TEST",
            "retry_failures": ["UNABLE_TO_LOCK_ROW"],
            "retry_always": True,
        }
        task = RunApexTests(self.project_config, task_config, self.org_config)
        with self.assertRaises(ApexTestException):
            task()

    @responses.activate
    def test_run_task__retry_tests_fails(self):
        self._mock_apex_class_query()
        self._mock_run_tests()
        self._mock_run_tests(body="JOBID_9999")
        self._mock_get_failed_test_classes()
        self._mock_get_failed_test_classes(job_id="JOBID_9999")
        self._mock_tests_complete()
        self._mock_tests_complete(job_id="JOBID_9999")
        self._mock_get_test_results("Fail", "UNABLE_TO_LOCK_ROW")
        self._mock_get_test_results("Fail", "DUPLICATES_DETECTED", job_id="JOBID_9999")

        task_config = TaskConfig()
        task_config.config["options"] = {
            "junit_output": "results_junit.xml",
            "poll_interval": 1,
            "test_name_match": "%_TEST",
            "retry_failures": ["UNABLE_TO_LOCK_ROW"],
        }
        task = RunApexTests(self.project_config, task_config, self.org_config)
        with self.assertRaises(ApexTestException):
            task()

    @responses.activate
    def test_run_task__processing(self):
        self._mock_apex_class_query()
        self._mock_run_tests()
        self._mock_tests_processing()
        self._mock_get_failed_test_classes()
        self._mock_tests_complete()
        self._mock_get_test_results()
        task = RunApexTests(self.project_config, self.task_config, self.org_config)
        task()
        log = self._task_log_handler.messages
        assert "Completed: 0  Processing: 1 (TestClass_TEST)  Queued: 0" in log["info"]

    @responses.activate
    def test_run_task__not_verbose(self):
        self._mock_apex_class_query()
        self._mock_run_tests()
        self._mock_tests_processing()
        self._mock_get_failed_test_classes()  # this returns all passes
        self._mock_tests_complete()
        self._mock_get_test_results()
        task = RunApexTests(self.project_config, self.task_config, self.org_config)
        task()
        log = self._task_log_handler.messages
        assert "Class: TestClass_TEST" not in log["info"]

    @responses.activate
    def test_run_task__verbose(self):
        self._mock_apex_class_query()
        self._mock_run_tests()
        self._mock_get_failed_test_classes_failure()
        self._mock_tests_complete()
        self._mock_get_test_results()
        self._mock_get_symboltable()
        task_config = TaskConfig()
        task_config.config["options"] = {
            "verbose": True,
            "junit_output": "results_junit.xml",
            "poll_interval": 1,
            "test_name_match": "%_TEST",
        }
        task = RunApexTests(self.project_config, task_config, self.org_config)
        with self.assertRaises(CumulusCIException):
            task()
        log = self._task_log_handler.messages
        assert "Class: TestClass_TEST" in log["info"]

    @responses.activate
    def test_run_task__no_code_coverage(self):
        self._mock_apex_class_query()
        self._mock_run_tests()
        self._mock_get_failed_test_classes()
        self._mock_tests_complete()
        self._mock_get_test_results()
        task_config = TaskConfig()
        task_config.config["options"] = {
            "junit_output": "results_junit.xml",
            "poll_interval": 1,
            "test_name_match": "%_TEST",
        }
        task = RunApexTests(self.project_config, task_config, self.org_config)
        task._check_code_coverage = Mock()
        task()
        task._check_code_coverage.assert_not_called()

    @responses.activate
    def test_run_task__checks_code_coverage(self):
        self._mock_apex_class_query()
        self._mock_run_tests()
        self._mock_get_failed_test_classes()
        self._mock_tests_complete()
        self._mock_get_test_results()
        task_config = TaskConfig()
        task_config.config["options"] = {
            "junit_output": "results_junit.xml",
            "poll_interval": 1,
            "test_name_match": "%_TEST",
            "required_org_code_coverage_percent": "90",
        }

        org_config = OrgConfig(
            {
                "id": "foo/1",
                "instance_url": "https://example.com",
                "access_token": "abc123",
            },
            "test",
        )
        org_config._installed_packages = {"TEST": StrictVersion("1.2.3")}
        task = RunApexTests(self.project_config, task_config, org_config)
        task._check_code_coverage = Mock()
        task()
        task._check_code_coverage.assert_called_once()

    def test_code_coverage_integer(self):
        task_config = TaskConfig()
        task_config.config["options"] = {
            "junit_output": "results_junit.xml",
            "poll_interval": 1,
            "test_name_match": "%_TEST",
            "required_org_code_coverage_percent": 90,
        }

        org_config = OrgConfig(
            {
                "id": "foo/1",
                "instance_url": "https://example.com",
                "access_token": "abc123",
            },
            "test",
        )
        task = RunApexTests(self.project_config, task_config, org_config)

        assert task.code_coverage_level == 90

    def test_code_coverage_percentage(self):
        task_config = TaskConfig()
        task_config.config["options"] = {
            "junit_output": "results_junit.xml",
            "poll_interval": 1,
            "test_name_match": "%_TEST",
            "required_org_code_coverage_percent": "90%",
        }

        org_config = OrgConfig(
            {
                "id": "foo/1",
                "instance_url": "https://example.com",
                "access_token": "abc123",
            },
            "test",
        )
        task = RunApexTests(self.project_config, task_config, org_config)

        assert task.code_coverage_level == 90

    def test_exception_bad_code_coverage(self):
        task_config = TaskConfig()
        task_config.config["options"] = {
            "junit_output": "results_junit.xml",
            "poll_interval": 1,
            "test_name_match": "%_TEST",
            "required_org_code_coverage_percent": "foo",
        }

        with self.assertRaises(TaskOptionsError):
            RunApexTests(self.project_config, task_config, self.org_config)

    @responses.activate
    def test_run_task__code_coverage_managed(self):
        self._mock_apex_class_query(namespace="TEST")
        self._mock_run_tests()
        self._mock_get_failed_test_classes()
        self._mock_tests_complete()
        self._mock_get_test_results()
        task_config = TaskConfig()
        task_config.config["options"] = {
            "junit_output": "results_junit.xml",
            "poll_interval": 1,
            "test_name_match": "%_TEST",
            "namespace": "TEST",
            "required_org_code_coverage_percent": "90",
        }
        org_config = OrgConfig(
            {
                "id": "foo/1",
                "instance_url": "https://example.com",
                "access_token": "abc123",
            },
            "test",
        )
        org_config._installed_packages = {"TEST": StrictVersion("1.2.3")}

        task = RunApexTests(self.project_config, task_config, org_config)
        task._check_code_coverage = Mock()
        task()
        task._check_code_coverage.assert_not_called()

    def test_check_code_coverage(self):
        task = RunApexTests(self.project_config, self.task_config, self.org_config)
        task.code_coverage_level = 90
        task.tooling = Mock()
        task.tooling.query.return_value = {
            "records": [{"PercentCovered": 90}],
            "totalSize": 1,
        }

        task._check_code_coverage()
        task.tooling.query.assert_called_once_with(
            "SELECT PercentCovered FROM ApexOrgWideCoverage"
        )

    def test_check_code_coverage__fail(self):
        task = RunApexTests(self.project_config, self.task_config, self.org_config)
        task.code_coverage_level = 90
        task.tooling = Mock()
        task.tooling.query.return_value = {
            "records": [{"PercentCovered": 89}],
            "totalSize": 1,
        }

        with self.assertRaises(ApexTestException):
            task._check_code_coverage()

    def test_is_retriable_failure(self):
        task_config = TaskConfig()
        task_config.config["options"] = {
            "junit_output": "results_junit.xml",
            "poll_interval": 1,
            "test_name_match": "%_TEST",
            "retry_failures": [
                "UNABLE_TO_LOCK_ROW",
                "unable to obtain exclusive access to this record",
            ],
        }
        task = RunApexTests(self.project_config, task_config, self.org_config)
        task._init_options(task_config.config["options"])

        self.assertTrue(
            task._is_retriable_failure(
                {
                    "Message": "UNABLE_TO_LOCK_ROW",
                    "StackTrace": "test",
                    "Outcome": "Fail",
                }
            )
        )
        self.assertTrue(
            task._is_retriable_failure(
                {
                    "Message": "TEST",
                    "StackTrace": "unable to obtain exclusive access to this record",
                    "Outcome": "Fail",
                }
            )
        )
        self.assertFalse(
            task._is_retriable_failure(
                {
                    "Message": "DUPLICATES_DETECTED",
                    "StackTrace": "test",
                    "Outcome": "Fail",
                }
            )
        )

    def test_init_options__regexes(self):
        task_config = TaskConfig()
        task_config.config["options"] = {
            "junit_output": "results_junit.xml",
            "poll_interval": 1,
            "test_name_match": "%_TEST",
            "retry_failures": ["UNABLE_TO_LOCK_ROW"],
        }
        task = RunApexTests(self.project_config, task_config, self.org_config)
        task._init_options(task_config.config["options"])

        self.assertIsNotNone(
            task.options["retry_failures"][0].search("UNABLE_TO_LOCK_ROW: test failed")
        )

    def test_init_options__bad_regexes(self):
        task_config = TaskConfig()
        task_config.config["options"] = {
            "junit_output": "results_junit.xml",
            "poll_interval": 1,
            "test_name_match": "%_TEST",
            "retry_failures": ["("],
        }
        with self.assertRaises(TaskOptionsError):
            task = RunApexTests(self.project_config, task_config, self.org_config)
            task._init_options(task_config.config["options"])

    def test_get_namespace_filter__managed(self):
        task_config = TaskConfig({"options": {"managed": True, "namespace": "testns"}})
        task = RunApexTests(self.project_config, task_config, self.org_config)
        namespace = task._get_namespace_filter()
        self.assertEqual("'testns'", namespace)

    def test_get_namespace_filter__managed_no_namespace(self):
        task_config = TaskConfig({"options": {"managed": True}})
        task = RunApexTests(self.project_config, task_config, self.org_config)
        with self.assertRaises(TaskOptionsError):
            task._get_namespace_filter()

    def test_get_test_class_query__exclude(self):
        task_config = TaskConfig(
            {"options": {"test_name_match": "%_TEST", "test_name_exclude": "EXCL"}}
        )
        task = RunApexTests(self.project_config, task_config, self.org_config)
        query = task._get_test_class_query()
        self.assertEqual(
            "SELECT Id, Name FROM ApexClass WHERE NamespacePrefix = null "
            "AND (Name LIKE '%_TEST') AND (NOT Name LIKE 'EXCL')",
            query,
        )

    def test_run_task__no_tests(self):
        task = RunApexTests(self.project_config, self.task_config, self.org_config)
        task._get_test_classes = MagicMock(return_value={"totalSize": 0})
        task()
        self.assertIsNone(task.result)


@patch(
    "cumulusci.tasks.salesforce.BaseSalesforceTask._update_credentials",
    MagicMock(return_value=None),
)
class TestAnonymousApexTask(unittest.TestCase):
    def setUp(self):
        self.api_version = 42.0
        self.universal_config = UniversalConfig(
            {"project": {"api_version": self.api_version}}
        )
        self.tmpdir = tempfile.mkdtemp(dir=".")
        apex_path = os.path.join(self.tmpdir, "test.apex")
        with open(apex_path, "w") as f:
            f.write('System.debug("from file")')
        self.task_config = TaskConfig()
        self.task_config.config["options"] = {
            "path": apex_path,
            "apex": 'system.debug("Hello World!")',
            "param1": "StringValue",
        }
        self.project_config = BaseProjectConfig(
            self.universal_config, config={"noyaml": True}
        )
        self.project_config.config = {
            "project": {
                "package": {"namespace": "abc", "api_version": self.api_version}
            }
        }
        keychain = BaseProjectKeychain(self.project_config, "")
        self.project_config.set_keychain(keychain)
        self.org_config = OrgConfig(
            {
                "id": "foo/1",
                "instance_url": "https://example.com",
                "access_token": "abc123",
                "namespace": "abc",
            },
            "test",
        )
        self.org_config._installed_packages = {}
        self.base_tooling_url = "{}/services/data/v{}/tooling/".format(
            self.org_config.instance_url, self.api_version
        )

    def tearDown(self):
        shutil.rmtree(self.tmpdir)

    def _get_url_and_task(self):
        task = AnonymousApexTask(self.project_config, self.task_config, self.org_config)
        url = self.base_tooling_url + "executeAnonymous"
        return task, url

    def test_validate_options(self):
        task_config = TaskConfig({})
        with self.assertRaises(TaskOptionsError):
            AnonymousApexTask(self.project_config, task_config, self.org_config)

    def test_run_from_path_outside_repo(self):
        task_config = TaskConfig({"options": {"path": "/"}})
        task = AnonymousApexTask(self.project_config, task_config, self.org_config)
        with self.assertRaises(TaskOptionsError):
            task()

    def test_run_path_not_found(self):
        task_config = TaskConfig({"options": {"path": "bogus"}})
        task = AnonymousApexTask(self.project_config, task_config, self.org_config)
        with self.assertRaises(TaskOptionsError):
            task()

    def test_prepare_apex(self):
        self.task_config.config["options"]["namespaced"] = True

        task = AnonymousApexTask(self.project_config, self.task_config, self.org_config)
        before = "String %%%NAMESPACED_ORG%%%str = '%%%NAMESPACED_RT%%%';"
        expected = "String abc__str = 'abc.';"
        self.assertEqual(expected, task._prepare_apex(before))

    def test_prepare_apex__detect_namespace(self):
        task = AnonymousApexTask(self.project_config, self.task_config, self.org_config)
        before = "String %%%NAMESPACED_ORG%%%str = '%%%NAMESPACED_RT%%%';"
        expected = "String abc__str = 'abc.';"
        self.assertEqual(expected, task._prepare_apex(before))

    def test_optional_parameter_1_replacement(self):
        task = AnonymousApexTask(self.project_config, self.task_config, self.org_config)
        before = "String str = '%%%PARAM_1%%%';"
        expected = "String str = 'StringValue';"
        self.assertEqual(expected, task._prepare_apex(before))

    def test_optional_parameter_2_replacement(self):
        task = AnonymousApexTask(self.project_config, self.task_config, self.org_config)
        before = "String str = '%%%PARAM_2%%%';"
        expected = "String str = '';"
        self.assertEqual(expected, task._prepare_apex(before))

    @responses.activate
    def test_run_anonymous_apex_success(self):
        task, url = self._get_url_and_task()
        resp = {"compiled": True, "success": True}
        responses.add(responses.GET, url, status=200, json=resp)
        task()

    @responses.activate
    def test_run_string_only(self):
        task_config = TaskConfig({"options": {"apex": 'System.debug("test");'}})
        task = AnonymousApexTask(self.project_config, task_config, self.org_config)
        url = self.base_tooling_url + "executeAnonymous"
        responses.add(
            responses.GET, url, status=200, json={"compiled": True, "success": True}
        )
        task()

    @responses.activate
    def test_run_anonymous_apex_status_fail(self):
        task, url = self._get_url_and_task()
        responses.add(responses.GET, url, status=418, body="I'm a teapot")
        with self.assertRaises(SalesforceGeneralError) as cm:
            task()
        err = cm.exception
        self.assertEqual(str(err), "Error Code 418. Response content: I'm a teapot")
        self.assertTrue(err.url.startswith(url))
        self.assertEqual(err.status, 418)
        self.assertEqual(err.content, "I'm a teapot")

    @responses.activate
    def test_run_anonymous_apex_compile_except(self):
        task, url = self._get_url_and_task()
        problem = "Unexpected token '('."
        resp = {
            "compiled": False,
            "compileProblem": problem,
            "success": False,
            "line": 1,
            "column": 13,
            "exceptionMessage": "",
            "exceptionStackTrace": "",
            "logs": "",
        }
        responses.add(responses.GET, url, status=200, json=resp)
        with self.assertRaises(ApexCompilationException) as cm:
            task()
        err = cm.exception
        self.assertEqual(err.args[0], 1)
        self.assertEqual(err.args[1], problem)

    @responses.activate
    def test_run_anonymous_apex_except(self):
        task, url = self._get_url_and_task()
        problem = "Unexpected token '('."
        trace = "Line 0, Column 99"
        resp = {
            "compiled": True,
            "compileProblem": "",
            "success": False,
            "line": 1,
            "column": 13,
            "exceptionMessage": problem,
            "exceptionStackTrace": trace,
            "logs": "",
        }
        responses.add(responses.GET, url, status=200, json=resp)
        with self.assertRaises(ApexException) as cm:
            task()
        err = cm.exception
        self.assertEqual(err.args[0], problem)
        self.assertEqual(err.args[1], trace)

    @responses.activate
    def test_run_anonymous_apex__gack(self):
        task, url = self._get_url_and_task()
        responses.add(responses.GET, url, status=200, body="null")
        with self.assertRaises(SalesforceException) as cm:
            task()
        err = str(cm.exception)
        assert "gack" in err


@patch(
    "cumulusci.tasks.salesforce.BaseSalesforceTask._update_credentials",
    MagicMock(return_value=None),
)
class TestRunBatchApex(MockLoggerMixin, unittest.TestCase):
    def setUp(self):
        self.api_version = 42.0
        self.universal_config = UniversalConfig(
            {"project": {"api_version": self.api_version}}
        )
        self.task_config = TaskConfig()
        self.task_config.config["options"] = {
            "class_name": "ADDR_Seasonal_BATCH",
            "poll_interval": 1,
        }
        self.project_config = BaseProjectConfig(
            self.universal_config, config={"noyaml": True}
        )
        self.project_config.config["project"] = {
            "package": {"api_version": self.api_version}
        }
        keychain = BaseProjectKeychain(self.project_config, "")
        self.project_config.set_keychain(keychain)
        self.org_config = OrgConfig(
            {
                "id": "foo/1",
                "instance_url": "https://example.com",
                "access_token": "abc123",
            },
            "test",
        )
        self.base_tooling_url = "{}/services/data/v{}/tooling/".format(
            self.org_config.instance_url, self.api_version
        )
        self.task_log = self._task_log_handler.messages

    def _get_query_resp(self):
        return {
            "size": 1,
            "totalSize": 1,
            "done": True,
            "queryLocator": None,
            "entityTypeName": "AsyncApexJob",
            "records": [
                {
                    "attributes": {
                        "type": "AsyncApexJob",
                        "url": "/services/data/v43.0/tooling/sobjects/AsyncApexJob/707L0000014nnPHIAY",
                    },
                    "Id": "707L0000014nnPHIAY",
                    "ApexClass": {
                        "attributes": {
                            "type": "ApexClass",
                            "url": "/services/data/v43.0/tooling/sobjects/ApexClass/01pL000000109ndIAA",
                        },
                        "Name": "ADDR_Seasonal_BATCH",
                    },
                    "Status": "Completed",
                    "ExtendedStatus": None,
                    "TotalJobItems": 1,
                    "JobItemsProcessed": 1,
                    "NumberOfErrors": 0,
                    "CreatedDate": "2018-08-07T16:00:56.000+0000",
                    "CompletedDate": "2018-08-07T16:01:57.000+0000",
                }
            ],
        }

    def _update_job_result(self, response: dict, result_dict: dict):
        "Extend the result from _get_query_resp with additional batch records"
        template_result = response["records"][-1]  # use the last result as a template
        assert isinstance(template_result, dict)
        new_result = {**template_result, **result_dict}  # copy with variations
        old_subjob_results = [  # set completed for all old subjob results
            {**record, "Status": "Completed"} for record in response["records"]
        ]
        result_list = [
            new_result
        ] + old_subjob_results  # prepend new result because SOQL is order by DESC
        return {**response, "records": result_list}

    def _get_url_and_task(self):
        task = BatchApexWait(self.project_config, self.task_config, self.org_config)
        url = (
            self.base_tooling_url
            + "query/?q=SELECT+Id%2C+ApexClass.Name%2C+Status%2C+ExtendedStatus%2C+TotalJobItems%2C+JobItemsProcessed%2C+NumberOfErrors%2C+CreatedDate%2C+CompletedDate+FROM+AsyncApexJob+WHERE+JobType+IN+%28%27BatchApex%27%2C%27Queueable%27%29+AND+ApexClass.Name%3D%27ADDR_Seasonal_BATCH%27++++ORDER+BY+CreatedDate+DESC++LIMIT+1+"
        )
        return task, url

    @responses.activate
    def test_run_batch_apex_status_fail(self):
        task, url = self._get_url_and_task()
        response = self._get_query_resp()
        response["records"][0]["NumberOfErrors"] = 1
        response["records"][0]["ExtendedStatus"] = "Bad Status"
        responses.add(responses.GET, url, json=response)
        with self.assertRaises(SalesforceException) as cm:
            task()
        err = cm.exception
        self.assertIn("Bad Status", err.args[0])

    @responses.activate
    def test_run_batch_apex_number_mismatch(self):
        task, url = self._get_url_and_task()
        response = self._get_query_resp()
        response["records"][0]["JobItemsProcessed"] = 1
        response["records"][0]["TotalJobItems"] = 3
        responses.add(responses.GET, url, json=response)
        task()

        self.assertIn("The final record counts do not add up.", self.task_log["info"])

    @responses.activate
    def test_run_batch_apex_status_ok(self):
        task, url = self._get_url_and_task()
        response = self._get_query_resp()
        responses.add(responses.GET, url, json=response)
        task()

    @responses.activate
    def test_run_batch_apex_calc_elapsed_time(self):
        task, url = self._get_url_and_task()
        response = self._get_query_resp()
        responses.add(responses.GET, url, json=response)
        task()
        self.assertEqual(task.elapsed_time(task.subjobs), 61)

    @responses.activate
    def test_run_batch_apex_queueable_status_failed(self):
        task, url = self._get_url_and_task()
        response = self._get_query_resp()
        response["records"][0]["JobType"] = "Queueable"
        response["records"][0]["Status"] = "Failed"
        response["records"][0]["JobItemsProcessed"] = 0
        response["records"][0]["TotalJobItems"] = 0
        response["records"][0]["ExtendedStatus"] = "Error Details"
        responses.add(responses.GET, url, json=response)
        with self.assertRaises(SalesforceException) as e:
            task()
        assert "failure" in str(e.exception)

    @responses.activate
    def test_run_batch_apex_status_aborted(self):
        task, url = self._get_url_and_task()
        response = self._get_query_resp()
        response["records"][0]["Status"] = "Aborted"
        response["records"][0]["JobItemsProcessed"] = 1
        response["records"][0]["TotalJobItems"] = 3
        responses.add(responses.GET, url, json=response)
        with self.assertRaises(SalesforceException) as e:
            task()
        assert "aborted" in str(e.exception)

    @responses.activate
    def test_run_batch_apex_status_failed(self):
        task, url = self._get_url_and_task()
        response = self._get_query_resp()
        response["records"][0]["Status"] = "Failed"
        response["records"][0]["JobItemsProcessed"] = 1
        response["records"][0]["TotalJobItems"] = 3
        responses.add(responses.GET, url, json=response)
        self.task_log["info"] = []
        with self.assertRaises(SalesforceException) as e:
            task()
        assert "failure" in str(e.exception)

    @responses.activate
    def test_chained_subjobs(self):
        "Test subjobs that kick off a successor before they complete"
        task, url = self._get_url_and_task()
        url2 = (
            url.split("?")[0]
            + "?q=SELECT+Id%2C+ApexClass.Name%2C+Status%2C+ExtendedStatus%2C+TotalJobItems%2C+JobItemsProcessed%2C+NumberOfErrors%2C+CreatedDate%2C+CompletedDate+FROM+AsyncApexJob+WHERE+JobType+IN+%28%27BatchApex%27%2C%27Queueable%27%29+AND+ApexClass.Name%3D%27ADDR_Seasonal_BATCH%27++AND+CreatedDate+%3E%3D+2018-08-07T16%3A00%3A00Z++ORDER+BY+CreatedDate+DESC++"
        )

        # batch 1
        response = self._get_query_resp()
        batch_record = response["records"][0]
        batch_record.update(
            {
                "JobItemsProcessed": 1,
                "TotalJobItems": 3,
                "NumberOfErrors": 0,
                "Status": "Processing",
                "CreatedDate": "2018-08-07T16:00:00.000+0000",
            }
        )

        responses.add(responses.GET, url, json=response)

        # batch 2: 1 error
        response = self._update_job_result(
            response, {"Id": "Id2", "NumberOfErrors": 1, "Status": "Processing"}
        )
        responses.add(responses.GET, url2, json=response)

        # batch 3: found another error
        response = self._update_job_result(
            response, {"Id": "Id2", "NumberOfErrors": 2, "Status": "Processing"}
        )
        responses.add(responses.GET, url2, json=response)

        # batch 4: Complete, no errors in this sub-batch
        response = self._update_job_result(
            response,
            {
                "NumberOfErrors": 0,
                "Id": "Id4",
                "Status": "Completed",
                "CompletedDate": "2018-08-07T16:10:00.000+0000",  # 10 minutes passed
            },
        )
        responses.add(responses.GET, url2, json=response.copy())

        with self.assertRaises(SalesforceException) as e:
            task()

        assert len(task.subjobs) == 4
        summary = task.summarize_subjobs(task.subjobs)
        assert not summary["Success"]
        assert not summary["CountsAddUp"]
        assert summary["ElapsedTime"] == 10 * 60
        assert summary["JobItemsProcessed"] == 4
        assert summary["TotalJobItems"] == 12
        assert summary["NumberOfErrors"] == 3
        assert "batch errors" in str(e.exception)

    @responses.activate
    def test_chained_subjobs_beginning(self):
        "Test the first subjob that kicks off a successor before they complete"
        task, url = self._get_url_and_task()
        url2 = (
            url.split("?")[0]
            + "?q=SELECT+Id%2C+ApexClass.Name%2C+Status%2C+ExtendedStatus%2C+TotalJobItems%2C+JobItemsProcessed%2C+NumberOfErrors%2C+CreatedDate%2C+CompletedDate+FROM+AsyncApexJob+WHERE+JobType+IN+%28%27BatchApex%27%2C%27Queueable%27%29+AND+ApexClass.Name%3D%27ADDR_Seasonal_BATCH%27++AND+CreatedDate+%3E%3D+2018-08-07T16%3A00%3A00Z++ORDER+BY+CreatedDate+DESC++"
        )

        # batch 1
        response = self._get_query_resp()
        responses.add(responses.GET, url2, json=response)

        batch_record = response["records"][0]
        batch_record.update(
            {
                "JobItemsProcessed": 1,
                "TotalJobItems": 3,
                "NumberOfErrors": 0,
                "Status": "Preparing",
                "CreatedDate": "2018-08-07T16:00:00.000+0000",
                "CompletedDate": None,
            }
        )

        real_poll_action = task._poll_action
        counter = 0

        def mock_poll_action():
            nonlocal counter
            counter += 1
            if counter == 1:
                task.poll_complete = False
                return real_poll_action()
            else:
                rc = real_poll_action()
                task.poll_complete = True
                return rc

        task._poll_action = mock_poll_action

        responses.add(responses.GET, url, json=response)

        task()
        assert counter == 2
        assert task.poll_complete
        summary = task.summarize_subjobs(task.subjobs)
        assert not summary["NumberOfErrors"]

    @responses.activate
    def test_chained_subjobs_halfway(self):
        "Test part-way through a series of subjobs that kick off a successor before they complete"
        task, url = self._get_url_and_task()
        url2 = (
            url.split("?")[0]
            + "?q=SELECT+Id%2C+ApexClass.Name%2C+Status%2C+ExtendedStatus%2C+TotalJobItems%2C+JobItemsProcessed%2C+NumberOfErrors%2C+CreatedDate%2C+CompletedDate+FROM+AsyncApexJob+WHERE+JobType+IN+%28%27BatchApex%27%2C%27Queueable%27%29+AND+ApexClass.Name%3D%27ADDR_Seasonal_BATCH%27++AND+CreatedDate+%3E%3D+2018-08-07T16%3A00%3A00Z++ORDER+BY+CreatedDate+DESC++"
        )

        # batch 1
        response = self._get_query_resp()
        responses.add(responses.GET, url2, json=response)

        batch_record = response["records"][0]
        batch_record.update(
            {
                "JobItemsProcessed": 1,
                "TotalJobItems": 3,
                "NumberOfErrors": 0,
                "Status": "Preparing",
                "CreatedDate": "2018-08-07T16:00:00.000+0000",
                "CompletedDate": "2018-08-07T16:05:00.000+0000",
            }
        )

        # batch 2: 1 error
        response = self._update_job_result(
            response, {"Id": "Id2", "NumberOfErrors": 1, "CompletedDate": None}
        )
        responses.add(responses.GET, url2, json=response)

        real_poll_action = task._poll_action
        counter = 0

        def mock_poll_action():
            nonlocal counter
            counter += 1
            if counter == 1:
                task.poll_complete = False
                return real_poll_action()
            else:
                rc = real_poll_action()
                task.poll_complete = True
                return rc

        task._poll_action = mock_poll_action

        responses.add(responses.GET, url, json=response)

        task()
        assert counter == 2
        assert task.poll_complete
        summary = task.summarize_subjobs(task.subjobs)
        assert not summary["NumberOfErrors"]

    @responses.activate
    def test_job_not_found(self):

        task, url = self._get_url_and_task()
        response = self._get_query_resp()
        response["records"] = []
        responses.add(responses.GET, url, json=response)

        with self.assertRaises(SalesforceException) as e:
            task()

        assert "found" in str(e.exception)


class TestApexIntegrationTests:
    @pytest.mark.org_shape("qa", "ccitest:qa_org")
    @pytest.mark.slow()
    @pytest.mark.skip()  # until our CI has access to github service, or test
    # doesn't rely on it. In the meantime, the VCR test below is still good
    # and this test works on laptops.
    def test_run_tests__integration_test__call_salesforce(self, create_task, caplog):
        self._test_run_tests__integration_test(create_task, caplog)

    # There were challenges VCR-ing this because it depends on a
    # particular flow. To recreate the tape you'll need to run the
    # ccitest:qa_org flow aginst your org first.
    #
    # Also: some redundant "polls" were removed by hand to avoid
    # disk space usage.
    def test_run_tests__integration_test(self, create_task, caplog, vcr):
        with vcr.use_cassette(
            "ManualEditTestApexIntegrationTests.test_run_tests__integration_test.yaml",
            record_mode="none",
        ):
            self._test_run_tests__integration_test(create_task, caplog)

    def _test_run_tests__integration_test(self, create_task, caplog):

        caplog.set_level(logging.INFO)
        with pytest.raises(exc.ApexTestException) as e:
            task = create_task(
                RunApexTests,
                {
                    "required_org_code_coverage_percent": 70,
                    "required_per_class_code_coverage_percent": 60,
                    "json_output": None,
                    "junit_output": None,
                },
            )
            with patch.object(task, "_update_credentials"):
                task()
        relevant_records = [
            record for record in caplog.records if "below required level" in str(record)
        ]
        assert len(relevant_records) == 1, caplog.records
        assert "SampleClass2" in str(relevant_records[0])
        assert "below required level" in str(e.value)
        assert "SampleClass2" in str(e.value)

        caplog.clear()

        task = create_task(
            RunApexTests,
            {
                "required_org_code_coverage_percent": 70,
                "required_per_class_code_coverage_percent": 40,
                "json_output": None,
                "junit_output": None,
            },
        )
        with patch.object(task, "_update_credentials"):
            task()
        relevant_records = [
            record for record in caplog.records if "expectations" in str(record)
        ]
        assert len(relevant_records) == 2
        assert "All classes meet" in str(relevant_records)
        assert "Organization-wide code" in str(relevant_records)
