diff --git a/client/__init__.py b/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/client/config.json b/client/config.json new file mode 100644 index 0000000..a0dfe4e --- /dev/null +++ b/client/config.json @@ -0,0 +1,20 @@ +{ + "network": { + "host": "127.0.0.1", + "port": 65432, + "receive_buffer_size": 1024 + }, + "blocked_domains": { + "example.com": true, + "ads.example.com": true, + "fxp.co.il": true + }, + "settings": { + "ad_block": "off", + "adult_block": "off" + }, + "logging": { + "level": "INFO", + "log_dir": "client_logs" + } +} \ No newline at end of file diff --git a/client/main.py b/client/main.py new file mode 100644 index 0000000..7ef3d01 --- /dev/null +++ b/client/main.py @@ -0,0 +1,8 @@ +from src.Application import Application + +def main() -> None: + application: Application = Application() + application.run() + +if __name__ == "__main__": + main() diff --git a/client/pytest.ini b/client/pytest.ini new file mode 100644 index 0000000..a635c5c --- /dev/null +++ b/client/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = . diff --git a/client/requirments.txt b/client/requirments.txt new file mode 100644 index 0000000..0a08159 --- /dev/null +++ b/client/requirments.txt @@ -0,0 +1,8 @@ +-e git+https://github.com/pazMenachem/My_Internet.git@b0c04b626f09baa0dace19fb70902cc8189f7ce0#egg=client&subdirectory=client +colorama==0.4.6 +exceptiongroup==1.2.2 +iniconfig==2.0.0 +packaging==24.1 +pluggy==1.5.0 +pytest==8.3.3 +tomli==2.0.2 diff --git a/client/setup.py b/client/setup.py new file mode 100644 index 0000000..37ab367 --- /dev/null +++ b/client/setup.py @@ -0,0 +1,7 @@ +from setuptools import setup, find_packages + +setup( + name="client", + packages=find_packages(), + version="0.1", +) \ No newline at end of file diff --git a/client/src/Application.py b/client/src/Application.py new file mode 100644 index 0000000..d4ec6bf --- /dev/null +++ b/client/src/Application.py @@ -0,0 +1,116 @@ +import json +import threading +from .Communicator import Communicator +from .View import Viewer +from .Logger import setup_logger +from .ConfigManager import ConfigManager + +from .utils import ( + STR_CODE, STR_CONTENT, + Codes +) + +class Application: + """ + Main application class that coordinates communication between UI and server. + + Uses threading to handle simultaneous GUI and network operations. + + Attributes: + _logger: Logger instance for application logging + _view: Viewer instance for GUI operations + _communicator: Communicator instance for network operations + """ + + def __init__(self) -> None: + """Initialize application components.""" + self._logger = setup_logger(__name__) + self._config_manager = ConfigManager() + self._request_lock = threading.Lock() + + self._view = Viewer(config_manager=self._config_manager, message_callback=self._handle_request) + self._communicator = Communicator(config_manager=self._config_manager, message_callback=self._handle_request) + + def run(self) -> None: + """ + Start the application with threaded communication handling. + + Raises: + Exception: If there's an error during startup of either component. + """ + self._logger.info("Starting application") + + try: + self._start_communication() + self._start_gui() + + except Exception as e: + self._logger.error(f"Error during execution: {str(e)}", exc_info=True) + raise + finally: + self._cleanup() + + def _start_communication(self) -> None: + """Initialize and start the communication thread.""" + try: + self._communicator.connect() + threading.Thread( + target=self._communicator.receive_message, + daemon=True + ).start() + + self._logger.info("Communication server started successfully") + except Exception as e: + self._logger.error(f"Failed to start communication: {str(e)}") + raise + + def _start_gui(self) -> None: + """Start the GUI main loop.""" + try: + self._logger.info("Starting GUI") + self._view.run() + + except Exception as e: + self._logger.error(f"Failed to start GUI: {str(e)}") + raise + + def _handle_request(self, request: str) -> None: + """ + Handle outgoing messages from the UI and Server. + + Args: + request: received request from server or user input from UI. + """ + try: + self._logger.info(f"Processing request: {request}") + request_dict = json.loads(request) + + with self._request_lock: + match request_dict[STR_CODE]: + case Codes.CODE_AD_BLOCK | \ + Codes.CODE_ADULT_BLOCK | \ + Codes.CODE_ADD_DOMAIN | \ + Codes.CODE_REMOVE_DOMAIN: + self._communicator.send_message(json.dumps(request)) + case Codes.CODE_DOMAIN_LIST_UPDATE: + self._view.update_domain_list(request_dict[STR_CONTENT]) + + except json.JSONDecodeError as e: + self._logger.error(f"Invalid JSON format: {str(e)}") + raise + except Exception as e: + self._logger.error(f"Error handling request: {str(e)}") + raise + + def _cleanup(self) -> None: + """Clean up resources and stop threads.""" + self._logger.info("Cleaning up application resources") + try: + if self._communicator: + self._communicator.close() + + if self._view and self._view.root.winfo_exists(): + self._view.root.destroy() + + except Exception as e: + self._logger.warning(f"Cleanup encountered an error: {str(e)}") diff --git a/client/src/Communicator.py b/client/src/Communicator.py new file mode 100644 index 0000000..e6275b7 --- /dev/null +++ b/client/src/Communicator.py @@ -0,0 +1,105 @@ +import socket +from typing import Optional, Callable +import json +from .Logger import setup_logger +from .utils import ( + DEFAULT_HOST, DEFAULT_PORT, DEFAULT_BUFFER_SIZE, + ERR_SOCKET_NOT_SETUP, + STR_NETWORK +) + +class Communicator: + def __init__(self, config_manager, message_callback: Callable[[str], None]) -> None: + """ + Initialize the communicator. + + Args: + config_manager: Configuration manager instance + message_callback: Callback function to handle received messages. + """ + self.logger = setup_logger(__name__) + self.logger.info("Initializing Communicator") + self.config = config_manager.get_config() + self._message_callback = message_callback + + self._host = self.config[STR_NETWORK][DEFAULT_HOST] + self._port = self.config[STR_NETWORK][DEFAULT_PORT] + self._receive_buffer_size = self.config[STR_NETWORK][DEFAULT_BUFFER_SIZE] + self._socket: Optional[socket.socket] = None + + def connect(self) -> None: + """ + Establish connection to the server. + + Raises: + socket.error: If connection cannot be established. + """ + try: + self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._socket.connect((self._host, self._port)) + self.logger.info(f"Connected to server at {self._host}:{self._port}") + except socket.error as e: + self.logger.error(f"Failed to connect to server: {str(e)}") + raise + + def send_message(self, message: str) -> None: + """ + Send a json message to the server. + + Args: + message_json: The message to send to the server. + + Raises: + RuntimeError: If socket connection is not established. + """ + self._validate_connection() + + try: + self._socket.send(message.encode('utf-8')) + self.logger.info(f"Message sent: {message}") + except Exception as e: + self.logger.error(f"Failed to send message: {str(e)}") + raise + + def receive_message(self) -> None: + """Continuously receive and process messages from the socket connection. + + This method runs in a loop to receive messages from the socket. Each received + message is decoded from UTF-8 and passed to the message callback function. + + Raises: + RuntimeError: If socket connection is not established. + socket.error: If there's an error receiving data from the socket. + UnicodeDecodeError: If received data cannot be decoded as UTF-8. + """ + self._validate_connection() + + self.logger.info("Starting message receive loop") + try: + while message_bytes := self._socket.recv(self._receive_buffer_size): + if not message_bytes: + self.logger.warning("Received empty message, breaking receive loop") + break + message = message_bytes.decode('utf-8') + self.logger.info(f"Received message: {message}") + self._message_callback(message) + except Exception as e: + self.logger.error(f"Error receiving message: {str(e)}") + raise + + def close(self) -> None: + """Close the socket connection and clean up resources.""" + if self._socket: + try: + self._socket.close() + self.logger.info("Socket connection closed") + except Exception as e: + self.logger.error(f"Error closing socket: {str(e)}") + finally: + self._socket = None + + def _validate_connection(self) -> None: + """Validate the socket connection.""" + if not self._socket: + self.logger.error(ERR_SOCKET_NOT_SETUP) + raise RuntimeError(ERR_SOCKET_NOT_SETUP) diff --git a/client/src/ConfigManager.py b/client/src/ConfigManager.py new file mode 100644 index 0000000..62e67df --- /dev/null +++ b/client/src/ConfigManager.py @@ -0,0 +1,87 @@ +"""Configuration management module for the application.""" + +import json +import os +from typing import Dict, Any +from .Logger import setup_logger +from .utils import DEFAULT_CONFIG + + +class ConfigManager: + """Manages application configuration loading and saving.""" + + def __init__(self, config_file: str = "config.json") -> None: + """ + Initialize the configuration manager. + + Args: + config_file: Path to the configuration file. + """ + self.logger = setup_logger(__name__) + self.config_file = config_file + self.config = self._load_config() + + def _load_config(self) -> Dict[str, Any]: + """ + Load configuration from JSON file. + + Returns: + Dict containing configuration settings. + """ + try: + if os.path.exists(self.config_file): + self.logger.info(f"Loading configuration from {self.config_file}") + with open(self.config_file, 'r') as f: + user_config = json.load(f) + return self._merge_configs(DEFAULT_CONFIG, user_config) + + self.logger.warning(f"Configuration file not found, using default configuration") + + except json.JSONDecodeError: + self.logger.error(f"Error decoding {self.config_file}, using default configuration") + + return DEFAULT_CONFIG.copy() + + def _merge_configs(self, default: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any]: + """ + Recursively merge user configuration with default configuration. + + Args: + default: Default configuration dictionary + user: User configuration dictionary + + Returns: + Merged configuration dictionary + """ + result = default.copy() + + for key, value in user.items(): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + result[key] = self._merge_configs(result[key], value) + else: + result[key] = value + + return result + + def save_config(self, config: Dict[str, Any]) -> None: + """ + Save configuration to JSON file. + + Args: + config: Configuration dictionary to save + """ + try: + with open(self.config_file, 'w') as f: + json.dump(config, f, indent=4) + self.logger.info("Configuration saved successfully") + except Exception as e: + self.logger.error(f"Error saving configuration: {str(e)}") + + def get_config(self) -> Dict[str, Any]: + """ + Get the current configuration. + + Returns: + Current configuration dictionary + """ + return self.config diff --git a/client/src/Logger.py b/client/src/Logger.py new file mode 100644 index 0000000..a6c5709 --- /dev/null +++ b/client/src/Logger.py @@ -0,0 +1,45 @@ +"""Logger module for handling application-wide logging configuration.""" + +import logging +import os +from datetime import datetime +from typing import Optional +from .utils import LOG_DIR, LOG_FORMAT, LOG_DATE_FORMAT + +_logger: Optional[logging.Logger] = None + +def setup_logger(name: str) -> logging.Logger: + """ + Configure and return a logger instance. + + Args: + name: The name of the module requesting the logger. + + Returns: + logging.Logger: Configured logger instance. + """ + global _logger + + if _logger is not None: + return logging.getLogger(name) + + if not os.path.exists(LOG_DIR): + os.makedirs(LOG_DIR) + + log_file: str = os.path.join( + LOG_DIR, f"Client_{datetime.now().strftime(LOG_DATE_FORMAT)}.log" + ) + + logging.basicConfig( + level=logging.INFO, + format=LOG_FORMAT, + handlers=[ + logging.FileHandler(log_file), + logging.StreamHandler(), + ], + ) + + _logger = logging.getLogger(name) + _logger.info("Logger setup complete") + + return _logger \ No newline at end of file diff --git a/client/src/View.py b/client/src/View.py new file mode 100644 index 0000000..2943421 --- /dev/null +++ b/client/src/View.py @@ -0,0 +1,378 @@ +import tkinter as tk +from tkinter import ttk, messagebox +from typing import Callable, List +import json +import threading +from .Logger import setup_logger +from .ConfigManager import ConfigManager + +from .utils import ( + Codes, + WINDOW_SIZE, WINDOW_TITLE, + ERR_DUPLICATE_DOMAIN, ERR_NO_DOMAIN_SELECTED, ERR_DOMAIN_LIST_UPDATE_FAILED, + STR_AD_BLOCK, STR_ADULT_BLOCK, STR_CODE, + STR_BLOCKED_DOMAINS, STR_CONTENT, STR_SETTINGS, STR_ERROR +) + + +class Viewer: + """ + Graphical user interface for the application. + """ + + def __init__(self, config_manager: ConfigManager, message_callback: Callable[[str], None]) -> None: + """ + Initialize the viewer window and its components. + + Args: + config_manager: Configuration manager instance + message_callback: Callback function to handle message sending. + """ + self.logger = setup_logger(__name__) + self.logger.info("Initializing Viewer") + self.config_manager = config_manager + self.config = config_manager.get_config() + self._message_callback = message_callback + self._update_list_lock = threading.Lock() + + # Initialize root window first + self.root: tk.Tk = tk.Tk() + self.root.title(WINDOW_TITLE) + self.root.geometry(WINDOW_SIZE) + + self.root.withdraw() # Hide the window temporarily + + # Configure styles + style = ttk.Style() + style.configure('TLabelframe', padding=10) + style.configure('TLabelframe.Label', font=('Arial', 10, 'bold')) + style.configure('TButton', padding=5) + style.configure('TRadiobutton', font=('Arial', 10)) + style.configure('TLabel', font=('Arial', 10)) + + self._setup_ui() + + # Show the window after setup is complete + self.root.deiconify() + self.logger.info("Viewer initialization complete") + + def run(self) -> None: + """Start the main event loop of the viewer.""" + self.logger.info("Starting main event loop") + self.root.mainloop() + + def get_blocked_domains(self) -> tuple[str, ...]: + """ + Get the list of currently blocked domains. + + Returns: + A tuple containing all blocked domains. + """ + return self.domains_listbox.get(0, tk.END) + + def get_block_settings(self) -> dict[str, str]: + """ + Get the current state of blocking settings. + + Returns: + A dictionary containing the current state of ad and adult content blocking. + """ + return { + STR_AD_BLOCK: self.ad_var.get(), + STR_ADULT_BLOCK: self.adult_var.get() + } + + def update_domain_list(self, domains: List[str]) -> None: + """ + Update the domains listbox with a new list of domains from the server. + + Args: + domains: List of domain strings to be displayed in the listbox. + """ + with self._update_list_lock: + self.logger.info("Updating domain list from server") + + try: + self.domains_listbox.delete(0, tk.END) + + for domain in domains: + self.domains_listbox.insert(tk.END, domain) + + self.logger.info(f"Updated domain list with {len(domains)} domains") + + except Exception as e: + self.logger.error(f"Error updating domain list: {str(e)}") + self._show_error(ERR_DOMAIN_LIST_UPDATE_FAILED) + + def _add_domain(self) -> None: + """Add a domain to the blocked sites list.""" + domain = self.domain_entry.get().strip() + + if domain: + if domain not in self.config[STR_BLOCKED_DOMAINS]: + self.domains_listbox.insert(tk.END, domain) + self.domain_entry.delete(0, tk.END) + + self.config[STR_BLOCKED_DOMAINS][domain] = True + self.config_manager.save_config(self.config) + + self._message_callback(json.dumps({ + STR_CODE: Codes.CODE_ADD_DOMAIN, + STR_CONTENT: domain + })) + + self.logger.info(f"Domain added: {domain}") + else: + self.logger.warning(f"Attempted to add duplicate domain: {domain}") + self._show_error(ERR_DUPLICATE_DOMAIN) + + def _remove_domain(self) -> None: + """Remove the selected domain from the blocked sites list.""" + selection = self.domains_listbox.curselection() + + if selection: + domain = self.domains_listbox.get(selection) + self.domains_listbox.delete(selection) + + del self.config[STR_BLOCKED_DOMAINS][domain] + self.config_manager.save_config(self.config) + + self._message_callback(json.dumps({ + STR_CODE: Codes.CODE_REMOVE_DOMAIN, + STR_CONTENT: domain + })) + + self.logger.info(f"Domain removed: {domain}") + else: + self.logger.warning("Attempted to remove domain without selection") + self._show_error(ERR_NO_DOMAIN_SELECTED) + + def _handle_ad_block(self) -> None: + """Handle changes to the ad block setting.""" + state = self.ad_var.get() + self.config[STR_SETTINGS][STR_AD_BLOCK] = state + self.config_manager.save_config(self.config) + + self._message_callback(json.dumps({ + STR_CODE: Codes.CODE_AD_BLOCK, + STR_CONTENT: state + })) + + self.logger.info(f"Ad blocking state changed to: {state}") + + def _handle_adult_block(self) -> None: + """Handle changes to the adult sites block setting.""" + state = self.adult_var.get() + self.config[STR_SETTINGS][STR_ADULT_BLOCK] = state + self.config_manager.save_config(self.config) + + self._message_callback(json.dumps({ + STR_CODE: Codes.CODE_ADULT_BLOCK, + STR_CONTENT: state + })) + + self.logger.info(f"Adult site blocking state changed to: {state}") + + def _show_error(self, message: str) -> None: + """ + Display an error message in a popup window. + + Args: + message: The error message to display. + """ + self.logger.error(f"Error message displayed: {message}") + tk.messagebox.showerror(STR_ERROR, message) + + def _setup_ui(self) -> None: + """Set up the UI components including block controls and domain list.""" + # Main container with increased padding + main_container = ttk.Frame(self.root, padding="20") + main_container.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + self.root.columnconfigure(0, weight=1) + self.root.rowconfigure(0, weight=1) + + # Left side - Specific sites block (now with better proportions) + sites_frame = ttk.LabelFrame( + main_container, + text="Specific Sites Block", + padding="15" + ) + sites_frame.grid( + row=0, + column=0, + rowspan=3, + padx=10, + sticky=(tk.W, tk.E, tk.N, tk.S) + ) + + # Create a frame for listbox and scrollbar + listbox_frame = ttk.Frame(sites_frame) + listbox_frame.grid(row=0, column=0, pady=5, sticky=(tk.W, tk.E, tk.N, tk.S)) + + # Domains listbox with scrollbars + self.domains_listbox = tk.Listbox( + listbox_frame, + width=40, + height=15, + selectmode=tk.SINGLE, + activestyle='dotbox', + font=('Arial', 10) + ) + scrollbar_y = ttk.Scrollbar( + listbox_frame, + orient=tk.VERTICAL, + command=self.domains_listbox.yview + ) + scrollbar_x = ttk.Scrollbar( + listbox_frame, + orient=tk.HORIZONTAL, + command=self.domains_listbox.xview + ) + + self.domains_listbox.configure( + yscrollcommand=scrollbar_y.set, + xscrollcommand=scrollbar_x.set + ) + + # Grid layout for listbox and scrollbars + self.domains_listbox.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + scrollbar_y.grid(row=0, column=1, sticky=(tk.N, tk.S)) + scrollbar_x.grid(row=1, column=0, sticky=(tk.W, tk.E)) + + # Add domain entry with improved layout + domain_entry_frame = ttk.Frame(sites_frame) + domain_entry_frame.grid( + row=1, + column=0, + pady=15, + sticky=(tk.W, tk.E) + ) + + ttk.Label( + domain_entry_frame, + text="Add Domain:", + font=('Arial', 10) + ).grid(row=0, column=0, padx=5) + + self.domain_entry = ttk.Entry( + domain_entry_frame, + font=('Arial', 10) + ) + self.domain_entry.grid( + row=0, + column=1, + padx=5, + sticky=(tk.W, tk.E) + ) + + # Buttons with improved styling + button_frame = ttk.Frame(sites_frame) + button_frame.grid( + row=2, + column=0, + pady=10, + sticky=(tk.W, tk.E) + ) + + style = ttk.Style() + style.configure('Action.TButton', padding=5) + + ttk.Button( + button_frame, + text="Add Domain", + style='Action.TButton', + command=self._add_domain + ).grid(row=0, column=0, padx=5) + + ttk.Button( + button_frame, + text="Remove Domain", + style='Action.TButton', + command=self._remove_domain + ).grid(row=0, column=1, padx=5) + + # Right side controls with improved spacing + controls_frame = ttk.Frame(main_container) + controls_frame.grid( + row=0, + column=1, + padx=20, + sticky=(tk.N, tk.S) + ) + + # Ad Block controls with better styling + ad_frame = ttk.LabelFrame( + controls_frame, + text="Ad Blocking", + padding="15" + ) + ad_frame.grid( + row=0, + column=0, + pady=10, + sticky=(tk.W, tk.E) + ) + + # Initialize with config value + self.ad_var = tk.StringVar(value=self.config[STR_SETTINGS][STR_AD_BLOCK]) + ttk.Radiobutton( + ad_frame, + text="Enable", + value="on", + variable=self.ad_var, + command=self._handle_ad_block + ).grid(row=0, column=0, padx=10) + ttk.Radiobutton( + ad_frame, + text="Disable", + value="off", + variable=self.ad_var, + command=self._handle_ad_block + ).grid(row=0, column=1, padx=10) + + # Adult sites Block controls + adult_frame = ttk.LabelFrame( + controls_frame, + text="Adult Content Blocking", + padding="15" + ) + adult_frame.grid( + row=1, + column=0, + pady=10, + sticky=(tk.W, tk.E) + ) + + # Initialize with config value + self.adult_var = tk.StringVar(value=self.config[STR_SETTINGS][STR_ADULT_BLOCK]) + ttk.Radiobutton( + adult_frame, + text="Enable", + value="on", + variable=self.adult_var, + command=self._handle_adult_block + ).grid(row=0, column=0, padx=10) + ttk.Radiobutton( + adult_frame, + text="Disable", + value="off", + variable=self.adult_var, + command=self._handle_adult_block + ).grid(row=0, column=1, padx=10) + + # Configure grid weights for better resizing + main_container.columnconfigure(0, weight=3) + main_container.columnconfigure(1, weight=1) + sites_frame.columnconfigure(0, weight=1) + listbox_frame.columnconfigure(0, weight=1) + listbox_frame.rowconfigure(0, weight=1) + domain_entry_frame.columnconfigure(1, weight=1) + button_frame.columnconfigure(0, weight=1) + button_frame.columnconfigure(1, weight=1) + + # Bind events + self.domains_listbox.bind('', lambda e: self._remove_domain()) + + # Load saved domains + for domain in self.config[STR_BLOCKED_DOMAINS].keys(): + self.domains_listbox.insert(tk.END, domain) diff --git a/client/src/__init__.py b/client/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/client/src/utils.py b/client/src/utils.py new file mode 100644 index 0000000..31b59f2 --- /dev/null +++ b/client/src/utils.py @@ -0,0 +1,64 @@ +"""Utility module containing constants and common functions for the application.""" + +# Network related constants +DEFAULT_HOST = "host" +DEFAULT_PORT = "port" +DEFAULT_BUFFER_SIZE = "receive_buffer_size" + +# GUI constants +WINDOW_TITLE = "Site Blocker" +WINDOW_SIZE = "800x600" +PADDING_SMALL = "5" +PADDING_MEDIUM = "10" + +# Message codes +class Codes: + """Constants for message codes used in communication.""" + CODE_AD_BLOCK = "50" + CODE_ADULT_BLOCK = "51" + CODE_ADD_DOMAIN = "52" + CODE_REMOVE_DOMAIN = "53" + CODE_DOMAIN_LIST_UPDATE = "54" + +# Default settings +DEFAULT_CONFIG = { + "network": { + "host": DEFAULT_HOST, + "port": DEFAULT_PORT, + "receive_buffer_size": DEFAULT_BUFFER_SIZE + }, + "blocked_domains": {}, + "settings": { + "ad_block": "off", + "adult_block": "off" + }, + "logging": { + "level": "INFO", + "log_dir": "client_logs" + } +} + +# Logging constants +LOG_DIR = "client_logs" +LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" +LOG_DATE_FORMAT = "%Y%m%d_%H%M%S" + +# Error messages +ERR_SOCKET_NOT_SETUP = "Socket not set up. Call connect method first." +ERR_NO_CONNECTION = "Attempted to send message without connection" +ERR_DUPLICATE_DOMAIN = "Domain already exists in the list" +ERR_NO_DOMAIN_SELECTED = "Please select a domain to remove" +ERR_DOMAIN_LIST_UPDATE_FAILED = "Failed to update domain list" + +# String Constants +STR_AD_BLOCK = "ad_block" +STR_ADULT_BLOCK = "adult_block" +STR_CODE = "code" +STR_CONTENT = "content" +STR_ERROR = "Error" + +# Config Constants +STR_BLOCKED_DOMAINS = "blocked_domains" +STR_NETWORK = "network" +STR_SETTINGS = "settings" +STR_LOGGING = "logging" diff --git a/client/tests/__init__.py b/client/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/client/tests/test_application.py b/client/tests/test_application.py new file mode 100644 index 0000000..f55c206 --- /dev/null +++ b/client/tests/test_application.py @@ -0,0 +1,145 @@ +import logging +from unittest import mock +from typing import Optional, Callable +import json + +import pytest + +from src.Application import Application +from src.View import Viewer +from src.Communicator import Communicator +from src.utils import ( + STR_CODE, STR_CONTENT, + Codes, DEFAULT_CONFIG +) + + +@pytest.fixture +def mock_config_manager() -> mock.Mock: + """Fixture to provide a mock configuration manager.""" + config_manager = mock.Mock() + config_manager.get_config.return_value = DEFAULT_CONFIG + return config_manager + + +@pytest.fixture +def application(mock_config_manager: mock.Mock) -> Application: + """Fixture to create an Application instance.""" + with mock.patch('src.Application.Viewer') as mock_viewer, \ + mock.patch('src.Application.Communicator') as mock_comm, \ + mock.patch('src.Application.setup_logger') as mock_logger: + app = Application() + app._logger = mock.Mock() + app._config_manager = mock_config_manager + return app + + +def test_init(application: Application) -> None: + """Test the initialization of Application.""" + assert hasattr(application, '_logger') + assert hasattr(application, '_view') + assert hasattr(application, '_communicator') + assert hasattr(application, '_request_lock') + assert hasattr(application, '_config_manager') + + +@mock.patch('src.Application.threading.Thread') +def test_start_communication( + mock_thread: mock.Mock, + application: Application +) -> None: + """Test the communication startup.""" + application._start_communication() + + application._communicator.connect.assert_called_once() + mock_thread.assert_called_once_with( + target=application._communicator.receive_message, + daemon=True + ) + mock_thread.return_value.start.assert_called_once() + + +def test_start_gui(application: Application) -> None: + """Test the GUI startup.""" + application._start_gui() + application._view.run.assert_called_once() + + +def test_handle_request_ad_block(application: Application) -> None: + """Test handling ad block request.""" + test_request = { + STR_CODE: Codes.CODE_AD_BLOCK, + STR_CONTENT: "test" + } + + application._communicator.send_message = mock.Mock() + + application._handle_request(json.dumps(test_request)) + + actual_arg = application._communicator.send_message.call_args[0][0] + + assert json.loads(json.loads(actual_arg)) == test_request + + +def test_handle_request_domain_list_update(application: Application) -> None: + """Test handling domain list update request.""" + test_content = ["domain1.com", "domain2.com"] + test_request = json.dumps({ + STR_CODE: Codes.CODE_DOMAIN_LIST_UPDATE, + STR_CONTENT: test_content + }) + + application._handle_request(test_request) + application._view.update_domain_list.assert_called_once_with(test_content) + + +def test_cleanup(application: Application) -> None: + """Test cleanup process.""" + application._cleanup() + + application._communicator.close.assert_called_once() + application._view.root.destroy.assert_called_once() + + +def test_run_success(application: Application) -> None: + """Test successful application run.""" + with mock.patch.object(application, '_start_communication'), \ + mock.patch.object(application, '_start_gui'), \ + mock.patch.object(application, '_cleanup'): + + application.run() + + application._start_communication.assert_called_once() + application._start_gui.assert_called_once() + application._cleanup.assert_called_once() + + +def test_run_exception(application: Application) -> None: + """Test application run with exception.""" + error_msg = "Test error" + + with mock.patch.object(application, '_start_communication') as mock_start_comm, \ + mock.patch.object(application, '_cleanup') as mock_cleanup: + + mock_start_comm.side_effect = Exception(error_msg) + + with pytest.raises(Exception) as exc_info: + application.run() + + assert str(exc_info.value) == error_msg + application._logger.error.assert_called_with( + f"Error during execution: {error_msg}", + exc_info=True + ) + mock_cleanup.assert_called_once() + + +def test_handle_request_json_error(application: Application) -> None: + """Test handling of invalid JSON in request.""" + invalid_json = "{" + + with pytest.raises(json.JSONDecodeError): + application._handle_request(invalid_json) + + application._logger.error.assert_called() + \ No newline at end of file diff --git a/client/tests/test_communicator.py b/client/tests/test_communicator.py new file mode 100644 index 0000000..2a761e4 --- /dev/null +++ b/client/tests/test_communicator.py @@ -0,0 +1,161 @@ +import socket +from unittest import mock +from typing import Optional, Callable + +import pytest + +from src.Communicator import Communicator +from src.utils import ( + DEFAULT_HOST, DEFAULT_PORT, DEFAULT_BUFFER_SIZE, + ERR_SOCKET_NOT_SETUP, STR_NETWORK, + DEFAULT_CONFIG +) + + +@pytest.fixture +def mock_config_manager() -> mock.Mock: + """Fixture to provide a mock configuration manager.""" + config_manager = mock.Mock() + config_manager.get_config.return_value = DEFAULT_CONFIG + return config_manager + + +@pytest.fixture +def mock_callback() -> Callable[[str], None]: + """Fixture to provide a mock callback function.""" + return mock.Mock() + + +@pytest.fixture +def communicator( + mock_config_manager: mock.Mock, + mock_callback: Callable[[str], None] +) -> Communicator: + """Fixture to create a Communicator instance.""" + return Communicator( + config_manager=mock_config_manager, + message_callback=mock_callback + ) + + +def test_init( + communicator: Communicator, + mock_callback: Callable[[str], None] +) -> None: + """Test the initialization of Communicator.""" + assert communicator._host == DEFAULT_CONFIG[STR_NETWORK][DEFAULT_HOST] + assert communicator._port == DEFAULT_CONFIG[STR_NETWORK][DEFAULT_PORT] + assert communicator._receive_buffer_size == DEFAULT_CONFIG[STR_NETWORK][DEFAULT_BUFFER_SIZE] + assert communicator._socket is None + assert communicator._message_callback == mock_callback + + +@mock.patch('socket.socket') +def test_connect( + mock_socket_class: mock.Mock, + communicator: Communicator +) -> None: + """Test the connect method initializes and connects the socket.""" + mock_socket_instance = mock_socket_class.return_value + communicator.connect() + + mock_socket_class.assert_called_once_with( + socket.AF_INET, + socket.SOCK_STREAM + ) + mock_socket_instance.connect.assert_called_once_with( + (communicator._host, communicator._port) + ) + assert communicator._socket is mock_socket_instance + + +@mock.patch('socket.socket') +def test_send_message_without_setup( + mock_socket_class: mock.Mock, + communicator: Communicator +) -> None: + """Test sending a message without setting up the socket raises RuntimeError.""" + with pytest.raises(RuntimeError) as exc_info: + communicator.send_message("Hello") + assert str(exc_info.value) == ERR_SOCKET_NOT_SETUP + + +@mock.patch('socket.socket') +def test_send_message( + mock_socket_class: mock.Mock, + communicator: Communicator +) -> None: + """Test sending a message successfully.""" + mock_socket_instance = mock_socket_class.return_value + communicator._socket = mock_socket_instance + + message: str = "Hello, World!" + communicator.send_message(message) + + mock_socket_instance.send.assert_called_once_with( + message.encode('utf-8') + ) + + +@mock.patch('socket.socket') +def test_receive_message_without_setup( + mock_socket_class: mock.Mock, + communicator: Communicator +) -> None: + """Test receiving a message without setting up the socket raises RuntimeError.""" + with pytest.raises(RuntimeError) as exc_info: + communicator.receive_message() + assert str(exc_info.value) == ERR_SOCKET_NOT_SETUP + + +@mock.patch('socket.socket') +def test_receive_message( + mock_socket_class: mock.Mock, + communicator: Communicator, + mock_callback: Callable[[str], None] +) -> None: + """Test receiving a message successfully.""" + mock_socket_instance = mock_socket_class.return_value + communicator._socket = mock_socket_instance + + mock_socket_instance.recv.side_effect = [b'Hello, Client!', b''] + + communicator.receive_message() + + mock_socket_instance.recv.assert_called_with( + DEFAULT_CONFIG[STR_NETWORK][DEFAULT_BUFFER_SIZE] + ) + mock_callback.assert_called_once_with('Hello, Client!') + + +@mock.patch('socket.socket') +def test_close_socket( + mock_socket_class: mock.Mock, + communicator: Communicator +) -> None: + """Test closing the socket.""" + mock_socket_instance = mock_socket_class.return_value + communicator._socket = mock_socket_instance + + communicator.close() + + mock_socket_instance.close.assert_called_once() + assert communicator._socket is None + + +@mock.patch('socket.socket') +def test_receive_message_decode_error( + mock_socket_class: mock.Mock, + communicator: Communicator, + mock_callback: Callable[[str], None] +) -> None: + """Test handling of decode errors in receive_message.""" + mock_socket_instance = mock_socket_class.return_value + communicator._socket = mock_socket_instance + + mock_socket_instance.recv.side_effect = [bytes([0xFF, 0xFE, 0xFD]), b''] + + with pytest.raises(UnicodeDecodeError): + communicator.receive_message() + + mock_callback.assert_not_called() diff --git a/client/tests/test_view.py b/client/tests/test_view.py new file mode 100644 index 0000000..f83f0b9 --- /dev/null +++ b/client/tests/test_view.py @@ -0,0 +1,158 @@ +import pytest +from unittest import mock +import json +from typing import Callable + +from src.View import Viewer +from src.utils import ( + Codes, STR_CODE, STR_CONTENT, + STR_SETTINGS, STR_AD_BLOCK, STR_ADULT_BLOCK, + STR_BLOCKED_DOMAINS, DEFAULT_CONFIG, + ERR_DUPLICATE_DOMAIN +) + +@pytest.fixture +def mock_config_manager() -> mock.Mock: + """Fixture to provide a mock configuration manager.""" + config_manager = mock.Mock() + config_manager.get_config.return_value = DEFAULT_CONFIG.copy() + return config_manager + +@pytest.fixture +def mock_callback() -> Callable[[str], None]: + """Fixture to provide a mock callback function.""" + return mock.Mock() + +@pytest.fixture +def viewer(mock_config_manager: mock.Mock, mock_callback: mock.Mock) -> Viewer: + """Fixture to create a Viewer instance with mocked components.""" + with mock.patch('tkinter.Tk') as mock_tk, \ + mock.patch('tkinter.ttk.Style'): + # Create a mock Tk instance + root = mock_tk.return_value + + # Set up the mock root properly + mock_tk._default_root = root + root._default_root = root + + # Create StringVar mock that returns string values + with mock.patch('tkinter.StringVar') as mock_string_var: + string_var_instance = mock.Mock() + string_var_instance.get.return_value = "off" + mock_string_var.return_value = string_var_instance + + # Create Entry and Listbox mocks + with mock.patch('tkinter.Entry') as mock_entry, \ + mock.patch('tkinter.Listbox') as mock_listbox: + + # Setup Entry mock + entry_instance = mock.Mock() + entry_instance.get.return_value = "" + mock_entry.return_value = entry_instance + + # Setup Listbox mock + listbox_instance = mock.Mock() + listbox_instance.curselection.return_value = () + listbox_instance.get.return_value = "" + mock_listbox.return_value = listbox_instance + + viewer = Viewer( + config_manager=mock_config_manager, + message_callback=mock_callback + ) + + # Store mock instances for easy access in tests + viewer.domain_entry = entry_instance + viewer.domains_listbox = listbox_instance + + # Mock the _show_error method + viewer._show_error = mock.Mock() + + return viewer + +def test_get_block_settings(viewer: Viewer) -> None: + """Test getting block settings.""" + # Configure the mock StringVar to return specific values + viewer.ad_var.get.return_value = "off" + viewer.adult_var.get.return_value = "off" + + settings = viewer.get_block_settings() + assert STR_AD_BLOCK in settings + assert STR_ADULT_BLOCK in settings + assert isinstance(settings[STR_AD_BLOCK], str) + assert isinstance(settings[STR_ADULT_BLOCK], str) + +def test_handle_ad_block(viewer: Viewer) -> None: + """Test handling ad block setting changes.""" + # Configure the mock StringVar to return "on" + viewer.ad_var.get.return_value = "on" + viewer._handle_ad_block() + + expected_json = json.dumps({ + STR_CODE: Codes.CODE_AD_BLOCK, + STR_CONTENT: "on" + }) + + viewer._message_callback.assert_called_once_with(expected_json) + viewer.config_manager.save_config.assert_called_once_with(viewer.config) + assert viewer.config[STR_SETTINGS][STR_AD_BLOCK] == "on" + +def test_handle_adult_block(viewer: Viewer) -> None: + """Test handling adult block setting changes.""" + # Configure the mock StringVar to return "on" + viewer.adult_var.get.return_value = "on" + viewer._handle_adult_block() + + expected_json = json.dumps({ + STR_CODE: Codes.CODE_ADULT_BLOCK, + STR_CONTENT: "on" + }) + + viewer._message_callback.assert_called_once_with(expected_json) + viewer.config_manager.save_config.assert_called_once_with(viewer.config) + assert viewer.config[STR_SETTINGS][STR_ADULT_BLOCK] == "on" + +def test_add_domain(viewer: Viewer) -> None: + """Test adding a domain.""" + domain = "test.com" + viewer.domain_entry.get.return_value = domain + viewer._add_domain() + + expected_json = json.dumps({ + STR_CODE: Codes.CODE_ADD_DOMAIN, + STR_CONTENT: domain + }) + + viewer._message_callback.assert_called_once_with(expected_json) + viewer.config_manager.save_config.assert_called_once_with(viewer.config) + assert viewer.config[STR_BLOCKED_DOMAINS][domain] is True + +def test_add_duplicate_domain(viewer: Viewer) -> None: + """Test adding a duplicate domain.""" + domain = "test.com" + viewer.config[STR_BLOCKED_DOMAINS][domain] = True + viewer.domain_entry.get.return_value = domain + + viewer._add_domain() + + viewer._message_callback.assert_not_called() + viewer._show_error.assert_called_once_with(ERR_DUPLICATE_DOMAIN) + assert len(viewer.config[STR_BLOCKED_DOMAINS]) == 1 + +def test_remove_domain(viewer: Viewer) -> None: + """Test removing a domain.""" + domain = "test.com" + viewer.config[STR_BLOCKED_DOMAINS][domain] = True + viewer.domains_listbox.curselection.return_value = (0,) + viewer.domains_listbox.get.return_value = domain + + viewer._remove_domain() + + expected_json = json.dumps({ + STR_CODE: Codes.CODE_REMOVE_DOMAIN, + STR_CONTENT: domain + }) + + viewer._message_callback.assert_called_once_with(expected_json) + viewer.config_manager.save_config.assert_called_once_with(viewer.config) + assert domain not in viewer.config[STR_BLOCKED_DOMAINS]