# Copyright 2021-2022 The Debusine Developers
# See the AUTHORS file at the top-level directory of this distribution
#
# This file is part of Debusine. It is subject to the license terms
# in the LICENSE file found in the top-level directory of this
# distribution. No part of Debusine, including this file, may be copied,
# modified, propagated, or distributed except according to the terms
# contained in the LICENSE file.

"""Tests for the Worker."""
import asyncio
import functools
import logging
import re
import signal
import socket
import time
from unittest import mock

from aiohttp import ClientResponseError, RequestInfo, WSMessage, WSMsgType
from aiohttp.client_exceptions import ClientResponse
from aiohttp.test_utils import unittest_run_loop
from aiohttp.web_exceptions import (
    HTTPForbidden,
    HTTPInternalServerError,
)

from yarl import URL

from debusine.client.exceptions import TokenDisabledError
from debusine.tasks import TaskConfigError
from debusine.tasks.sbuild import Sbuild
from debusine.test import TestHelpersMixin
from debusine.worker import Worker, system_information
from debusine.worker.config import ConfigHandler
from debusine.worker.tests import server
from debusine.worker.tests.server import ServerConfig


class WorkerTests(TestHelpersMixin, server.DebusineAioHTTPTestCase):
    """Test Worker class."""

    def setUp(self):
        """Initialize TestWorker."""
        super().setUp()
        self.worker = None

        self.debusine_url = str(self.server.make_url(''))

        self.config_temp_directory = self.create_temp_config_directory(
            {'General': {'debusine-url': self.debusine_url}}
        )

        self.config = ConfigHandler(directories=[self.config_temp_directory])
        self.config['General']['log-file'] = '/dev/null'

        self.default_sigint_handler = signal.getsignal(signal.SIGINT)
        self.default_sigterm_handler = signal.getsignal(signal.SIGTERM)

    def tearDown(self):
        """Cleanup after executing a test."""
        # Restore signal handlers. Cli.execute() changes them
        signal.signal(signal.SIGINT, self.default_sigint_handler)
        signal.signal(signal.SIGTERM, self.default_sigterm_handler)

        super().tearDown()

    async def tearDownAsync(self) -> None:
        """Asynchronous cleanup."""
        await super().tearDownAsync()
        if self.worker:
            await self.worker.close()

    def setup_valid_token(self):
        """Set a valid token in the test server and client configuration."""
        token = '6c931875627131b5135b7de3371c44'
        self.server_config.registered_token = token
        self.config.write_token(token)

    def setup_worker(
        self, config=None, connect=False, log_file=None, log_level=None
    ):
        """Set a new worker to self.worker (with config or self.config)."""
        if config is None:
            config = self.config

        self.worker = Worker(
            log_file=log_file,
            log_level=log_level,
            config=config,
        )

        try:
            # If the caller of setup_worker created an event loop
            # (e.g. using @unittest_run_loop) then let the worker use it.
            asyncio.get_running_loop()
            self.worker._set_main_event_loop()
        except RuntimeError:
            pass

        if connect:
            self.async_run(self.worker.connect())

    def patch_worker(self, attribute, **kwargs):
        """Patch an attribute of the worker object."""
        patcher = mock.patch.object(self.worker, attribute)
        mocked = patcher.start()
        for key, value in kwargs.items():
            setattr(mocked, key, value)
        self.addCleanup(patcher.stop)
        return mocked

    @staticmethod
    def async_run(coroutine):
        """Run coroutine in an event loop."""
        loop = asyncio.get_event_loop()
        loop.run_until_complete(coroutine)

    def patch_config_handler(self):
        """
        Patch ConfigHandler class.

        :return: the MagicMock
        """
        patch = mock.patch("debusine.worker._worker.ConfigHandler")
        config_handler_mocked = patch.start()
        self.addCleanup(patch.stop)

        return config_handler_mocked

    def test_worker_init(self):
        """Init without any keyword."""
        mocked_config_handler = self.patch_config_handler()

        worker = Worker(log_file='/dev/null', log_level=logging.WARNING)

        # Worker() created a ConfigHandler default object and set it
        # to Worker._config
        self.assertIsNotNone(worker._config)
        mocked_config_handler.assert_called()

    def test_worker_init_use_config_handler(self):
        """worker.set_config_handler() sets the ConfigHandler."""
        self.setup_worker(log_file='/dev/null', log_level=logging.WARNING)

        self.assertIs(self.worker._config, self.config)

    @unittest_run_loop
    async def test_ensure_has_session(self):
        """_ensure_session opens the client session."""
        self.setup_worker()

        self.worker._ensure_has_session()

        self.assertIsNotNone(self.worker._aiohttp_client_session)

    def test_registration_success(self):
        """
        Worker client registers successfully.

        Client creates a token, sends it to the server and save to the
        configuration.
        """
        # No token in the configuration
        self.assertIsNone(self.config.token)

        # Worker connects: it will create a token, send and save
        self.setup_worker(connect=True)

        # A token has been retrieved
        self.assertIsNotNone(self.config.token)

        registration_request = self.server_requests[-2]
        connect_request = self.server_requests[-1]

        self.assertEqual(registration_request.path, '/api/1.0/worker/register/')
        self.assertEqual(connect_request.path, '/ws/1.0/worker/connect/')

        # Server side received expected registration data
        self.assertEqual(
            registration_request.json_content,
            {'token': self.config.token, 'fqdn': socket.getfqdn()},
        )

    def test_registration_failure(self):
        """
        Worker client registration failure.

        The server returns 403, the client raises WorkerRegistrationError.
        """
        self.server_config.register_response_status_code = (
            HTTPForbidden.status_code
        )

        log_message = (
            'Could not register. Server HTTP status code: 403 Body: error'
        )

        self.setup_worker()
        with self.assertLogsContains(log_message), self.assertRaisesSystemExit(
            3
        ):
            self.async_run(self.worker.connect())

    def test_connection_success(self):
        """Worker client connects successfully."""
        self.setup_valid_token()

        self.setup_worker(connect=True)
        self.assertEqual(
            self.server_latest_request.path, '/ws/1.0/worker/connect/'
        )
        self.assertTrue(self.server_latest_request.is_authenticated)

    def test_connection_failure(self):
        """Worker client cannot connect: invalid token."""
        self.registered_token = 'f924ba0099588bf6'

        self.config.write_token('e3c0284')

        self.setup_worker(connect=True)

        self.assertEqual(
            self.server_latest_request.path, '/ws/1.0/worker/connect/'
        )
        self.assertFalse(self.server_latest_request.is_authenticated)

    @unittest_run_loop
    async def test_connection_reset_error(self):
        """Worker client handles ConnectionResetError exception."""
        self.setup_valid_token()
        self.setup_worker()

        self.patch_worker("_process_message", side_effect=ConnectionResetError)
        mock_wait_before_reconnect = self.patch_worker(
            "_tenacity_sleep",
            side_effect=[None],
        )

        self.server_config.connect_response_bodies = [{}]

        log_message = 'ConnectionResetError, will reconnect'

        # It tried twice to connect: once before the call to
        # Worker._wait_before_reconnect and once when it has been executed
        with self.assertLogsContains(
            log_message, expected_count=2, level=logging.DEBUG
        ), self.assertRaises(StopAsyncIteration):
            await self.worker.connect()

        # It's called twice: first it returns None and the second time raises
        # and exception because side_effect=[None]
        self.assertEqual(mock_wait_before_reconnect.call_count, 2)

    @unittest_run_loop
    async def test_connection_read_error(self):
        """Worker cannot read data from the server (low level disconnection)."""
        self.setup_worker()
        mock_post = self.patch_worker(
            "_post",
            side_effect=ClientResponseError(
                RequestInfo(URL('https://test.com'), '', {}), ClientResponse
            ),
        )
        log_message = (
            'Could not register. Server HTTP status code: 0 '
            'Body: Not available'
        )
        with self.assertLogsContains(log_message), self.assertRaisesSystemExit(
            3
        ):
            await self.worker.connect()

        self.assertTrue(mock_post.called)

    def test_connection_rejected(self):
        """Worker tries to connect, server returns 500."""
        self.server_config.register_response_status_code = (
            HTTPInternalServerError.status_code
        )

        log_message = (
            'Could not register. Server HTTP status code: 500 Body: error'
        )

        self.setup_worker()

        with self.assertLogsContains(log_message), self.assertRaisesSystemExit(
            3
        ):
            self.async_run(self.worker.connect())

    @unittest_run_loop
    async def test_connection_error_connection_refused(self):
        """Worker client cannot connect: connection refused."""
        # Because the token exists the worker will try to connect
        # instead of to register
        self.config.write_token('e3c0284')

        await self.server.close()

        self.setup_worker()
        mocked_sleep = self.patch_worker(
            "_tenacity_sleep", side_effect=[None, None]
        )

        log_message = 'Error connecting to'

        # Assert that it tried three times to connect
        # The first one Work._wait_before_reconnect wasn't called, then
        # twice before Work._wait_before_reconnect returns None twice

        with self.assertLogsContains(
            log_message, expected_count=3
        ), self.assertRaises(StopAsyncIteration):
            # StopAsyncIteration because mocked_sleep side_effects
            await self.worker.connect()

        # It's called three times: twice return None and once raises
        # and exception because mocked_sleep.side_effect=[None, None]
        self.assertEqual(mocked_sleep.call_count, 3)

    @unittest_run_loop
    async def test_connection_error_token_disabled_try_again(self):
        """Worker tries to connect again if server returns "TOKEN_DISABLED"."""
        self.setup_valid_token()
        self.server_config.connect_response_bodies = [
            ServerConfig.RESPONSES['token_disabled_error']
        ]

        # No request to any URL...
        self.assertIsNone(self.server_latest_request)

        self.setup_worker()
        mocked_method = self.patch_worker(
            '_tenacity_sleep', side_effect=[None, None]
        )

        with self.assertRaises(StopAsyncIteration), self.assertLogsContains(
            self.config.token, expected_count=3
        ) as logs:
            # RuntimeError due to the implementation of the mocked
            # returning None twice
            await self.worker.connect()

        # It's called three times: twice _wait_before_reconnected returned None,
        # and once raises StopIteration
        self.assertEqual(mocked_method.call_count, 3)

        # Assert that the logs contain "Reason: The token is disabled"
        token_is_disabled_found = False
        for log in logs.output:  # pragma: no cover
            if "Reason: The token is disabled" in log:
                token_is_disabled_found = True
                break

        self.assertTrue(token_is_disabled_found)

    @unittest_run_loop
    async def test_process_message_dynamic_metadata_request(self):
        """Worker receives a request to send the dynamic metadata."""
        tasks_metadata = {
            'sbuild:version': 1,
            'sbuild:chroots': 'chroot:unstable-amd64-sbuild',
        }

        patcher = mock.patch('debusine.tasks.Task.analyze_worker_all_tasks')
        mocked_function = patcher.start()
        mocked_function.return_value = tasks_metadata
        self.addCleanup(patcher.stop)

        self.setup_valid_token()
        self.server_config.connect_response_bodies = [
            ServerConfig.RESPONSES['request_dynamic_metadata']
        ]

        # No request to any URL...
        self.assertIsNone(self.server_latest_request)

        self.setup_worker()
        await self.worker.connect()

        # The server sends a 'request_dynamic_metadata' and the worker
        # sends the Worker._system_metadata() and Task.analyze_worker_all_tasks

        self.assertEqual(
            self.server_latest_request.path, '/api/1.0/worker/dynamic-metadata/'
        )

        self.assertTrue(self.server_latest_request.is_authenticated)

        # Worker sent Worker._system_metadata() and tasks_metadata
        self.assertEqual(
            self.server_latest_request.json_content,
            {**Worker._system_metadata(), **tasks_metadata},
        )

    def assert_request_done_times(self, method, path, times):
        """Assert that request method regexp_path has been done times."""
        count = 0
        for request in self.server_requests:
            if request.method == method and request.path == path:
                count += 1

        self.assertEqual(count, times)

    async def assert_request_is_done(self, method, regexp_path):
        """
        Assert that a request with method and regexp_path is done.

        Timeouts in 1 second.
        """
        self.assertTrue(
            await self.request_is_done(method, regexp_path, timeout=1)
        )

    async def assert_request_is_not_done(self, method, regexp_path):
        """
        Assert that a request with method and regexp_path is not done.

        Waits 1 second before asserting it.
        """
        self.assertFalse(
            await self.request_is_done(method, regexp_path, timeout=1)
        )

    async def request_is_done(self, method, regexp_path, *, timeout) -> bool:
        """
        Return True if the request to method and regexp is done.

        Timeout: 1 second.
        """
        self.new_request_event = asyncio.Event()
        while True:
            self.new_request_event.clear()
            try:
                await asyncio.wait_for(
                    self.new_request_event.wait(), timeout=timeout
                )
            except asyncio.TimeoutError:
                return False
            if self.server_latest_request.method == method and re.match(
                regexp_path, self.server_latest_request.path
            ):
                return True

    async def receive_task_submit_result(self, execute_result, json_result):
        """Worker fetches the task and validates submitted JSON."""
        patcher = mock.patch.object(Sbuild, 'execute')
        mocked_execute = patcher.start()
        mocked_execute.return_value = execute_result
        self.addCleanup(patcher.stop)

        self.setup_valid_token()
        self.server_config.connect_response_bodies = [
            ServerConfig.RESPONSES['work_request_available']
        ]

        # No request to any URL...
        self.assertIsNone(self.server_latest_request)

        self.setup_worker()
        await self.worker.connect()

        # The WorkRequest is executed...
        self.assertEqual(mocked_execute.call_count, 1)

        await self.assert_request_is_done(
            "PUT", r'^/api/1.0/work-request/(\d+)/completed/$'
        )

        self.assertEqual(
            self.server_latest_request.json_content, {'result': json_result}
        )
        self.assertIsNone(self.worker._task_exec_future)

    @unittest_run_loop
    async def test_task_is_received_executed_submit_success(self):
        """Receive a task, execute it and submit success completion."""
        await self.receive_task_submit_result(True, 'success')

    @unittest_run_loop
    async def test_task_is_received_executed_submit_failure(self):
        """Receive a task, executes it (failure) and submits failure."""
        await self.receive_task_submit_result(False, 'failure')

    @unittest_run_loop
    async def test_print_no_work_request_available(self):
        """Worker logs 'No work request available'."""
        self.setup_valid_token()
        self.server_config.connect_response_bodies = [
            ServerConfig.RESPONSES['work_request_available']
        ]
        self.server_config.get_next_for_worker = (
            ServerConfig.GetNextForWorkerResponse.NO_WORK_REQUEST_PENDING
        )

        self.setup_worker()

        log_message = 'No work request available'

        with self.assertLogsContains(log_message, level=logging.DEBUG):
            await self.worker.connect()

    @unittest_run_loop
    async def test_log_work_request_ignored_already_running(self):
        """Worker receive two Work Request available, fetches only one."""

        def wait():
            # time.sleep(1) is called but not awaited (the thread
            # is cancelled before).
            time.sleep(1)
            raise self.failureException(
                "Thread was not cancelled"
            )  # pragma: no cover

        patcher = mock.patch.object(Sbuild, "execute")
        mocked_execute = patcher.start()
        mocked_execute.side_effect = wait
        self.addCleanup(patcher.stop)

        self.setup_valid_token()

        work_request_available = ServerConfig.RESPONSES[
            "work_request_available"
        ]

        self.server_config.connect_response_bodies = [
            work_request_available,
            work_request_available,
        ]

        self.setup_worker()

        log_message = (
            "Work request available but ignored: currently running a Task"
        )

        # Log that a Work Request is available (coming from the server)
        # but the Worker ignores it because the worker is already executing
        # a task
        with self.assertLogsContains(log_message):
            await self.worker.connect()

        # Sbuild.execute() is called only once
        self.assertEqual(mocked_execute.call_count, 1)

        # The server sent twice "work_request_available" and the worker
        # called get-next-for-worker/ once only: because a Task is already
        # being executed
        self.assert_request_done_times(
            "GET", "/api/1.0/work-request/get-next-for-worker/", times=1
        )

        # Cancel the execution of sleep(1) job
        self.worker._task_exec_future.cancel()

    async def assert_invalid_get_next_for_worker_logging(
        self, get_next_for_worker
    ):
        """Assert log_message is logged for get_next_for_worker response."""
        self.setup_valid_token()
        self.server_config.connect_response_bodies = [
            ServerConfig.RESPONSES['work_request_available']
        ]
        self.server_config.get_next_for_worker = get_next_for_worker

        self.setup_worker()

        with self.assertLogsContains(
            "Invalid WorkRequest received from", level=logging.WARNING
        ):
            await self.worker.connect()

    @unittest_run_loop
    async def test_request_work_request_invalid_work_request_response(self):
        """Worker logs 'Invalid content of...'."""
        await self.assert_invalid_get_next_for_worker_logging(
            ServerConfig.GetNextForWorkerResponse.INVALID_WORK_REQUEST
        )

    @unittest_run_loop
    async def test_request_work_request_invalid_json_response(self):
        """Worker logs 'Invalid JSON response...'."""
        await self.assert_invalid_get_next_for_worker_logging(
            ServerConfig.GetNextForWorkerResponse.INVALID_JSON
        )

    async def assert_execute_work_request_send_and_log_error(self, error_msg):
        """Assert error is sent to the server and logged."""
        self.setup_valid_token()
        self.server_config.connect_response_bodies = [
            ServerConfig.RESPONSES['work_request_available']
        ]

        self.setup_worker()

        with self.assertLogsContains(error_msg):
            await asyncio.gather(
                self.worker.connect(),
                self.assert_request_is_done(
                    "PUT", r"/api/1.0/work-request/(\d+)/completed/"
                ),
            )

        self.assertEqual(
            self.server_latest_request.json_content, {'result': 'error'}
        )

    @unittest_run_loop
    async def test_task_configure_error_submit_error(self):
        """Sbuild.configure fails, Worker submits error."""
        patcher = mock.patch.object(Sbuild, 'configure')
        mocked_configure = patcher.start()
        mocked_configure.side_effect = TaskConfigError('Invalid schema')
        self.addCleanup(patcher.stop)

        await self.assert_execute_work_request_send_and_log_error(
            'Task: sbuild Error configure: Invalid schema'
        )

    @unittest_run_loop
    async def test_task_execution_error_submit_error(self):
        """Sbuild.execute raises TaskConfigError, Worker submits error."""
        patcher = mock.patch.object(Sbuild, 'execute')
        mocked_execute = patcher.start()
        mocked_execute.side_effect = TaskConfigError('No suitable schroot')
        self.addCleanup(patcher.stop)

        await self.assert_execute_work_request_send_and_log_error(
            'Task: sbuild Error execute: No suitable schroot'
        )

    def test_system_metadata(self):
        """Worker._system_metadata() return valid system metadata."""
        total_physical_memory = system_information.total_physical_memory()
        cpu_count = system_information.cpu_count()

        self.assertEqual(
            Worker._system_metadata(),
            {
                'system:cpu_count': cpu_count,
                'system:total_physical_memory': total_physical_memory,
            },
        )

    @unittest_run_loop
    async def test_process_message_invalid_msg(self):
        """Worker._process_message returns False."""
        msg = WSMessage(WSMsgType.TEXT, '{"text": "something"}', None)

        self.setup_worker()

        message_processed = await self.worker._process_message(msg)

        self.assertFalse(message_processed)

    @unittest_run_loop
    async def test_process_message_txt_without_msg_key(self):
        """Worker._process_message returns False (no msg text)."""
        msg = WSMessage(WSMsgType.TEXT, '{"foo": "bar"}', None)
        self.setup_worker()

        log_message = "Could not connect. Reason: 'unknown'"

        with self.assertLogsContains(log_message):
            message_processed = await self.worker._process_message(msg)

        self.assertFalse(message_processed)

    @unittest_run_loop
    async def test_process_message_raise_token_disabled_error_disabled(self):
        """Worker._process_message logs reason of worker's not connection."""
        msg = WSMessage(
            WSMsgType.TEXT,
            '{"reason": "Disabled token", "reason_code": "TOKEN_DISABLED"}',
            None,
        )
        self.setup_worker()

        with self.assertRaises(TokenDisabledError) as exc:
            await self.worker._process_message(msg)

        self.assertEqual(str(exc.exception), "Disabled token")

    @unittest_run_loop
    async def test_process_message_raise_token_disabled_error_unknown(self):
        """Worker._process_message logs reason of worker's, exc is 'Unknown'."""
        msg = WSMessage(
            WSMsgType.TEXT,
            '{"reason_code": "TOKEN_DISABLED"}',
            None,
        )
        self.setup_worker()

        with self.assertRaises(TokenDisabledError) as exc:
            await self.worker._process_message(msg)

        self.assertEqual(str(exc.exception), "Unknown")

    @unittest_run_loop
    async def test_process_message_unknown_reason_code_is_logged_ignored(self):
        """Worker logs and returns False for msg with an unknown reason_code."""
        msg = WSMessage(
            WSMsgType.TEXT,
            '{"reason": "Disabled token",'
            '"reason_code": "SOMETHING_NOT_IMPLEMENTED"}',
            None,
        )

        self.setup_worker()

        with self.assertLogsContains('SOMETHING_NOT_IMPLEMENTED'):
            processed = await self.worker._process_message(msg)

        self.assertFalse(processed)

    @unittest_run_loop
    async def test_process_message_non_text_type(self):
        """Worker._process_message returns False for non-text type."""
        msg = WSMessage(WSMsgType.PING, None, None)
        self.setup_worker()

        message_processed = await self.worker._process_message(msg)

        self.assertFalse(message_processed)

    @unittest_run_loop
    async def test_process_message_invalid_msg_type(self):
        """Worker._process_message return False for an invalid message type."""
        msg = 'Something not an instance of WMessage'

        self.setup_worker()

        log_message = (
            "Worker._process_message: unexpected type: <class 'str'> is not an "
            "instance of <class 'aiohttp.http_websocket.WSMessage'>"
        )
        with self.assertLogsContains(log_message, level=logging.DEBUG):
            message_processed = await self.worker._process_message(msg)

        self.assertFalse(message_processed)

    @unittest_run_loop
    async def test_process_message_invalid_json(self):
        """Worker._process_message return False if the JSON is invalid."""
        msg = WSMessage(WSMsgType.TEXT, '{[', None)

        self.setup_worker()

        message_processed = await self.worker._process_message(msg)

        self.assertFalse(message_processed)

    def patch_config_handler_fail(self, config_handler):
        """
        Patch ConfigHandler._fail and returns mock.

        side_effect is aligned with the behaviour of _fail.
        """
        patched = mock.patch.object(config_handler, '_fail')
        fail_mocked = patched.start()
        fail_mocked.side_effect = SystemExit(3)
        self.addCleanup(patched.stop)

        return fail_mocked

    @unittest_run_loop
    async def test_invalid_configuration_missing_debusine_url(self):
        """Worker() raises an exception: debusine_url is not in [General]."""
        config_temp_directory = self.create_temp_config_directory(
            {'General': {}}
        )
        config = ConfigHandler(directories=[config_temp_directory])

        log_message = (
            f'Missing required key "debusine-url" in General '
            f'section in the configuration file '
            f'({config.active_configuration_file})'
        )

        fail_mocked = self.patch_config_handler_fail(config)

        with self.assertRaisesSystemExit(3):
            self.setup_worker(config)

        fail_mocked.assert_called_with(log_message)

    @unittest_run_loop
    async def test_invalid_configuration_missing_general_section(self):
        """Worker() raises an exception: [General] section is not available."""
        config_temp_directory = self.create_temp_config_directory({})
        config = ConfigHandler(directories=[config_temp_directory])

        log_message = (
            f'Missing required section "General" in the configuration file '
            f'({config.active_configuration_file})'
        )

        fail_mocked = self.patch_config_handler_fail(config)

        with self.assertRaisesSystemExit(3):
            self.setup_worker(config)

        fail_mocked.assert_called_with(log_message)

    def assert_functools_partial_equal(self, partial1, partial2):
        """Assert two functools.partials are semantically equal."""
        # functools.partial does not implement __eq__
        self.assertEqual(partial1.func, partial2.func)
        self.assertEqual(partial1.args, partial2.args)
        self.assertEqual(partial1.keywords, partial2.keywords)

    @unittest_run_loop
    async def test_main_calls_connect(self):
        """Ensure worker.main() calls worker.connect()."""
        self.setup_worker()
        mock_connect = self.patch_worker("connect")

        patcher = mock.patch.object(
            self.worker._main_event_loop, "add_signal_handler"
        )
        mocked_add_signal_handler = patcher.start()
        self.addCleanup(patcher.stop)

        await self.worker.main()

        sigint_call = mocked_add_signal_handler.mock_calls[0][1]
        self.assertEqual(sigint_call[0], signal.SIGINT)
        self.assert_functools_partial_equal(
            sigint_call[1],
            functools.partial(
                self.worker._create_task_signal_int_term_handler, signal.SIGINT
            ),
        )

        sigterm_call = mocked_add_signal_handler.mock_calls[1][1]
        self.assertEqual(sigterm_call[0], signal.SIGTERM)
        self.assert_functools_partial_equal(
            sigterm_call[1],
            functools.partial(
                self.worker._create_task_signal_int_term_handler, signal.SIGTERM
            ),
        )

        self.assertEqual(len(mocked_add_signal_handler.mock_calls), 2)

        mock_connect.assert_awaited()

    @unittest_run_loop
    async def test_create_task_signal_int_term_handler(self):
        """
        Test Task returned from Worker._create_task_signal_int_term_handler.

        Assert that the task logs "Terminated with signal ...".
        Other tests on the behaviour of Worker._signal_int_term_handler
        is implemented in the method test_signal_int_term_handler.

        Testing that Worker._create_task_signal_int_term_handler returns
        a Task that will execute Worker._signal_int_term_handler might be
        possible but only if accessing Task._coro private method.
        """
        self.setup_worker()

        test_signals = [signal.SIGINT, signal.SIGTERM]

        for test_signal in test_signals:
            with self.subTest(test_signal=test_signal):
                sig_handler = self.worker._create_task_signal_int_term_handler(
                    test_signal
                )

                self.assertIsInstance(sig_handler, asyncio.Task)

                with self.assertLogsContains(
                    f"Terminated with signal {test_signal.name}"
                ):
                    await sig_handler

    @unittest_run_loop
    async def test_make_http_request_raise_path_must_be_absolute(self):
        """Worker._make_http_request raise exception for relative paths."""
        self.setup_worker()

        with self.assertRaisesRegex(ValueError, 'Path must be absolute'):
            self.worker._make_http_request('PUT', 'api/endpoint')

    @unittest_run_loop
    async def test_make_http_request_raise_invalid_method(self):
        """Worker._make_http_request raise exception for unknown HTTP method."""
        self.setup_worker()

        with self.assertRaisesRegex(
            ValueError, '^Method must be one of: PUT, POST, GET$'
        ):
            self.worker._make_http_request('connect', '/api/endpoint')

    async def setup_for_make_http_request(self):
        """Set up self.worker to be able to make HTTP requests."""
        self.setup_worker(self.config)

    async def assert_http_request(self, method, include_token):
        """
        Assert that Worker._make_http_request sends the data and token.

        :param method: HTTP method used for the test.
        :param include_token: includes the token in the headers or not.
        """
        if include_token:
            self.setup_valid_token()

        await self.setup_for_make_http_request()

        path = '/collect_request_information/'

        if method != 'GET':
            data = {'key': 'data'}
        else:
            data = None
        await self.worker._make_http_request(method, path, json=data)

        # Token was included
        self.assertEqual(
            'token' in self.server_latest_request.headers, include_token
        )

        # HTTP method sent is the method received
        self.assertEqual(method, self.server_latest_request.method)

        # Data sent from the client is the data received in the server
        self.assertEqual(self.server_latest_request.json_content, data)

        # Path that the request was made is the path that it was received
        self.assertEqual(self.server_latest_request.path, path)

        # Avoid "ResourceWarning: Unclosed client session..." if a test
        # after this one set a new self.worker (and this one might get
        # destroyed without waiting for it)
        # (in other cases this is done in self.tearDownAsync() / Worker.main())
        await self.worker.close()

    @unittest_run_loop
    async def test_make_http_requests_with_token(self):
        """Worker._make_http_request makes the request with token."""
        for method in ('PUT', 'POST', 'GET'):
            with self.subTest(method=method):
                await self.assert_http_request(method, include_token=True)

    @unittest_run_loop
    async def test_make_http_request_without_token(self):
        """Worker._make_http_request makes the request without token."""
        for method in ('PUT', 'POST', 'GET'):
            with self.subTest(method=method):
                await self.assert_http_request(method, include_token=False)

    @unittest_run_loop
    async def test_make_http_request_get_with_data_raises_value_error(self):
        """_make_http_request raises ValueError if data passed in HTTP GET."""
        await self.setup_for_make_http_request()
        with self.assertRaisesRegex(
            ValueError, '^data not allowed in GET requests$'
        ):
            self.worker._make_http_request(
                'GET', path='/path/', data={'some_data': 'data'}
            )

    def assert_logging_setup(
        self, argv_setup: dict, config_setup: dict, expected: dict
    ):
        """
        Assert logging setup for argv_setup and config_setup.

        :param argv_setup: parameters that a user might have passed using
          the CLI
        :param config_setup: parameters that might be set in the config.ini
        :param expected: given the argv_setup and config_setup: what is the
          expected configuration
        """
        del self.config['General']['log-file']
        if config_setup['log-file'] is not None:
            self.config['General']['log-file'] = config_setup['log-file']
        if config_setup['log-level'] is not None:
            self.config['General']['log-level'] = logging.getLevelName(
                config_setup['log-level']
            )

        # Normalizes log_level: always using the str representation and not
        # the int
        if argv_setup['log_level'] is not None:
            argv_setup['log_level'] = logging.getLevelName(
                argv_setup['log_level']
            )
        expected['level'] = logging.getLevelName(expected['level'])

        # Patches basicConfig
        patcher = mock.patch('debusine.worker._worker.logging.basicConfig')
        mocked_logging = patcher.start()
        self.addCleanup(patcher.stop)

        self.setup_worker(**argv_setup)

        self.assertTrue(mocked_logging.called)
        called_args = mocked_logging.call_args[1]
        self.assertEqual(called_args['filename'], expected['filename'])
        self.assertEqual(called_args['level'], expected['level'])

    def test_setup_logging_no_logging_configuration(self):
        """Logging system: no explicit setup: use Worker.DEFAULT_LOG_LEVEL."""
        argv_setup = {'log_file': None, 'log_level': None}
        config_setup = {'log-file': None, 'log-level': None}
        expected = {'filename': None, 'level': Worker.DEFAULT_LOG_LEVEL}
        self.assert_logging_setup(argv_setup, config_setup, expected)

    def test_setup_logging_from_constructor(self):
        """Logging system: use settings from the Worker constructor."""
        log_file = '/var/log/debusine.log'
        argv_setup = {'log_file': log_file, 'log_level': logging.DEBUG}
        config_setup = {'log-file': None, 'log-level': None}
        expected = {'filename': log_file, 'level': logging.DEBUG}

        self.assert_logging_setup(argv_setup, config_setup, expected)

    def test_setup_logging_from_config(self):
        """Logging system: use settings from the configuration file."""
        log_file = '/var/log/debusine.log'
        argv_setup = {'log_file': None, 'log_level': None}
        config_setup = {'log-file': log_file, 'log-level': logging.DEBUG}
        expected = {'filename': log_file, 'level': logging.DEBUG}
        self.assert_logging_setup(argv_setup, config_setup, expected)

    def test_setup_logging_worker_constructor_overwrites_config(self):
        """Logging system: use settings from the Worker constructor."""
        log_file = '/var/log/debusine.log'
        argv_setup = {'log_file': log_file, 'log_level': logging.DEBUG}
        config_setup = {'log-file': 'unused', 'log-level': logging.ERROR}
        expected = {'filename': log_file, 'level': logging.DEBUG}

        self.assert_logging_setup(argv_setup, config_setup, expected)

    def test_setup_logging_cannot_open_log_file(self):
        """Worker raises SystemExit if the log-file cannot be opened."""
        patched = mock.patch('debusine.worker.Worker._fail')

        fail_mocked = patched.start()
        fail_mocked.side_effect = SystemExit(3)
        self.addCleanup(patched.stop)

        with self.assertRaisesRegex(SystemExit, '^3$'):
            self.setup_worker(log_file='/')

        fail_mocked.assert_called()

    def test_log_forced_exit(self):
        """log_forced_exit logs the event."""
        self.setup_worker()

        with self.assertLogsContains(
            "Terminated with signal SIGINT", level=logging.INFO
        ):
            self.worker.log_forced_exit(signal.SIGINT)

    @unittest_run_loop
    async def test_sleep(self):
        """Worker._sleep calls asyncio.sleep."""
        patched = mock.patch('asyncio.sleep')
        mocked = patched.start()
        self.addCleanup(patched.stop)

        await Worker._tenacity_sleep(1.5)

        mocked.assert_called_with(1.5)

    def test_send_task_result_no_loop_running(self):
        """
        Assert that "Could not send Task.execute() result" is logged.

        This might happen when debusine is shutting down and a Task is
        finished executing.
        """
        self.setup_worker()
        self.worker._task_name = "Sbuild"

        self.worker._task_running = Sbuild()

        self.worker._main_event_loop = mock.Mock()
        self.worker._main_event_loop.configure_mock(
            **{"is_running.return_value": False}
        )

        task_exec_future = mock.Mock()
        task_exec_future.configure_mock(**{"exception.return_value": None})

        with self.assertLogsContains(
            'Could not send Task.execute() result', level=logging.INFO
        ):
            self.worker._send_task_result("http://some_url", task_exec_future)

    @unittest_run_loop
    async def test_send_task_result_do_not_send_aborted_task(self):
        """Assert that _send_task_result does not send the result (aborted)."""
        self.setup_worker()
        self.worker._task_name = "Sbuild"

        self.worker._task_running = Sbuild()
        self.worker._task_running.abort()

        with self.assertLogsContains(
            "Task: Sbuild has been aborted", level=logging.INFO
        ):
            self.worker._send_task_result("http://some_url", None)

        await self.assert_request_is_not_done(
            "PUT", r"^/api/1.0/work-request/(\d+)/completed/$"
        )

    @unittest_run_loop
    async def test_signal_int_term_handler(self):
        """Test signal handler logs "Terminated..." and closes the session."""
        self.setup_worker()

        await self.worker.connect()

        self.assertFalse(self.worker._aiohttp_client_session.closed)

        with self.assertLogsContains("Terminated with signal SIGINT"):
            await self.worker._signal_int_term_handler(signal.SIGINT)

        self.assertTrue(self.worker._aiohttp_client_session.closed)

    @unittest_run_loop
    async def test_signal_int_term_handler_cancels_task(self):
        """Test signal handler aborts() the task."""
        self.setup_worker()
        self.worker._task_running = Sbuild()

        self.assertFalse(self.worker._task_running.aborted)

        with self.assertLogsContains("Terminated with signal SIGTERM"):
            await self.worker._signal_int_term_handler(signal.SIGTERM)

        self.assertTrue(self.worker._task_running.aborted)

    @unittest_run_loop
    async def test_setup_signal_int_term_handler(self):
        """Test that SIGINT/SIGTERM setup is done in Worker.main()."""
        self.setup_worker()

        self.worker._set_main_event_loop()

        self.assertFalse(
            self.worker._main_event_loop.remove_signal_handler(signal.SIGINT)
        )
        self.assertFalse(
            self.worker._main_event_loop.remove_signal_handler(signal.SIGTERM)
        )

        await self.worker.main()

        self.assertTrue(
            self.worker._main_event_loop.remove_signal_handler(signal.SIGINT)
        )
        self.assertTrue(
            self.worker._main_event_loop.remove_signal_handler(signal.SIGTERM)
        )
