diff --git a/listeners/filters.py b/listeners/filters.py new file mode 100644 index 0000000..ae6c186 --- /dev/null +++ b/listeners/filters.py @@ -0,0 +1,42 @@ +from dataclasses import dataclass +from enum import Enum +from typing import List, Optional + + +class FilterType(Enum): + MULTI_SELECT = "multi_select" + TOGGLE = "toggle" + + +@dataclass +class FilterOptions: + name: str + value: str + + +@dataclass +class Filter: + name: str + display_name: str + type: FilterType + display_name_plural: Optional[str] = None + options: Optional[List[FilterOptions]] = None + + +LANGUAGES_FILTER = Filter( + name="languages", + display_name="Language", + display_name_plural="Languages", + type=FilterType.MULTI_SELECT.value, + options=[ + FilterOptions(name="Python", value="python"), + FilterOptions(name="Java", value="java"), + FilterOptions(name="JavaScript", value="javascript"), + FilterOptions(name="TypeScript", value="typescript"), + ], +) + +TEMPLATES_FILTER = Filter(name="template", display_name="Templates", type=FilterType.TOGGLE.value) + + +SAMPLES_FILTER = Filter(name="sample", display_name="Samples", type=FilterType.TOGGLE.value) diff --git a/listeners/functions/filters.py b/listeners/functions/filters.py index 674a3a6..1904370 100644 --- a/listeners/functions/filters.py +++ b/listeners/functions/filters.py @@ -1,29 +1,17 @@ import logging -from enum import Enum -from typing import List, Optional, TypedDict +from dataclasses import asdict +from typing import Dict from slack_bolt import Ack, Complete, Fail +from listeners.filters import LANGUAGES_FILTER, SAMPLES_FILTER, TEMPLATES_FILTER + FILTER_PROCESSING_ERROR_MSG = ( "We encountered an issue processing filter results. Please try again or contact the app owner if the problem persists." ) - -class FilterType(Enum): - MULTI_SELECT = "multi_select" - TOGGLE = "toggle" - - -class FilterOptions(TypedDict): - name: str - value: str - - -class SearchFilter(TypedDict): - name: str - display_name: str - filter_type: FilterType - options: Optional[List[FilterOptions]] +def filter_none(items: Dict): + return {k: v for k, v in items if v is not None} def filters_step_callback(ack: Ack, inputs: dict, fail: Fail, complete: Complete, logger: logging.Logger): @@ -31,32 +19,20 @@ def filters_step_callback(ack: Ack, inputs: dict, fail: Fail, complete: Complete user_context = inputs.get("user_context", {}) logger.debug(f"User {user_context.get('id')} executing filter request") - filters: List[SearchFilter] = [ - { - "name": "languages", - "display_name": "Languages", - "type": FilterType.MULTI_SELECT.value, - "options": [ - {"name": "Python", "value": "python"}, - {"name": "Java", "value": "java"}, - {"name": "JavaScript", "value": "javascript"}, - {"name": "TypeScript", "value": "typescript"}, - ], - }, - { - "name": "type", - "display_name": "Type", - "type": FilterType.MULTI_SELECT.value, - "options": [ - {"name": "Template", "value": "template"}, - {"name": "Sample", "value": "sample"}, - ], - }, - ] - - complete(outputs={"filters": filters}) + complete( + outputs={ + "filters": [ + asdict(LANGUAGES_FILTER, dict_factory=filter_none), + asdict(TEMPLATES_FILTER, dict_factory=filter_none), + asdict(SAMPLES_FILTER, dict_factory=filter_none), + ] + } + ) except Exception as e: - logger.error(f"Unexpected error occurred while processing filter request: {type(e).__name__} - {str(e)}", exc_info=e) + logger.error( + f"Unexpected error occurred while processing filter request: {type(e).__name__} - {str(e)}", + exc_info=e, + ) fail(error=FILTER_PROCESSING_ERROR_MSG) finally: ack() diff --git a/listeners/functions/search.py b/listeners/functions/search.py index c0af4d7..4d99db5 100644 --- a/listeners/functions/search.py +++ b/listeners/functions/search.py @@ -1,6 +1,5 @@ import logging -from ast import List -from typing import NotRequired, Optional, TypedDict +from typing import List, NotRequired, Optional, TypedDict from slack_bolt import Ack, Complete, Fail from slack_sdk import WebClient @@ -12,8 +11,6 @@ "Please try again or contact the app owner if the problem persists." ) -print(SEARCH_PROCESSING_ERROR_MSG) - class EntityReference(TypedDict): id: str @@ -29,21 +26,19 @@ class SearchResult(TypedDict): content: NotRequired[str] -def search_step_callback(ack: Ack, inputs: dict, fail: Fail, complete: Complete, client: WebClient, logger: logging.Logger): +def search_step_callback( + ack: Ack, + inputs: dict, + fail: Fail, + complete: Complete, + client: WebClient, + logger: logging.Logger, +): try: query = inputs.get("query") - filters = inputs.get("filters", {}) - languages_filter = filters.get("languages", []) - type_filter = filters.get("type", []) - - filters_payload = {} - if languages_filter: - filters_payload["languages"] = languages_filter - if type_filter: - if len(type_filter) == 1: - filters_payload["type"] = type_filter[0] + filters = inputs.get("filters") - response = fetch_sample_data(client=client, query=query, filters=filters_payload, logger=logger) + response = fetch_sample_data(client=client, query=query, filters=filters, logger=logger) samples = response.get("samples", []) @@ -66,7 +61,8 @@ def search_step_callback(ack: Ack, inputs: dict, fail: Fail, complete: Complete, fail(error=SEARCH_PROCESSING_ERROR_MSG) else: logger.error( - f"Unexpected error occurred while processing search request: {type(e).__name__} - {str(e)}", exc_info=e + f"Unexpected error occurred while processing search request: {type(e).__name__} - {str(e)}", + exc_info=e, ) finally: ack() diff --git a/listeners/sample_data_service.py b/listeners/sample_data_service.py index 54b3f3b..b61bdb3 100644 --- a/listeners/sample_data_service.py +++ b/listeners/sample_data_service.py @@ -1,8 +1,11 @@ -import json import logging from slack_sdk import WebClient +from listeners.filters import LANGUAGES_FILTER, SAMPLES_FILTER, TEMPLATES_FILTER + +API_METHOD = "developer.sampleData.get" + class SlackResponseError(Exception): def __init__(self, message: str): @@ -11,21 +14,31 @@ def __init__(self, message: str): def fetch_sample_data(client: WebClient, query: str = None, filters: dict = None, logger: logging.Logger = None): - method = "developer.sampleData.get" - params = {} - if query: - params["query"] = query + params = {"query": query} + if filters: - params["filters"] = json.dumps(filters) + selected_filters = {} + + languages = filters.get(LANGUAGES_FILTER.name, []) + templates = filters.get(TEMPLATES_FILTER.name, False) + samples = filters.get(SAMPLES_FILTER.name, False) - response = client.api_call(method, params=params) + if languages: + selected_filters[LANGUAGES_FILTER.name] = languages + + if templates ^ samples: + if templates: + selected_filters["type"] = TEMPLATES_FILTER.name + elif samples: + selected_filters["type"] = SAMPLES_FILTER.name + + if selected_filters: + params["filters"] = selected_filters + + response = client.api_call(API_METHOD, params=params) if not response.get("ok", False): logger.error(f"Search API request failed with error: {response.get('error', 'no error found')}") - raise SlackResponseError(f"Failed to fetch sample data from Slack API: ok=false for method={method}") - - if "samples" not in response: - logger.error(f"Failed to parse API response as sample data. Received: {json.dumps(response)}") - raise SlackResponseError(f"Invalid response format from Slack API from {method}") + raise SlackResponseError(f"Failed to fetch sample data from Slack API: ok=false for method={API_METHOD}") return response diff --git a/tests/listeners/functions/test_filters.py b/tests/listeners/functions/test_filters.py index 936775b..f55b6b5 100644 --- a/tests/listeners/functions/test_filters.py +++ b/tests/listeners/functions/test_filters.py @@ -15,8 +15,9 @@ def setup_method(self): self.expected_filters = [ { "name": "languages", - "display_name": "Languages", + "display_name": "Language", "type": "multi_select", + "display_name_plural": "Languages", "options": [ {"name": "Python", "value": "python"}, {"name": "Java", "value": "java"}, @@ -25,13 +26,14 @@ def setup_method(self): ], }, { - "name": "type", - "display_name": "Type", - "type": "multi_select", - "options": [ - {"name": "Template", "value": "template"}, - {"name": "Sample", "value": "sample"}, - ], + "name": "template", + "display_name": "Templates", + "type": "toggle", + }, + { + "name": "sample", + "display_name": "Samples", + "type": "toggle", }, ] @@ -39,7 +41,11 @@ def test_filters_step_callback_success(self): inputs = {"user_context": {"id": "U123456"}} filters_step_callback( - ack=self.mock_ack, inputs=inputs, fail=self.mock_fail, complete=self.mock_complete, logger=self.mock_logger + ack=self.mock_ack, + inputs=inputs, + fail=self.mock_fail, + complete=self.mock_complete, + logger=self.mock_logger, ) self.mock_complete.assert_called_once() @@ -52,7 +58,11 @@ def test_filters_step_callback_success(self): def test_filters_step_callback_empty_user_context(self): filters_step_callback( - ack=self.mock_ack, inputs={}, fail=self.mock_fail, complete=self.mock_complete, logger=self.mock_logger + ack=self.mock_ack, + inputs={}, + fail=self.mock_fail, + complete=self.mock_complete, + logger=self.mock_logger, ) self.mock_complete.assert_called_once() @@ -66,12 +76,15 @@ def test_filters_step_callback_unexpected_exception(self): self.mock_complete.side_effect = Exception("Unexpected error") filters_step_callback( - ack=self.mock_ack, inputs={}, fail=self.mock_fail, complete=self.mock_complete, logger=self.mock_logger + ack=self.mock_ack, + inputs={}, + fail=self.mock_fail, + complete=self.mock_complete, + logger=self.mock_logger, ) self.mock_fail.assert_called_once() call_args = self.mock_fail.call_args - print(FILTER_PROCESSING_ERROR_MSG) assert call_args.kwargs["error"] == FILTER_PROCESSING_ERROR_MSG self.mock_ack.assert_called_once() diff --git a/tests/listeners/functions/test_search.py b/tests/listeners/functions/test_search.py index b26ff1d..9c26777 100644 --- a/tests/listeners/functions/test_search.py +++ b/tests/listeners/functions/test_search.py @@ -3,6 +3,7 @@ from slack_bolt import Ack, Complete, Fail from slack_sdk import WebClient +from listeners.filters import LANGUAGES_FILTER, SAMPLES_FILTER, TEMPLATES_FILTER from listeners.functions.search import SEARCH_PROCESSING_ERROR_MSG, search_step_callback from listeners.sample_data_service import SlackResponseError @@ -40,7 +41,9 @@ def setup_method(self): def test_search_step_callback_success(self, mock_fetch_sample_data): mock_fetch_sample_data.return_value = self.mock_sample_data - inputs = {"query": "test query", "filters": {"languages": ["python"], "type": ["code"]}} + filters = {LANGUAGES_FILTER.name: ["python"]} + + inputs = {"query": "test query", "filters": filters} search_step_callback( ack=self.mock_ack, @@ -54,18 +57,46 @@ def test_search_step_callback_success(self, mock_fetch_sample_data): mock_fetch_sample_data.assert_called_once_with( client=self.mock_client, query="test query", - filters={"languages": ["python"], "type": "code"}, + filters=filters, logger=self.mock_logger, ) self.mock_complete.assert_called_once() call_args = self.mock_complete.call_args outputs = call_args.kwargs["outputs"] + assert outputs["search_result"] == self.mock_sample_data["samples"] self.mock_ack.assert_called_once() self.mock_fail.assert_not_called() + @patch("listeners.functions.search.fetch_sample_data") + def test_search_step_callback_multiple_filter_types(self, mock_fetch_sample_data): + mock_fetch_sample_data.return_value = self.mock_sample_data + + filters = {TEMPLATES_FILTER.name: True, SAMPLES_FILTER.name: True, LANGUAGES_FILTER.name: ["python", "javascript"]} + + inputs = {"query": "test query", "filters": filters} + + search_step_callback( + ack=self.mock_ack, + inputs=inputs, + fail=self.mock_fail, + complete=self.mock_complete, + client=self.mock_client, + logger=self.mock_logger, + ) + + mock_fetch_sample_data.assert_called_once_with( + client=self.mock_client, + query="test query", + filters=filters, + logger=self.mock_logger, + ) + + self.mock_complete.assert_called_once() + self.mock_ack.assert_called_once() + @patch("listeners.functions.search.fetch_sample_data") def test_search_step_callback_no_filters(self, mock_fetch_sample_data): mock_fetch_sample_data.return_value = {"samples": []} @@ -80,7 +111,7 @@ def test_search_step_callback_no_filters(self, mock_fetch_sample_data): ) mock_fetch_sample_data.assert_called_once_with( - client=self.mock_client, query="test query", filters={}, logger=self.mock_logger + client=self.mock_client, query="test query", filters=None, logger=self.mock_logger ) self.mock_complete.assert_called_once() @@ -123,27 +154,8 @@ def test_search_step_callback_unexpected_exception(self, mock_fetch_sample_data) logger=self.mock_logger, ) + self.mock_logger.error.assert_called_once() + self.mock_fail.assert_not_called() self.mock_complete.assert_not_called() self.mock_ack.assert_called_once() - - @patch("listeners.functions.search.fetch_sample_data") - def test_search_step_callback_multiple_type_filters(self, mock_fetch_sample_data): - mock_fetch_sample_data.return_value = {"samples": []} - - inputs = {"query": "test query", "filters": {"type": ["code", "document"]}} - - search_step_callback( - ack=self.mock_ack, - inputs=inputs, - fail=self.mock_fail, - complete=self.mock_complete, - client=self.mock_client, - logger=self.mock_logger, - ) - - mock_fetch_sample_data.assert_called_once_with( - client=self.mock_client, query="test query", filters={}, logger=self.mock_logger - ) - - self.mock_complete.assert_called_once() diff --git a/tests/listeners/test_sample_data_service.py b/tests/listeners/test_sample_data_service.py new file mode 100644 index 0000000..06d543e --- /dev/null +++ b/tests/listeners/test_sample_data_service.py @@ -0,0 +1,113 @@ +from unittest.mock import MagicMock + +import pytest +from slack_sdk import WebClient + +from listeners.filters import LANGUAGES_FILTER, SAMPLES_FILTER, TEMPLATES_FILTER +from listeners.sample_data_service import API_METHOD, SlackResponseError, fetch_sample_data + + +class TestSampleDataService: + def setup_method(self): + self.mock_client = MagicMock(spec=WebClient) + self.mock_logger = MagicMock() + + self.mock_response = { + "ok": True, + "samples": [ + { + "title": "Sample 1", + "description": "Description 1", + "link": "https://example.com/1", + "date_updated": "2023-01-01", + "external_ref": {"id": "sample1"}, + }, + { + "title": "Sample 2", + "description": "Description 2", + "link": "https://example.com/2", + "date_updated": "2023-01-02", + "external_ref": {"id": "sample2"}, + "content": "Full content here", + }, + ], + } + + self.mock_client.api_call.return_value = self.mock_response + + def test_fetch_sample_data_no_filters(self): + result = fetch_sample_data(client=self.mock_client, logger=self.mock_logger) + + self.mock_client.api_call.assert_called_once_with(API_METHOD, params={"query": None}) + assert result == self.mock_response + + def test_fetch_sample_data_with_query(self): + result = fetch_sample_data(client=self.mock_client, query="test query", logger=self.mock_logger) + + self.mock_client.api_call.assert_called_once_with(API_METHOD, params={"query": "test query"}) + assert result == self.mock_response + + def test_fetch_sample_data_with_languages_filter(self): + filters = {LANGUAGES_FILTER.name: ["python", "javascript"]} + + result = fetch_sample_data(client=self.mock_client, query="test query", filters=filters, logger=self.mock_logger) + + self.mock_client.api_call.assert_called_once_with(API_METHOD, params={"query": "test query", "filters": filters}) + + assert result == self.mock_response + + def test_fetch_sample_data_with_templates_filter(self): + filters = {TEMPLATES_FILTER.name: True} + + result = fetch_sample_data(client=self.mock_client, query="test query", filters=filters, logger=self.mock_logger) + + self.mock_client.api_call.assert_called_once_with( + API_METHOD, params={"query": "test query", "filters": {"type": TEMPLATES_FILTER.name}} + ) + + assert result == self.mock_response + + def test_fetch_sample_data_with_samples_filter(self): + filters = {SAMPLES_FILTER.name: True} + + result = fetch_sample_data(client=self.mock_client, query="test query", filters=filters, logger=self.mock_logger) + + self.mock_client.api_call.assert_called_once_with( + API_METHOD, params={"query": "test query", "filters": {"type": SAMPLES_FILTER.name}} + ) + + assert result == self.mock_response + + def test_fetch_sample_data_with_combined_filters(self): + filters = {LANGUAGES_FILTER.name: ["python"], TEMPLATES_FILTER.name: True} + + result = fetch_sample_data(client=self.mock_client, query="test query", filters=filters, logger=self.mock_logger) + + self.mock_client.api_call.assert_called_once_with( + API_METHOD, + params={"query": "test query", "filters": {LANGUAGES_FILTER.name: ["python"], "type": TEMPLATES_FILTER.name}}, + ) + + assert result == self.mock_response + + def test_fetch_sample_data_with_both_template_and_sample(self): + filters = {TEMPLATES_FILTER.name: True, SAMPLES_FILTER.name: True} + + result = fetch_sample_data(client=self.mock_client, query="test query", filters=filters, logger=self.mock_logger) + + self.mock_client.api_call.assert_called_once_with(API_METHOD, params={"query": "test query"}) + + assert result == self.mock_response + + def test_fetch_sample_data_api_error(self): + error_response = {"ok": False, "error": "invalid_auth"} + self.mock_client.api_call.return_value = error_response + + with pytest.raises(SlackResponseError) as excinfo: + fetch_sample_data(client=self.mock_client, query="test query", logger=self.mock_logger) + + # Verify error was logged + self.mock_logger.error.assert_called_once() + + # Verify exception message + assert f"Failed to fetch sample data from Slack API: ok=false for method={API_METHOD}" in str(excinfo.value)