From 5ac6b27ca34c964a37b4db1294721c4a2030e2ee Mon Sep 17 00:00:00 2001 From: Iamhexi Date: Tue, 15 Oct 2024 16:03:27 +0200 Subject: [PATCH 1/5] feat(backend, materials, utils): implement CRUD in materials database --- knowledge_verificator/backend.py | 39 ++++++++++-- knowledge_verificator/materials.py | 75 +++++++++++++++++++++-- knowledge_verificator/utils/filesystem.py | 6 +- 3 files changed, 107 insertions(+), 13 deletions(-) diff --git a/knowledge_verificator/backend.py b/knowledge_verificator/backend.py index bf0baac..96da3df 100644 --- a/knowledge_verificator/backend.py +++ b/knowledge_verificator/backend.py @@ -88,7 +88,7 @@ def get_material(material_id: str, response: Response): @endpoints.post('/materials') def add_material(material: Material, response: Response) -> dict: """ - Endpoint to add a learning material to a database. + Endpoint to add a learning material to the database. Args: material (Material): Learning material to be added. @@ -110,13 +110,15 @@ def add_material(material: Material, response: Response) -> dict: return format_response(message=message) data = {'material_id': material.id} - return format_response(data=data) + return format_response( + data=data, message=f'Added the material with id = {material.id}.' + ) @endpoints.delete('/materials/{material_id}') def delete_material(material_id: str, response: Response) -> dict: """ - Endpoint to delete a learning material. + Endpoint to delete a learning material from the database. Args: material_id (str): ID of the material to be removed. @@ -134,4 +136,33 @@ def delete_material(material_id: str, response: Response) -> dict: return format_response(message=message) response.status_code = 200 - return format_response(data=str(material_id)) + return format_response( + message=f'Deleted the material with id = {material_id}.' + ) + + +@endpoints.delete('/materials') +def update_material(material: Material, response: Response) -> dict: + """ + Endpoint to update multiple attributes of a learning material + in the database. + + Args: + material_id (str): ID of the material to be removed. + response (Response): Response to a request. Automatically passed. + + Returns: + dict: Under `data` key, there is `material_id` key containing ID + of the removed material. + """ + try: + material_db.update_material(material) + except KeyError as e: + message = str(e) + response.status_code = 404 + return format_response(message=message) + + response.status_code = 200 + return format_response( + message=f'Updated the material with id = {material.id}.' + ) diff --git a/knowledge_verificator/materials.py b/knowledge_verificator/materials.py index 406dae0..6b84b00 100644 --- a/knowledge_verificator/materials.py +++ b/knowledge_verificator/materials.py @@ -1,9 +1,9 @@ """Module with tools for managing learning material.""" -from dataclasses import dataclass +from dataclasses import dataclass, field +import hashlib import os from pathlib import Path -import uuid from knowledge_verificator.utils.filesystem import in_directory @@ -14,11 +14,17 @@ class Material: Data class representing a learning material loaded from a database. """ - path: Path title: str paragraphs: list[str] - tags: list[str] - id: str = str(uuid.uuid4()) + tags: list[str] = field(default_factory=list) + path: Path | None = None + id: str = '' + + def __eq__(self, value: object) -> bool: + if not isinstance(value, (Material)): + return False + + return value.id == self.id class MaterialDatabase: @@ -90,7 +96,8 @@ def delete_material(self, material: Material | str) -> None: """ Remove the first material matching the provided material with its `id`. - As `id` is actually universally unique identifier it should remove one item + As `id` is actually universally unique identifier, + it should remove one item. Args: @@ -112,6 +119,12 @@ def delete_material(self, material: Material | str) -> None: index = self.materials.index(material) del self.materials[index] + def _title_to_path(self, title: str) -> Path: + title = title.replace(' ', '_') + title = title.replace('"', '') + title = title.replace("'", '') + return self.materials_dir.joinpath(title) + def add_material(self, material: Material) -> None: """ Add a learning material to a database, also material's its @@ -131,11 +144,21 @@ def add_material(self, material: Material) -> None: """ if not material.title: raise ValueError('Title of a learning material cannot be empty.') + + content = '\n\n'.join(material.paragraphs) + material.id = hashlib.sha256( + (material.title + content).encode(encoding='utf-8') + ).hexdigest() + + if material.path is None: + material.path = self._title_to_path(material.title) + if material.path.exists(): raise FileExistsError( 'A file in the provided path already exists. ' 'Choose a different filename.' ) + if not in_directory(file=material.path, directory=self.materials_dir): raise ValueError( f'A file {os.path.basename(material.path)}' @@ -167,6 +190,46 @@ def _format_file_content(self, material: Material) -> str: return output def _create_file_with_material(self, material: Material) -> None: + if material.path is None: + raise ValueError( + f'Cannot create a material without a valid path. Current path: `{material.path}`.' + ) with open(material.path, 'wt', encoding='utf-8') as fd: file_content = self._format_file_content(material=material) fd.write(file_content) + + def update_material( + self, material: Material, ignore_empty: bool = True + ) -> None: + """ + Update an existing learning material. + + + Args: + material (Material): Instance of learning material from the database. + ignore_empty (bool, optional): Do not update attribute with the + value equal to `None`. Defaults to True. + + Raises: + KeyError: Raised if the learning material is not + present in the database. + """ + if material not in self.materials: + raise KeyError( + f'Cannot update non-existent material: {str(material)}.' + ) + + for _, attribute in material.__dataclass_fields__.items(): + field_name = attribute.name + value = getattr(material, field_name) + + if value is None and ignore_empty: + continue + + index = self.materials.index(material) + original_material = self.materials[index] + + setattr(original_material, field_name, value) + + # Override a file with old material with the updated one. + self._create_file_with_material(material) diff --git a/knowledge_verificator/utils/filesystem.py b/knowledge_verificator/utils/filesystem.py index bcbfc9c..810ed59 100644 --- a/knowledge_verificator/utils/filesystem.py +++ b/knowledge_verificator/utils/filesystem.py @@ -15,9 +15,9 @@ def in_directory(file: Path, directory: Path) -> bool: Returns: bool: Present in a directory or subdirectories (True) or not (False). """ - return str(directory.resolve()) in str( - file.resolve() - ) and not file.samefile(directory) + directory_path = str(directory.resolve()) + file_path = str(file.resolve()) + return directory_path in file_path and directory_path != file_path def create_text_file(path: Path | str, content: str = '') -> None: From 35a06c13d6e7d48d2b3befeb640bfb347d27a59c Mon Sep 17 00:00:00 2001 From: Iamhexi Date: Tue, 15 Oct 2024 20:21:14 +0200 Subject: [PATCH 2/5] fix(backen, io_handler, tests): Fixed running backend tests with mock cli args (WIP) --- knowledge_verificator/backend.py | 26 +++--- knowledge_verificator/command_line.py | 3 +- knowledge_verificator/io_handler.py | 37 ++++++--- knowledge_verificator/main.py | 7 +- knowledge_verificator/materials.py | 20 +++-- .../utils/configuration_parser.py | 1 - poetry.lock | 2 +- pyproject.toml | 1 + tests/software/test_materials_database.py | 80 +++++++++++++++++++ 9 files changed, 140 insertions(+), 37 deletions(-) create mode 100644 tests/software/test_materials_database.py diff --git a/knowledge_verificator/backend.py b/knowledge_verificator/backend.py index 96da3df..46ae1af 100644 --- a/knowledge_verificator/backend.py +++ b/knowledge_verificator/backend.py @@ -5,10 +5,10 @@ from fastapi import FastAPI, Response from knowledge_verificator.materials import Material, MaterialDatabase -from knowledge_verificator.io_handler import config +from knowledge_verificator.io_handler import get_config -endpoints = FastAPI() -material_db = MaterialDatabase(materials_dir=config.learning_materials) +ENDPOINTS = FastAPI() +MATERIAL_DB = MaterialDatabase(materials_dir=get_config().learning_materials) def format_response(data: Any = '', message: str = '') -> dict: @@ -38,7 +38,7 @@ def format_response(data: Any = '', message: str = '') -> dict: } -@endpoints.get('/materials') +@ENDPOINTS.get('/materials') def get_materials( response: Response, criteria: Union[str, None] = None ) -> dict: @@ -58,10 +58,10 @@ def get_materials( response.status_code = 501 return format_response(message=message) response.status_code = 200 - return format_response(data=material_db.materials) + return format_response(data=MATERIAL_DB.materials) -@endpoints.get('/materials/{material_id}') +@ENDPOINTS.get('/materials/{material_id}') def get_material(material_id: str, response: Response): """ Get a specific learning material. @@ -74,7 +74,7 @@ def get_material(material_id: str, response: Response): dict: Under `data` key, there are `material_id` and `material` keys. """ try: - material = material_db[material_id] + material = MATERIAL_DB[material_id] except KeyError: message = f'Material with id = {material_id} was not found.' response.status_code = 404 @@ -85,7 +85,7 @@ def get_material(material_id: str, response: Response): return format_response(data=data) -@endpoints.post('/materials') +@ENDPOINTS.post('/materials') def add_material(material: Material, response: Response) -> dict: """ Endpoint to add a learning material to the database. @@ -101,7 +101,7 @@ def add_material(material: Material, response: Response) -> dict: response.status_code = 200 message = '' try: - material_db.add_material(material=material) + MATERIAL_DB.add_material(material=material) except (ValueError, FileExistsError) as e: message = str(e) response.status_code = 400 @@ -115,7 +115,7 @@ def add_material(material: Material, response: Response) -> dict: ) -@endpoints.delete('/materials/{material_id}') +@ENDPOINTS.delete('/materials/{material_id}') def delete_material(material_id: str, response: Response) -> dict: """ Endpoint to delete a learning material from the database. @@ -129,7 +129,7 @@ def delete_material(material_id: str, response: Response) -> dict: of the removed material. """ try: - material_db.delete_material(material=material_id) + MATERIAL_DB.delete_material(material=material_id) except KeyError as e: message = str(e) response.status_code = 400 @@ -141,7 +141,7 @@ def delete_material(material_id: str, response: Response) -> dict: ) -@endpoints.delete('/materials') +@ENDPOINTS.delete('/materials') def update_material(material: Material, response: Response) -> dict: """ Endpoint to update multiple attributes of a learning material @@ -156,7 +156,7 @@ def update_material(material: Material, response: Response) -> dict: of the removed material. """ try: - material_db.update_material(material) + MATERIAL_DB.update_material(material) except KeyError as e: message = str(e) response.status_code = 404 diff --git a/knowledge_verificator/command_line.py b/knowledge_verificator/command_line.py index dc359cc..e7ed678 100644 --- a/knowledge_verificator/command_line.py +++ b/knowledge_verificator/command_line.py @@ -2,7 +2,7 @@ from rich.text import Text -from knowledge_verificator.io_handler import logger, console, config +from knowledge_verificator.io_handler import logger, console, get_config from knowledge_verificator.answer_chooser import AnswerChooser from knowledge_verificator.materials import MaterialDatabase from knowledge_verificator.nli import NaturalLanguageInference, Relation @@ -45,6 +45,7 @@ def run_cli_mode(): qg_module = QuestionGeneration() ac_module = AnswerChooser() nli_module = NaturalLanguageInference() + config = get_config() while True: options = ['knowledge database', 'my own paragraph'] diff --git a/knowledge_verificator/io_handler.py b/knowledge_verificator/io_handler.py index 06e6d56..f918225 100644 --- a/knowledge_verificator/io_handler.py +++ b/knowledge_verificator/io_handler.py @@ -1,15 +1,19 @@ """ -Module handling Input/Output, including parsing CLI arguments, provding +Module handling Input/Output, including parsing CLI arguments, providing an instance of `rich` console, and an instance of a preconfigured `Logger`. """ from argparse import ArgumentParser +from functools import cache from logging import Logger from pathlib import Path from rich.console import Console from rich.logging import RichHandler -from knowledge_verificator.utils.configuration_parser import ConfigurationParser +from knowledge_verificator.utils.configuration_parser import ( + Configuration, + ConfigurationParser, +) def get_argument_parser() -> ArgumentParser: @@ -39,16 +43,27 @@ def get_argument_parser() -> ArgumentParser: return arg_parser -console = Console() -logger = Logger('main_logger') +@cache +def get_config() -> Configuration: + """ + Get configuration of the system. -_logging_handler = RichHandler(rich_tracebacks=True) + Returns: + Configuration: Instance of configuration. + """ + _parser = get_argument_parser() + args = _parser.parse_args() -_parser = get_argument_parser() -args = _parser.parse_args() + _configuration_parser = ConfigurationParser(configuration_file=args.config) + config = _configuration_parser.parse_configuration() -_configuratioParser = ConfigurationParser(configuration_file=args.config) -config = _configuratioParser.parse_configuration() + _logging_handler.setLevel(config.logging_level) + logger.addHandler(_logging_handler) -_logging_handler.setLevel(config.logging_level) -logger.addHandler(_logging_handler) + return config + + +console = Console() +logger = Logger('main_logger') + +_logging_handler = RichHandler(rich_tracebacks=True) diff --git a/knowledge_verificator/main.py b/knowledge_verificator/main.py index 092165b..50df445 100755 --- a/knowledge_verificator/main.py +++ b/knowledge_verificator/main.py @@ -3,13 +3,14 @@ from pathlib import Path import sys -from knowledge_verificator.io_handler import config +from knowledge_verificator.io_handler import get_config from knowledge_verificator.utils.configuration_parser import OperatingMode from knowledge_verificator.command_line import run_cli_mode -from knowledge_verificator.backend import endpoints +from knowledge_verificator.backend import ENDPOINTS from tests.model.runner import ExperimentRunner if __name__ == '__main__': + config = get_config() match config.mode: case OperatingMode.EXPERIMENT: experiment_directory = Path(config.experiment_implementation) @@ -24,7 +25,7 @@ import uvicorn uvicorn.run( - endpoints, + ENDPOINTS, host='127.0.0.1', port=8000, reload=(not config.production_mode), diff --git a/knowledge_verificator/materials.py b/knowledge_verificator/materials.py index 6b84b00..568d2cc 100644 --- a/knowledge_verificator/materials.py +++ b/knowledge_verificator/materials.py @@ -85,13 +85,16 @@ def load_material(self, path: Path) -> Material: content = ''.join(fd.readlines()).rstrip() paragraphs = content.split('\n\n') - return Material( + material = Material( path=path.resolve(), title=title, paragraphs=paragraphs, tags=tags, ) + self._set_id(material) + return material + def delete_material(self, material: Material | str) -> None: """ Remove the first material matching the provided material with its `id`. @@ -125,10 +128,16 @@ def _title_to_path(self, title: str) -> Path: title = title.replace("'", '') return self.materials_dir.joinpath(title) + def _set_id(self, material: Material) -> None: + content = '\n\n'.join(material.paragraphs) + material.id = hashlib.sha256( + (material.title + content).encode(encoding='utf-8') + ).hexdigest() + def add_material(self, material: Material) -> None: """ - Add a learning material to a database, also material's its - representation in a file. + Add a learning material to a database, also create material's + representation in a file. Args: material (Material): Initialised learning material without @@ -145,10 +154,7 @@ def add_material(self, material: Material) -> None: if not material.title: raise ValueError('Title of a learning material cannot be empty.') - content = '\n\n'.join(material.paragraphs) - material.id = hashlib.sha256( - (material.title + content).encode(encoding='utf-8') - ).hexdigest() + self._set_id(material) if material.path is None: material.path = self._title_to_path(material.title) diff --git a/knowledge_verificator/utils/configuration_parser.py b/knowledge_verificator/utils/configuration_parser.py index 4e96ba4..340ae03 100644 --- a/knowledge_verificator/utils/configuration_parser.py +++ b/knowledge_verificator/utils/configuration_parser.py @@ -63,7 +63,6 @@ def __init__( self.__setattr__(attribute, value) # Convert to a proper datatypes. - self.learning_materials: Path = Path(kwargs['learning_materials']) self.mode: OperatingMode = OperatingMode(kwargs['mode']) self.experiment_implementation = Path( kwargs['experiment_implementation'] diff --git a/poetry.lock b/poetry.lock index bc6144e..9827f56 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2746,4 +2746,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "147f2537157ec9820556620585f198b9adc74fec900b2b670c5ffb484f159363" +content-hash = "641d77274fd2ddc9a0668e8de2ed66bdf095a9b5cad190059b295c05a2cb7958" diff --git a/pyproject.toml b/pyproject.toml index ce78230..22cb3ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ tqdm = "^4.66.5" sentence-transformers = "^3.1.1" fastapi = {extras = ["standard"], version = "^0.115.2"} pyyaml = "^6.0.2" +requests = "^2.32.3" [tool.poetry.group.test] diff --git a/tests/software/test_materials_database.py b/tests/software/test_materials_database.py new file mode 100644 index 0000000..0fa7d51 --- /dev/null +++ b/tests/software/test_materials_database.py @@ -0,0 +1,80 @@ +"""Module with test for backend operations on the database of materials.""" + +import json +import multiprocessing +import os +from pathlib import Path +import shutil +import sys +import time +import pytest +import requests # type: ignore[import-untyped] +import uvicorn +import uvicorn.server + + +@pytest.fixture +def database_directory(): + """ + Create a directory for test database during the setup, + and return path to it; remove the directory during the teardown. + """ + directory = Path('test_database') + os.mkdir(directory) + + yield directory + shutil.rmtree(directory) + + +@pytest.fixture() +def server_ip() -> str: + """Fixture to provide IP of the server.""" + return '127.0.0.1' + + +@pytest.fixture() +def server_port() -> int: + """Fixture to provide port of the server.""" + return 8000 + + +@pytest.fixture +def mock_args(monkeypatch): + """ + Simulate command-line arguments to prevent argparse from consuming + pytest's arguments. + """ + monkeypatch.setattr( + sys, 'argv', ['knowledge_verificator', '-c', 'config.yaml'] + ) + + +@pytest.fixture +def server(mock_args, database_directory, server_ip, server_port): + """Set up and teardown the server with endpoints.""" + process = multiprocessing.Process( + target=uvicorn.run, + args=('knowledge_verificator.backend:ENDPOINTS',), + kwargs={'host': server_ip, 'port': server_port, 'reload': True}, + ) + process.start() + # Wait for a server to start up. + time.sleep(2) + + yield + + process.terminate() + time.sleep(2) + process.kill() + + +def test_getting_empty_database(server, server_ip, server_port): + """Test if the empty database returns no materials when requested.""" + url = f'http://{server_ip}:{server_port}/materials' + response = requests.get(url=url, timeout=10) + + content = response.content.decode() + content = json.loads(content) + + assert content['data'] == [] + assert content['message'] == [] From 45b8828b57da5619ecafa145947dfc1eb81c83ab Mon Sep 17 00:00:00 2001 From: Iamhexi Date: Tue, 15 Oct 2024 23:32:50 +0200 Subject: [PATCH 3/5] feat(backend, tests): implement tests of materials db on backend --- knowledge_verificator/backend.py | 2 +- knowledge_verificator/main.py | 3 +- knowledge_verificator/materials.py | 6 + pyproject.toml | 5 + tests/software/test_config.yaml | 6 + tests/software/test_materials_database.py | 182 +++++++++++++++++++++- 6 files changed, 193 insertions(+), 11 deletions(-) create mode 100644 tests/software/test_config.yaml diff --git a/knowledge_verificator/backend.py b/knowledge_verificator/backend.py index 46ae1af..b8821d8 100644 --- a/knowledge_verificator/backend.py +++ b/knowledge_verificator/backend.py @@ -141,7 +141,7 @@ def delete_material(material_id: str, response: Response) -> dict: ) -@ENDPOINTS.delete('/materials') +@ENDPOINTS.put('/materials') def update_material(material: Material, response: Response) -> dict: """ Endpoint to update multiple attributes of a learning material diff --git a/knowledge_verificator/main.py b/knowledge_verificator/main.py index 50df445..6ddc84f 100755 --- a/knowledge_verificator/main.py +++ b/knowledge_verificator/main.py @@ -6,7 +6,6 @@ from knowledge_verificator.io_handler import get_config from knowledge_verificator.utils.configuration_parser import OperatingMode from knowledge_verificator.command_line import run_cli_mode -from knowledge_verificator.backend import ENDPOINTS from tests.model.runner import ExperimentRunner if __name__ == '__main__': @@ -25,7 +24,7 @@ import uvicorn uvicorn.run( - ENDPOINTS, + 'knowledge_verificator.backend:ENDPOINTS', host='127.0.0.1', port=8000, reload=(not config.production_mode), diff --git a/knowledge_verificator/materials.py b/knowledge_verificator/materials.py index 568d2cc..49ce84c 100644 --- a/knowledge_verificator/materials.py +++ b/knowledge_verificator/materials.py @@ -225,6 +225,9 @@ def update_material( f'Cannot update non-existent material: {str(material)}.' ) + index = self.materials.index(material) + old_path = self.materials[index].path + for _, attribute in material.__dataclass_fields__.items(): field_name = attribute.name value = getattr(material, field_name) @@ -237,5 +240,8 @@ def update_material( setattr(original_material, field_name, value) + # If path is missing (not provided), use the old path. + if not material.path: + material.path = old_path # Override a file with old material with the updated one. self._create_file_with_material(material) diff --git a/pyproject.toml b/pyproject.toml index 22cb3ee..7af9251 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,11 @@ exclude = "tests/model/*" [tool.pytest.ini_options] addopts = "--cov=knowledge_verificator --cov-report html --cov-branch" +[tool.coverage] +exclude = [ + "*/backend.py", # Backend tests exists but are not reported by coverage. +] + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" diff --git a/tests/software/test_config.yaml b/tests/software/test_config.yaml new file mode 100644 index 0000000..d431a92 --- /dev/null +++ b/tests/software/test_config.yaml @@ -0,0 +1,6 @@ +mode: BACKEND +logging_level: DEBUG +production_mode: true +learning_materials: ./test_database +experiment_implementation: ./tests/model +experiment_results: ./tests/model/results diff --git a/tests/software/test_materials_database.py b/tests/software/test_materials_database.py index 0fa7d51..bc1cda2 100644 --- a/tests/software/test_materials_database.py +++ b/tests/software/test_materials_database.py @@ -7,6 +7,7 @@ import shutil import sys import time +from typing import Any import pytest import requests # type: ignore[import-untyped] import uvicorn @@ -44,8 +45,9 @@ def mock_args(monkeypatch): Simulate command-line arguments to prevent argparse from consuming pytest's arguments. """ + temporary_test_config = 'tests/software/test_config.yaml' monkeypatch.setattr( - sys, 'argv', ['knowledge_verificator', '-c', 'config.yaml'] + sys, 'argv', ['knowledge_verificator', '-c', temporary_test_config] ) @@ -55,7 +57,7 @@ def server(mock_args, database_directory, server_ip, server_port): process = multiprocessing.Process( target=uvicorn.run, args=('knowledge_verificator.backend:ENDPOINTS',), - kwargs={'host': server_ip, 'port': server_port, 'reload': True}, + kwargs={'host': server_ip, 'port': server_port, 'reload': False}, ) process.start() # Wait for a server to start up. @@ -68,13 +70,177 @@ def server(mock_args, database_directory, server_ip, server_port): process.kill() +def send_request( + endpoint: str, + server: str, + port: int, + timeout: int = 15, + method: str = 'get', + request_body: Any = None, + expect_failure: bool = False, +) -> tuple[dict, int]: + """ + Wrapper for convenient requests sending. + + Args: + endpoint (str): Name of the API endpoint. + server (str): IP or domain of the server. + port (int): Port of the server. + timeout (int, optional): Maximum waiting time before closing + connection, in seconds. Defaults to 10. + method (str, optional): HTTP method, one of "get", "post", "put", + "delete", "patch". Defaults to 'get'. + request_body (Any, optional): Body of the HTTP request in JSON. + By default, None. + expect_error (bool, optional): If a failure is the expected behaviour. + If True, no exceptions are emitted for failed requests. + + Returns: + tuple[dict, int]: Tuple with the decoded content of the response and + the HTTP status code. + """ + url = f'http://{server}:{port}/{endpoint}' + response = requests.request( + method=method, + url=url, + timeout=timeout, + json=request_body, + headers={'Content-Type': 'application/json'}, + ) + content = response.content.decode() + content = json.loads(content) + if response.status_code != 200 and not expect_failure: + raise ValueError(content) + + return (content, response.status_code) + + def test_getting_empty_database(server, server_ip, server_port): """Test if the empty database returns no materials when requested.""" - url = f'http://{server_ip}:{server_port}/materials' - response = requests.get(url=url, timeout=10) + response, _ = send_request( + endpoint='materials', + server=server_ip, + port=server_port, + method='get', + ) + assert response['data'] == [] + assert response['message'] == '' + + +def test_adding_material(server, server_ip, server_port): + """Test if a material""" + data = { + 'title': '123', + 'paragraphs': ['123'], + 'tags': ['123'], + } + + response, _ = send_request( + endpoint='materials', + server=server_ip, + port=server_port, + method='post', + request_body=data, + ) - content = response.content.decode() - content = json.loads(content) + assert response['data']['material_id'], 'Material id is empty.' + + +def test_adding_and_removing_material(server, server_ip, server_port): + """Test if a material may be added then removed.""" + data = { + 'title': '123', + 'paragraphs': ['123'], + 'tags': ['123'], + } + + response, _ = send_request( + endpoint='materials', + server=server_ip, + port=server_port, + method='post', + request_body=data, + ) + + assert response['data']['material_id'], 'Material id is empty.' + + material_id = response['data']['material_id'] + response, _ = send_request( + endpoint=f'materials/{material_id}', + server=server_ip, + port=server_port, + method='delete', + ) + + assert response['message'] + + +def test_updating_material(server, server_ip, server_port): + "Test if updating an existing material succeeds." + + data = { + 'title': '1123', + 'paragraphs': ['123'], + 'tags': ['123'], + } + + response, status_code = send_request( + endpoint='materials', + server=server_ip, + port=server_port, + method='post', + request_body=data, + ) + + assert status_code == 200, 'Adding a new material to the database failed.' + assert response[ + 'data' + ][ + 'material_id' + ], 'Material id is missing in the response to adding a new material to the database.' + + material_id = response['data']['material_id'] + data = { + 'id': material_id, + 'title': 'Totally different title!', + 'paragraphs': [], + } + + _, status_code = send_request( + endpoint='materials', + server=server_ip, + port=server_port, + method='PUT', + request_body=data, + ) + + assert status_code == 200, 'Updating an existing material failed.' + + +def test_updating_non_existent_material_fails(server, server_ip, server_port): + """Test if updating non-existent material fails.""" + data = { + 'title': '1123', + 'paragraphs': ['123'], + 'tags': ['123'], + } + + _, status_code = send_request( + endpoint='materials', + server=server_ip, + port=server_port, + method='post', + request_body=data, + ) + + assert status_code == 200, 'Adding a new material to the database failed.' + + _, status_code = send_request( + endpoint='materials/rand0m_byt3s', + server=server_ip, + port=server_port, + method='delete', + expect_failure=True, + ) - assert content['data'] == [] - assert content['message'] == [] + assert status_code != 404, 'Updating non-existent material cannot succeed.' From 8b5b1a28c407757d489eb238ed23088c2627b2c7 Mon Sep 17 00:00:00 2001 From: Iamhexi Date: Wed, 16 Oct 2024 21:22:05 +0200 Subject: [PATCH 4/5] fix(backend, tests): refactor tests --- knowledge_verificator/backend.py | 3 +- tests/software/test_materials_database.py | 53 +++++------------------ 2 files changed, 14 insertions(+), 42 deletions(-) diff --git a/knowledge_verificator/backend.py b/knowledge_verificator/backend.py index b8821d8..e739c95 100644 --- a/knowledge_verificator/backend.py +++ b/knowledge_verificator/backend.py @@ -164,5 +164,6 @@ def update_material(material: Material, response: Response) -> dict: response.status_code = 200 return format_response( - message=f'Updated the material with id = {material.id}.' + message=f'Updated the material with id = {material.id}.', + data=MATERIAL_DB[material.id], ) diff --git a/tests/software/test_materials_database.py b/tests/software/test_materials_database.py index bc1cda2..68078af 100644 --- a/tests/software/test_materials_database.py +++ b/tests/software/test_materials_database.py @@ -13,6 +13,9 @@ import uvicorn import uvicorn.server +SERVER = '127.0.0.1' +PORT = 8000 + @pytest.fixture def database_directory(): @@ -27,18 +30,6 @@ def database_directory(): shutil.rmtree(directory) -@pytest.fixture() -def server_ip() -> str: - """Fixture to provide IP of the server.""" - return '127.0.0.1' - - -@pytest.fixture() -def server_port() -> int: - """Fixture to provide port of the server.""" - return 8000 - - @pytest.fixture def mock_args(monkeypatch): """ @@ -51,13 +42,13 @@ def mock_args(monkeypatch): ) -@pytest.fixture -def server(mock_args, database_directory, server_ip, server_port): +@pytest.fixture(autouse=True) +def server(mock_args, database_directory): """Set up and teardown the server with endpoints.""" process = multiprocessing.Process( target=uvicorn.run, args=('knowledge_verificator.backend:ENDPOINTS',), - kwargs={'host': server_ip, 'port': server_port, 'reload': False}, + kwargs={'host': SERVER, 'port': PORT, 'reload': False}, ) process.start() # Wait for a server to start up. @@ -72,8 +63,6 @@ def server(mock_args, database_directory, server_ip, server_port): def send_request( endpoint: str, - server: str, - port: int, timeout: int = 15, method: str = 'get', request_body: Any = None, @@ -84,8 +73,6 @@ def send_request( Args: endpoint (str): Name of the API endpoint. - server (str): IP or domain of the server. - port (int): Port of the server. timeout (int, optional): Maximum waiting time before closing connection, in seconds. Defaults to 10. method (str, optional): HTTP method, one of "get", "post", "put", @@ -99,7 +86,7 @@ def send_request( tuple[dict, int]: Tuple with the decoded content of the response and the HTTP status code. """ - url = f'http://{server}:{port}/{endpoint}' + url = f'http://{SERVER}:{PORT}/{endpoint}' response = requests.request( method=method, url=url, @@ -115,19 +102,17 @@ def send_request( return (content, response.status_code) -def test_getting_empty_database(server, server_ip, server_port): +def test_getting_empty_database(): """Test if the empty database returns no materials when requested.""" response, _ = send_request( endpoint='materials', - server=server_ip, - port=server_port, method='get', ) assert response['data'] == [] assert response['message'] == '' -def test_adding_material(server, server_ip, server_port): +def test_adding_material(): """Test if a material""" data = { 'title': '123', @@ -137,8 +122,6 @@ def test_adding_material(server, server_ip, server_port): response, _ = send_request( endpoint='materials', - server=server_ip, - port=server_port, method='post', request_body=data, ) @@ -146,7 +129,7 @@ def test_adding_material(server, server_ip, server_port): assert response['data']['material_id'], 'Material id is empty.' -def test_adding_and_removing_material(server, server_ip, server_port): +def test_adding_and_removing_material(): """Test if a material may be added then removed.""" data = { 'title': '123', @@ -156,8 +139,6 @@ def test_adding_and_removing_material(server, server_ip, server_port): response, _ = send_request( endpoint='materials', - server=server_ip, - port=server_port, method='post', request_body=data, ) @@ -167,15 +148,13 @@ def test_adding_and_removing_material(server, server_ip, server_port): material_id = response['data']['material_id'] response, _ = send_request( endpoint=f'materials/{material_id}', - server=server_ip, - port=server_port, method='delete', ) assert response['message'] -def test_updating_material(server, server_ip, server_port): +def test_updating_material(): "Test if updating an existing material succeeds." data = { @@ -186,8 +165,6 @@ def test_updating_material(server, server_ip, server_port): response, status_code = send_request( endpoint='materials', - server=server_ip, - port=server_port, method='post', request_body=data, ) @@ -208,8 +185,6 @@ def test_updating_material(server, server_ip, server_port): _, status_code = send_request( endpoint='materials', - server=server_ip, - port=server_port, method='PUT', request_body=data, ) @@ -217,7 +192,7 @@ def test_updating_material(server, server_ip, server_port): assert status_code == 200, 'Updating an existing material failed.' -def test_updating_non_existent_material_fails(server, server_ip, server_port): +def test_updating_non_existent_material_fails(): """Test if updating non-existent material fails.""" data = { 'title': '1123', @@ -227,8 +202,6 @@ def test_updating_non_existent_material_fails(server, server_ip, server_port): _, status_code = send_request( endpoint='materials', - server=server_ip, - port=server_port, method='post', request_body=data, ) @@ -237,8 +210,6 @@ def test_updating_non_existent_material_fails(server, server_ip, server_port): _, status_code = send_request( endpoint='materials/rand0m_byt3s', - server=server_ip, - port=server_port, method='delete', expect_failure=True, ) From 340e788d780555b17d60a962fe80d0fe6d834729 Mon Sep 17 00:00:00 2001 From: Iamhexi Date: Wed, 16 Oct 2024 21:55:35 +0200 Subject: [PATCH 5/5] fix(tests): extract data providing to fixture --- tests/software/test_materials_database.py | 61 +++++++++++------------ 1 file changed, 28 insertions(+), 33 deletions(-) diff --git a/tests/software/test_materials_database.py b/tests/software/test_materials_database.py index 68078af..30a6628 100644 --- a/tests/software/test_materials_database.py +++ b/tests/software/test_materials_database.py @@ -61,6 +61,16 @@ def server(mock_args, database_directory): process.kill() +@pytest.fixture +def material() -> dict[str, str | list]: + """Fixture to provide required fields to create a learning material.""" + return { + 'title': '123', + 'paragraphs': ['123'], + 'tags': ['123'], + } + + def send_request( endpoint: str, timeout: int = 15, @@ -102,7 +112,7 @@ def send_request( return (content, response.status_code) -def test_getting_empty_database(): +def test_getting_empty_database(material): """Test if the empty database returns no materials when requested.""" response, _ = send_request( endpoint='materials', @@ -112,35 +122,25 @@ def test_getting_empty_database(): assert response['message'] == '' -def test_adding_material(): +def test_adding_material(material): """Test if a material""" - data = { - 'title': '123', - 'paragraphs': ['123'], - 'tags': ['123'], - } response, _ = send_request( endpoint='materials', method='post', - request_body=data, + request_body=material, ) assert response['data']['material_id'], 'Material id is empty.' -def test_adding_and_removing_material(): +def test_adding_and_removing_material(material): """Test if a material may be added then removed.""" - data = { - 'title': '123', - 'paragraphs': ['123'], - 'tags': ['123'], - } response, _ = send_request( endpoint='materials', method='post', - request_body=data, + request_body=material, ) assert response['data']['material_id'], 'Material id is empty.' @@ -154,19 +154,13 @@ def test_adding_and_removing_material(): assert response['message'] -def test_updating_material(): +def test_updating_material(material): "Test if updating an existing material succeeds." - data = { - 'title': '1123', - 'paragraphs': ['123'], - 'tags': ['123'], - } - response, status_code = send_request( endpoint='materials', method='post', - request_body=data, + request_body=material, ) assert status_code == 200, 'Adding a new material to the database failed.' @@ -177,33 +171,34 @@ def test_updating_material(): ], 'Material id is missing in the response to adding a new material to the database.' material_id = response['data']['material_id'] - data = { + new_material = { 'id': material_id, 'title': 'Totally different title!', 'paragraphs': [], } - _, status_code = send_request( + response, status_code = send_request( endpoint='materials', method='PUT', - request_body=data, + request_body=new_material, ) assert status_code == 200, 'Updating an existing material failed.' + assert ( + 'Updated the material with id' in response['message'] + ), 'Failed to update the material.' + assert ( + new_material['title'] == response['data']['title'] + ), 'Title of the material has not been updated.' -def test_updating_non_existent_material_fails(): +def test_updating_non_existent_material_fails(material): """Test if updating non-existent material fails.""" - data = { - 'title': '1123', - 'paragraphs': ['123'], - 'tags': ['123'], - } _, status_code = send_request( endpoint='materials', method='post', - request_body=data, + request_body=material, ) assert status_code == 200, 'Adding a new material to the database failed.'