diff --git a/README.md b/README.md index 023b0c43..d019d194 100644 --- a/README.md +++ b/README.md @@ -321,9 +321,24 @@ def hello_name(request): name = request.GET.get("name", "World") return JsonResponse({"message": f"Hello, {name}!"}) + + +def hello_name_age(request): + """ + A simple view that returns 'Hello, {name}! You are {age} years old.' in JSON format. + Uses query parameters named 'name' and 'age'. + """ + name = request.GET.get("name", "World") + age = request.GET.get("age", "unknown") # Default to 'unknown' if age is not provided + + return JsonResponse({"message": f"Hello, {name}! You are {age} years old."}) + +# test this using -> hello_name_age/?name=John&age=25 + urlpatterns = [ path('admin/', admin.site.urls), path('hello/', hello_name), + path('hello_name_age/', hello_name_age), # Example usage: /hello/?name=Bob # returns {"message": "Hello, Bob!"} ] @@ -364,6 +379,9 @@ Enter the endpoint, for example: ``` http://127.0.0.1:8001/hello/?name=Bob ``` +### http://127.0.0.1:8000/hello_name/?name=John%20Doe!@# +# This shows that the API can handle special characters as well. + Send the request. You should see a JSON response: ``` @@ -474,7 +492,7 @@ A quick demonstration of hot reloading in action after making changes can be fou --- - - - - +![GitHub Repo stars](https://img.shields.io/github/stars/VedanshiAwasthi/interneers-lab?style=social) +![GitHub forks](https://img.shields.io/github/forks/VedanshiAwasthi/interneers-lab?style=social) +![GitHub last commit](https://img.shields.io/github/last-commit/VedanshiAwasthi/interneers-lab) +![GitHub license](https://img.shields.io/github/license/VedanshiAwasthi/interneers-lab) \ No newline at end of file diff --git a/backend/django_app/settings.py b/backend/django_app/settings.py index 98c87ddf..181d6e9d 100644 --- a/backend/django_app/settings.py +++ b/backend/django_app/settings.py @@ -9,6 +9,7 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/5.1/ref/settings/ """ +import mongoengine from pathlib import Path @@ -25,7 +26,12 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = ["*"] + +REST_FRAMEWORK = { + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 10 +} # Application definition @@ -37,6 +43,8 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + 'rest_framework', + # "products", ] MIDDLEWARE = [ @@ -74,13 +82,29 @@ # https://docs.djangoproject.com/en/5.1/ref/settings/#databases DATABASES = { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": BASE_DIR / "db.sqlite3", + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / "db.sqlite3", } } + +# mongoengine.connect( +# db="ECommerceDB", +# host="mongodb://localhost:27017/", +# ) + +#ADDED AUTHENTICATION + +mongoengine.connect( + db="ECommerceDB", + host="mongodb://root:example@localhost:27017/ECommerceDB?authSource=admin" , + uuidRepresentation="standard" +) + + + # Password validation # https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators @@ -116,8 +140,18 @@ # https://docs.djangoproject.com/en/5.1/howto/static-files/ STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / 'static' # Default primary key field type # https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + +class DisableMigrations: + def __contains__(self, item): + return True + + def __getitem__(self, item): + return None + +MIGRATION_MODULES = DisableMigrations() diff --git a/backend/django_app/urls.py b/backend/django_app/urls.py index 0418448b..7d9f5d60 100644 --- a/backend/django_app/urls.py +++ b/backend/django_app/urls.py @@ -1,11 +1,39 @@ from django.contrib import admin -from django.urls import path +from django.urls import path, include from django.http import HttpResponse +from django.http import JsonResponse + +# def hello_world(request): +# return HttpResponse("Hello, world! This is our interneers-lab Django server.") + +# urlpatterns = [ +# path('admin/', admin.site.urls), +# path('hello/', hello_world), +# ] + + +def home(request): + return HttpResponse("Hi") + + +def hello(request): + return HttpResponse("Hello, world! This is our interneers-lab Django server, First change made, function name changed from i.e hello_world to hello") + +def hello_name(request): + """ + A simple view that returns 'Hello, {name}' in JSON format. + Uses a query parameter named 'name'. + """ + # Get 'name' from the query string, default to 'World' if missing + name = request.GET.get("name", "World") + return JsonResponse({"message": f"Hello, {name}!"}) -def hello_world(request): - return HttpResponse("Hello, world! This is our interneers-lab Django server.") urlpatterns = [ path('admin/', admin.site.urls), - path('hello/', hello_world), + path('hello/', hello), + path('', home), + path('hello_name/', hello_name), #test this using -> hello_name/?name=Vedanshi + #tested with few more apis like http://127.0.0.1:8000/hello_name/?name=John%20Doe!@# , etc + path('api/', include('products.urls')), ] diff --git a/backend/docker-compose.yaml b/backend/docker-compose.yaml index 2620a26b..d4c07a04 100644 --- a/backend/docker-compose.yaml +++ b/backend/docker-compose.yaml @@ -3,10 +3,11 @@ services: image: mongo:latest container_name: interneers_lab_mongodb ports: - - '27018:27017' + - '27017:27017' environment: MONGO_INITDB_ROOT_USERNAME: root MONGO_INITDB_ROOT_PASSWORD: example + command: ["mongod", "--auth"] volumes: - mongodb_data:/data/db diff --git a/backend/products/__init__.py b/backend/products/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/products/admin.py b/backend/products/admin.py new file mode 100644 index 00000000..7041a270 --- /dev/null +++ b/backend/products/admin.py @@ -0,0 +1,4 @@ +from django.contrib import admin +from .models import Product + +# admin.site.register(Product) \ No newline at end of file diff --git a/backend/products/apps.py b/backend/products/apps.py new file mode 100644 index 00000000..864c43ed --- /dev/null +++ b/backend/products/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ProductsConfig(AppConfig): + name = 'products' diff --git a/backend/products/errorHandler.py b/backend/products/errorHandler.py new file mode 100644 index 00000000..d4ba3024 --- /dev/null +++ b/backend/products/errorHandler.py @@ -0,0 +1,18 @@ +from rest_framework.response import Response +from rest_framework.exceptions import APIException, NotFound +from rest_framework import status + +class ProductNotFound(NotFound): + default_detail = {"error": "Product not found"} + default_code = "not_found" + +def handle_exception(exception): + print("Exception Caught:", type(exception), exception) + + if isinstance(exception, ProductNotFound): + return Response(exception.default_detail, status=status.HTTP_404_NOT_FOUND) + + if isinstance(exception, APIException): + return Response({"error": str(exception)}, status=exception.status_code) + + return Response({"error": "An unexpected error occurred"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/backend/products/models/CategoryModel.py b/backend/products/models/CategoryModel.py new file mode 100644 index 00000000..db9323e7 --- /dev/null +++ b/backend/products/models/CategoryModel.py @@ -0,0 +1,19 @@ +import mongoengine +import datetime + +class ProductCategory(mongoengine.Document): + title = mongoengine.StringField(max_length=100, required=True, unique=True) + description = mongoengine.StringField(required=False) + created_at = mongoengine.DateTimeField(default=lambda: datetime.datetime.now(datetime.UTC)) + updated_at = mongoengine.DateTimeField(default=lambda: datetime.datetime.now(datetime.UTC)) + + + meta = {'collection': 'ProductCategory' , 'db_alias': 'default'} + + def save(self, *args, **kwargs): + self.updated_at = datetime.datetime.now(datetime.UTC) + return super(ProductCategory, self).save(*args, **kwargs) + + def __str__(self): + return self.title + diff --git a/backend/products/models/ProductModel.py b/backend/products/models/ProductModel.py new file mode 100644 index 00000000..3f8cbace --- /dev/null +++ b/backend/products/models/ProductModel.py @@ -0,0 +1,28 @@ +import mongoengine +import datetime +from .CategoryModel import ProductCategory + +class Product(mongoengine.Document): + name = mongoengine.StringField(max_length=255, required=True) + description = mongoengine.StringField(required=False) + brand = mongoengine.StringField(max_length=100, required=True) + category = mongoengine.ListField( + mongoengine.ReferenceField(ProductCategory, reverse_delete_rule=mongoengine.PULL) + ) #Use PULL when you want to remove references to a deleted document without deleting dependent documents. + price = mongoengine.DecimalField(precision=2, required=True) + quantity = mongoengine.IntField(default=0, min_value=0) + created_at = mongoengine.DateTimeField(default=lambda: datetime.datetime.now(datetime.UTC)) + updated_at = mongoengine.DateTimeField(default=lambda: datetime.datetime.now(datetime.UTC)) + + meta = {'collection': 'Product' , 'db_alias': 'default'} + + def save(self, *args, **kwargs): + self.updated_at = datetime.datetime.now(datetime.UTC) + return super(Product, self).save(*args, **kwargs) + + def __str__(self): + return self.name + + + + diff --git a/backend/products/repositories/Category.py b/backend/products/repositories/Category.py new file mode 100644 index 00000000..91eb742d --- /dev/null +++ b/backend/products/repositories/Category.py @@ -0,0 +1,57 @@ +from ..models.CategoryModel import ProductCategory +from ..models.ProductModel import Product + +class CategoryRepository: + @staticmethod + def create(title, description=None): + if ProductCategory.objects.filter(title=title).first(): + raise ValueError("Category with this title already exists.") + category = ProductCategory.objects.create(title=title, description=description) + return category + + @staticmethod + def get_category_by_title(title): + return ProductCategory.objects.filter(title__iexact=title).first() + + + @staticmethod + def getCategoryById(category_id): + try: + return ProductCategory.objects.get(id = category_id) + + except ProductCategory.DoesNotExist: + return None + + + @staticmethod + def get_products_by_category(category): + return Product.objects.filter(category=category) + + @staticmethod + def get_all(): + return ProductCategory.objects.all() + + @staticmethod + def update(title, new_title=None, new_description=None): + category = ProductCategory.objects.filter(title=title).first() + if not category: + raise ValueError("Category not found.") + + if new_title and ProductCategory.objects.filter(title=new_title).exists(): + raise ValueError("Category with this new title already exists.") + if new_title: + category.title = new_title + + if new_description is not None: + category.description = new_description + + category.save() + return category + + @staticmethod + def delete(title): + category = ProductCategory.objects.filter(title=title).first() + if not category: + raise ValueError("Category not found.") + category.delete() + return True diff --git a/backend/products/repositories/ProductRepository.py b/backend/products/repositories/ProductRepository.py new file mode 100644 index 00000000..a79b9e9e --- /dev/null +++ b/backend/products/repositories/ProductRepository.py @@ -0,0 +1,66 @@ +from ..models.ProductModel import Product +from bson import ObjectId +from bson.errors import InvalidId + +class ProductRepository: + + @staticmethod + def createProd(prod_details): + prod_details["price"] = float(prod_details["price"]) + prod = Product(**prod_details) + prod.save() + return prod + + + @staticmethod + def getAllProd(): + return list(Product.objects.all()) + + + @staticmethod + def getProdById(prod_id): + try: + obj_id = ObjectId(prod_id) + return Product.objects.get(id=obj_id) + + except InvalidId: + raise ValueError("Invalid ID format. Must be a 12-byte input or 24-character hex string.") + + except Product.DoesNotExist: + return None + + + @staticmethod + def updateProd(prod_id , prod_details): + + prod = ProductRepository.getProdById(prod_id) + + if prod: + for key,value in prod_details.items(): + setattr(prod,key,value) + prod.save() + return prod + + return None + + + @staticmethod + def deleteProd(prod_id): + print(f"Looking for product with ID: {prod_id}") + prod = ProductRepository.getProdById(prod_id) + print(f"Product fetched: {prod}") + + if prod: + prod.delete() + return True + + return None + + + + @staticmethod + def update_product_categories(product, updated_categories): + product.category = updated_categories + product.save() + + \ No newline at end of file diff --git a/backend/products/repositories/__init__.py b/backend/products/repositories/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/products/serializers.py b/backend/products/serializers.py new file mode 100644 index 00000000..2412f262 --- /dev/null +++ b/backend/products/serializers.py @@ -0,0 +1,98 @@ +from rest_framework import serializers +from .models.ProductModel import Product +from .models.CategoryModel import ProductCategory +from bson import ObjectId + +class ProductCategorySerializer(serializers.Serializer): + id = serializers.CharField(read_only=True) + title = serializers.CharField(max_length=255) + description = serializers.CharField(allow_blank=True, required=False) + + def create(self, validated_data): + return ProductCategory(**validated_data).save() + + def update(self, instance, validated_data): + for key, value in validated_data.items(): + setattr(instance, key, value) + instance.save() + return instance + + +class ProductSerializer(serializers.Serializer): + id = serializers.CharField(read_only=True) + name = serializers.CharField() + description = serializers.CharField(required=False, allow_blank=True) + brand = serializers.CharField(required=False, allow_blank=True) + category = serializers.ListField(child=serializers.CharField()) + price = serializers.FloatField() + quantity = serializers.IntegerField() + + def validate_name(self, value): + if not value.strip(): + raise serializers.ValidationError("Product name cannot be empty.") + return value + + def validate_price(self, value): + if value <= 0: + raise serializers.ValidationError("Price must be greater than zero.") + return value + + def validate_quantity(self, value): + if value < 0: + raise serializers.ValidationError("Quantity cannot be negative.") + return value + + def validate_category(self, value): + + if not isinstance(value, list): + raise serializers.ValidationError("Category must be a list of valid category IDs.") + + categories = ProductCategory.objects(title__in=value) + if len(categories) != len(value): + raise serializers.ValidationError("Some categories are invalid.") + + return categories + + def create(self, validated_data): + + categories = validated_data.pop("category", []) + product = Product(category=categories, **validated_data) + product.save() + return product + + def update(self, instance, validated_data): + + for key, value in validated_data.items(): + if key == "category": + if not isinstance(value, list): # Ensure value is a list + raise serializers.ValidationError("Category must be a list of ObjectIds.") + + try: + # Convert category IDs to ObjectId format + category_ids = [ObjectId(cid) for cid in value] + except Exception: + raise serializers.ValidationError("Invalid category ID format.") + + categories = ProductCategory.objects(id__in=category_ids) + if len(categories) != len(category_ids): + raise serializers.ValidationError("Some categories are invalid.") + + instance.category = list(categories) + else: + setattr(instance, key, value) + + instance.save() + return instance + + def to_representation(self, instance): + + data = { + "id": str(instance.id), + "name": instance.name, + "description": instance.description, + "brand": instance.brand, + "category": [cat.title for cat in instance.category] if instance.category else [], + "price": float(instance.price), + "quantity": int(instance.quantity), + } + return data diff --git a/backend/products/services/CategoryService.py b/backend/products/services/CategoryService.py new file mode 100644 index 00000000..0aa061b0 --- /dev/null +++ b/backend/products/services/CategoryService.py @@ -0,0 +1,38 @@ +from ..repositories.Category import CategoryRepository +from django.http import Http404 + +class CategoryService: + @staticmethod + def create_category(title, description=None): + return CategoryRepository.create(title, description) + + @staticmethod + def get_products_by_category_title(title): + category = CategoryRepository.get_category_by_title(title) + if not category: + return None, "Category not found" + + products = CategoryRepository.get_products_by_category(category) + return products, None + + + def getCategoryById(category_id): + + data = CategoryRepository.getCategoryById(category_id) + + if not data: + raise Http404(" Category not found") + + return data + + @staticmethod + def get_all_categories(): + return CategoryRepository.get_all() + + @staticmethod + def update_category(title, new_title=None, new_description=None): + return CategoryRepository.update(title, new_title, new_description) + + @staticmethod + def delete_category(title): + return CategoryRepository.delete(title) diff --git a/backend/products/services/ProductService.py b/backend/products/services/ProductService.py new file mode 100644 index 00000000..80452009 --- /dev/null +++ b/backend/products/services/ProductService.py @@ -0,0 +1,111 @@ +from ..repositories.ProductRepository import ProductRepository +from ..repositories.Category import CategoryRepository +from ..serializers import ProductSerializer +from django.http import Http404 +from bson import ObjectId +from rest_framework.exceptions import ValidationError + + +class ProductService: + + @staticmethod + def createProd(prod_details): + + ser = ProductSerializer(data = prod_details) + + if ser.is_valid(): + prod = ProductRepository.createProd(prod_details) + return { "success": True , "data" : ProductSerializer(prod).data} + + return {"success" : False , "data" : ser.errors} + + + @staticmethod + def getAllProds(): + data = ProductRepository.getAllProd() + return data + + + @staticmethod + def getProdById(prod_id): + try: + return ProductRepository.getProdById(prod_id) + except ValueError as e: + raise ValidationError({"error": str(e)}) + + + @staticmethod + def updateProd(prod_id, prod_details): + prod = ProductRepository.getProdById(prod_id) + + if not prod: + return None + + ser = ProductSerializer(prod, data=prod_details, partial=True) + + if ser.is_valid(): + ser.save() + return prod + + raise Exception(ser.errors) + + + + + @staticmethod + def deleteProd(prod_id): + + TrueOrFalse = ProductRepository.deleteProd(prod_id) + + if TrueOrFalse: + return {"success" : True , "message": "Product deleted successfully"} + + raise Http404("Product not found") + + @staticmethod + def remove_category_from_product(product_id, category_id): + product = ProductRepository.getProdById(product_id) + category = CategoryRepository.getCategoryById(category_id) + + if not product: + raise Http404("Product not found") + if not category: + raise Http404("Category not found") + + product_category_ids = [ObjectId(cat.id) for cat in product.category] + + for cat in product.category: + print(cat.id, ObjectId(cat.id) ) + + if ObjectId(category_id) not in product_category_ids: + return {"message": "Category not assigned to product.", "status": 400} + + updated_categories = [cat for cat in product.category if ObjectId(cat.id) != ObjectId(category_id)] + ProductRepository.update_product_categories(product, updated_categories) + + return {"message": "Category removed from product successfully.", "status": 200} + + @staticmethod + def add_category_to_product(product_id, category_id): + + product = ProductRepository.getProdById(product_id) + category = CategoryRepository.getCategoryById(category_id) + + if not product: + raise Http404("Product not found.") + if not category: + raise Http404("Category not found.") + + + product_category_ids = [ObjectId(cat.id) for cat in product.category] + + if category_id in product_category_ids: + return {"message": "Category already assigned to product.", "status": 400} + + + product.category.append(category) + product.save() + + return {"message": "Category added to product successfully.", "status": 200} + + \ No newline at end of file diff --git a/backend/products/services/__init__.py b/backend/products/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/products/tests.py b/backend/products/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/backend/products/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/products/tests/conftest.py b/backend/products/tests/conftest.py new file mode 100644 index 00000000..ae530913 --- /dev/null +++ b/backend/products/tests/conftest.py @@ -0,0 +1,59 @@ +import pytest +import mongoengine +from mongoengine import connect, disconnect +from products.models.ProductModel import ProductCategory, Product +from pymongo.mongo_client import MongoClient +from pymongo.errors import ServerSelectionTimeoutError +from django.conf import settings +from rest_framework.test import APIClient +from seed_data import seed_database + +# Check if MongoDB is available, otherwise use mongomock +def is_mongodb_available(): + try: + client = MongoClient(host='localhost', port=27017, serverSelectionTimeoutMS=1000) + client.admin.command('ismaster') + return True + except ServerSelectionTimeoutError: + return False + +@pytest.fixture(scope="session") +def mongodb_use_local(): + + return is_mongodb_available() + +@pytest.fixture(scope="function") +def db(mongodb_use_local): + + mongoengine.disconnect_all() + + if mongodb_use_local: + + mongoengine.connect( + db='test_products_db', + host='localhost', + port=27017, + alias='default' + ) + print("Using local MongoDB for tests") + else: + + import mongomock + mongoengine.connect( + db='test_products_db', + host='mongomock://localhost', + alias='default', + uuidRepresentation='standard' + ) + print("Using mongomock for tests") + + + seed_data = seed_database() + + yield seed_data + + mongoengine.disconnect_all() + +@pytest.fixture +def api_client(): + return APIClient() \ No newline at end of file diff --git a/backend/products/tests/integration/test_integration_category.py b/backend/products/tests/integration/test_integration_category.py new file mode 100644 index 00000000..0282c939 --- /dev/null +++ b/backend/products/tests/integration/test_integration_category.py @@ -0,0 +1,297 @@ +import pytest +import json +import mongoengine +from decimal import Decimal +from bson import ObjectId +from rest_framework import status +from django.urls import reverse + +@pytest.mark.django_db(transaction=True) +class TestCategoryAPI: + + # GET ALL CATEGORIES + @pytest.mark.django_db + def test_get_all_categories_success(self, api_client, db): + + response = api_client.get('/api/categories/all') + assert response.status_code == status.HTTP_200_OK + + if 'results' in response.data: + # Paginated response + categories = response.data['results'] + assert len(categories) > 0 + assert response.data['count'] == 3 + assert 'next' in response.data + assert 'previous' in response.data + + # Test category structure + first_category = categories[0] + assert 'id' in first_category + assert 'title' in first_category + assert 'description' in first_category + assert first_category['title'] == 'Electronics' + else: + # Non-paginated response + categories = response.data + assert len(categories) == 3 + + + # GET CATEGORY BY ID + @pytest.mark.django_db + def test_get_category_by_id_success(self, api_client, db): + + category_id = str(db['categories']['electronics'].id) + response = api_client.get(f'/api/categories/id/{category_id}/') + + assert response.status_code == status.HTTP_200_OK + assert response.data['title'] == 'Electronics' + assert response.data['description'] == 'Electronic devices and accessories' + assert '_id' in response.data or 'id' in response.data + + @pytest.mark.django_db + def test_get_category_by_id_invalid_format(self, api_client, db): + + invalid_id = "invalid_format" + response = api_client.get(f'/api/categories/id/{invalid_id}/') + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert 'error' in response.data + assert 'Invalid ID format' in str(response.data['error']) + + @pytest.mark.django_db + def test_get_category_by_id_not_found(self, api_client, db): + + non_existent_id = str(ObjectId()) + response = api_client.get(f'/api/categories/id/{non_existent_id}/') + assert response.status_code == status.HTTP_404_NOT_FOUND + assert 'detail' in response.data + assert response.data['detail'] == "Category not found" + + # GET CATEGORY BY TITLE + @pytest.mark.django_db + def test_get_category_by_title_success(self, api_client, db): + + response = api_client.get('/api/categories/title/Electronics/') + + print("RESPONSE DATA:", response.data) + + assert response.status_code == status.HTTP_200_OK + assert isinstance(response.data, list) + assert len(response.data) > 0 + for product in response.data: + assert 'category' in product + assert 'Electronics' in product['category'] + + @pytest.mark.django_db + def test_get_category_by_title_case_insensitive(self, api_client, db): + + response = api_client.get('/api/categories/title/electronics/') + assert response.status_code == status.HTTP_200_OK + assert isinstance(response.data, list) + assert len(response.data) > 0 + for product in response.data: + assert 'category' in product + assert 'Electronics' in product['category'] + + + @pytest.mark.django_db + def test_get_category_by_title_not_found(self, api_client, db): + + response = api_client.get('/api/categories/title/NonExistentCategory/') + assert response.status_code == status.HTTP_404_NOT_FOUND + assert 'error' in response.data + + @pytest.mark.django_db + def test_get_category_by_title_with_special_chars(self, api_client, db): + + response = api_client.get('/api/categories/title/Special@Category!/') + assert response.status_code == status.HTTP_404_NOT_FOUND + + # CREATE CATEGORY + @pytest.mark.django_db + def test_create_category_success(self, api_client, db): + + new_category = { + 'title': 'Books', + 'description': 'Fiction and non-fiction books' + } + + response = api_client.post('/api/categories/', new_category, format='json') + + assert response.status_code == status.HTTP_201_CREATED + assert response.data['category']['title'] == 'Books' + assert 'message' in response.data + assert response.data['message'] == 'Category created successfully' + + # Verify the category was added + response = api_client.get('/api/categories/title/Books/') + assert response.status_code == status.HTTP_200_OK + + @pytest.mark.django_db + def test_create_category_duplicate_title(self, api_client, db): + + new_category = { + 'title': 'Unique', + 'description': 'Original category' + } + + # Create first category + api_client.post('/api/categories/', new_category, format='json') + + # Try to create duplicate + response = api_client.post('/api/categories/', new_category, format='json') + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert 'error' in response.data + + @pytest.mark.django_db + def test_create_category_missing_title(self, api_client, db): + + invalid_category = { + 'description': 'Missing title field' + } + response = api_client.post('/api/categories/', invalid_category, format='json') + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert 'title' in response.data + assert response.data['title'][0] == 'This field is required.' + + @pytest.mark.django_db + def test_create_category_empty_title(self, api_client, db): + + invalid_category = { + 'title': '', + 'description': 'Empty title' + } + response = api_client.post('/api/categories/', invalid_category, format='json') + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data['title'][0] == 'This field may not be blank.' + + + + # UPDATE CATEGORY + @pytest.mark.django_db + def test_update_category_success(self, api_client, db): + + new_category = { + 'title': 'TestUpdate', + 'description': 'Will be updated' + } + + create_response = api_client.post('/api/categories/', new_category, format='json') + assert create_response.status_code == status.HTTP_201_CREATED + + updated_data = { + 'title': 'TestUpdate', + 'description': 'Updated description' + } + + + response = api_client.put('/api/categories/TestUpdate/update', updated_data, format='json') + assert response.status_code == status.HTTP_200_OK + assert response.data['message'] == 'Category updated successfully' + assert response.data['category']['description'] == 'Updated description' + + # # Confirm it's updated + # response = api_client.get('/api/categories/title/TestUpdate/') + # assert response.status_code == status.HTTP_200_OK + # for product in response.data: + # assert 'Updated description' in product['description'] + + @pytest.mark.django_db + def test_update_category_not_found(self, api_client, db): + """Test updating a non-existent category fails""" + updated_data = { + 'title': 'NonExistent', + 'description': 'Updated description' + } + + response = api_client.put('/api/categories/title/NonExistent/', updated_data, format='json') + assert response.status_code == status.HTTP_404_NOT_FOUND + assert 'error' in response.data + + @pytest.mark.django_db + def test_update_category_missing_fields(self, api_client, db): + + # First create a test category + new_category = { + 'title': 'TestPartial', + 'description': 'Will test partial update' + } + + api_client.post('/api/categories/', new_category, format='json') + + # Test partial update (missing fields) + partial_data = {'description': 'Partial update'} + response = api_client.put('/api/categories/title/TestPartial/', partial_data, format='json') + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert 'title' in response.data + assert response.data['title'][0] == 'This field is required.' + + # DELETE CATEGORY + @pytest.mark.django_db + def test_delete_category_success(self, api_client, db): + + # First create a temporary category + new_category = { + 'title': 'Temporary', + 'description': 'Will be deleted' + } + + create_response = api_client.post('/api/categories/', new_category, format='json') + assert create_response.status_code == status.HTTP_201_CREATED + + # Now delete it + response = api_client.delete('/api/categories/title/Temporary/') + assert response.status_code == status.HTTP_200_OK + assert response.data['message'] == 'Category deleted successfully' + + # Verify it's gone + response = api_client.get('/api/categories/title/Temporary/') + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.django_db + def test_delete_category_not_found(self, api_client, db): + + response = api_client.delete('/api/categories/title/NonExistent/') + assert response.status_code == status.HTTP_404_NOT_FOUND + assert 'error' in response.data + + @pytest.mark.django_db + def test_delete_category_with_products(self, api_client, db): + + # Test deleting category with products (assuming this should be prevented) + response = api_client.delete('/api/categories/title/Electronics/') + if response.status_code == status.HTTP_400_BAD_REQUEST: + # Expected response if deletion is prevented due to existing products + assert 'error' in response.data + assert 'cannot delete' in response.data['error'].lower() or 'has products' in response.data['error'].lower() + + # GET PRODUCTS BY CATEGORY + @pytest.mark.django_db + def test_get_products_by_category_success(self, api_client, db): + + response = api_client.get('/api/categories/title/Electronics/') + + assert response.status_code == status.HTTP_200_OK + products = response.data + + # Should return 3 electronics products + assert len(products) == 3 + product_names = [product['name'] for product in products] + assert 'Smartphone X' in product_names + assert 'Laptop Pro' in product_names + assert 'Smart Watch' in product_names + + # Test each product has required fields + for product in products: + assert 'id' in product or '_id' in product + assert 'name' in product + assert 'price' in product + assert 'description' in product + + + @pytest.mark.django_db + def test_get_products_by_category_not_found(self, api_client, db): + + response = api_client.get('/api/categories/title/NonExistent/') + assert response.status_code == status.HTTP_404_NOT_FOUND + assert 'error' in response.data + diff --git a/backend/products/tests/integration/test_integration_product.py b/backend/products/tests/integration/test_integration_product.py new file mode 100644 index 00000000..ac5aad41 --- /dev/null +++ b/backend/products/tests/integration/test_integration_product.py @@ -0,0 +1,355 @@ +import pytest +import json +import mongoengine +from decimal import Decimal +from bson import ObjectId +from rest_framework import status +from copy import deepcopy + +@pytest.mark.django_db(transaction=True) +class TestProductAPI: + + def test_get_all_products(self, api_client, db): + + # Test default pagination + response = api_client.get('/api/products/') + assert response.status_code == status.HTTP_200_OK + print(response) + if 'results' in response.data: + products = response.data['results'] + assert len(products) <= 10 + assert response.data['count'] == 5 + + # Test page navigation + response = api_client.get('/api/products/?page=2') + assert response.status_code == status.HTTP_200_OK + + # Test custom page size + response = api_client.get('/api/products/?page_size=2') + assert response.status_code == status.HTTP_200_OK + assert len(response.data['results']) <= 2 + else: + # No pagination + products = response.data + assert len(products) == 5 + + + def test_get_product_by_id_success(self, api_client, db): + + product_id = str(db['products']['smartphone'].id) + response = api_client.get(f'/api/products/{product_id}/') + + assert response.status_code == status.HTTP_200_OK + assert response.data['name'] == 'Smartphone X' + assert float(response.data['price']) == 799.99 + assert response.data['quantity'] == 50 + assert response.data['brand'] == 'TechBrand' + + + def test_get_product_by_id_not_found(self, api_client, db): #Just by adding db to the function parameters, the fixture will be executed and the connection will be properly set. + + fake_id = str(ObjectId()) + response = api_client.get(f'/api/products/{fake_id}/') + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert 'detail' in response.data + assert response.data['detail'] == 'Product not found' + + + def test_get_product_by_id_invalid_id(self, api_client, db): + + invalid_id = "invalid_format" + response = api_client.get(f'/api/products/{invalid_id}/') + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert 'error' in response.data + assert 'Invalid ID format' in str(response.data['error']) + + def test_get_category_by_id_invalid_format(self, api_client, db): + + invalid_id = "invalid_format" + response = api_client.get(f'/api/categories/id/{invalid_id}/') + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert 'error' in response.data + assert 'Invalid ID format' in str(response.data['error']) + + + def test_create_product_success(self, api_client, db): + + new_product = { + 'name': 'Wireless Earbuds', + 'description': 'High-quality wireless earbuds with noise cancellation', + 'brand': 'SoundMaster', + 'category': ['Electronics'], + 'price': 129.99, + 'quantity': 75 + } + + response = api_client.post('/api/products/create/', new_product, format='json') + + assert response.status_code == status.HTTP_201_CREATED + assert response.data['data']['name'] == 'Wireless Earbuds' + assert float(response.data['data']['price']) == 129.99 + assert 'Electronics' in response.data['data']['category'] + + # Verify product was actually created in the database + product_id = response.data['data']['id'] + get_response = api_client.get(f'/api/products/{product_id}/') + assert get_response.status_code == status.HTTP_200_OK + + def test_create_product_missing_required_fields(self, api_client): + + incomplete_product = { + 'name': 'Incomplete Product', + # Missing price, quantity + } + + response = api_client.post('/api/products/create/', incomplete_product, format='json') + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert 'error' in response.data + assert 'price' in response.data['error'] + assert 'quantity' in response.data['error'] + + def test_create_product_invalid_data(self, api_client, db): + + invalid_product = { + 'name': 'Invalid Product', + 'description': 'Testing validation', + 'brand': 'TestBrand', + 'category': ['Electronics'], + 'price': 'not-a-number', # Invalid price + 'quantity': -10 # Invalid quantity + } + + response = api_client.post('/api/products/create/', invalid_product, format='json') + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert 'error' in response.data + + def test_create_product_with_nonexistent_category(self, api_client , db): + + product_with_fake_category = { + 'name': 'Test Product', + 'description': 'Testing category validation', + 'brand': 'TestBrand', + 'category': ['FakeCategory'], + 'price': 99.99, + 'quantity': 50 + } + + response = api_client.post('/api/products/create/', product_with_fake_category, format='json') + + # Check if API rejects non-existent categories or creates them on-the-fly + # This assertion depends on your API design + assert response.status_code in [status.HTTP_400_BAD_REQUEST, status.HTTP_201_CREATED] + if response.status_code == status.HTTP_400_BAD_REQUEST: + assert 'category' in response.data['error'] + + def test_update_product_success(self, api_client, db): + + product_id = str(db['products']['tshirt'].id) + original_data = api_client.get(f'/api/products/{product_id}/').data + + updated_data = { + 'name': 'Premium Cotton T-Shirt', + 'price': 24.99, + 'quantity': 180 + } + + response = api_client.put(f'/api/products/{product_id}/update/', updated_data, format='json') + + assert response.status_code == status.HTTP_200_OK + assert response.data['data']['name'] == 'Premium Cotton T-Shirt' + assert float(response.data['data']['price']) == 24.99 + assert response.data['data']['quantity'] == 180 + + # Verify unchanged fields are preserved + assert response.data['data']['brand'] == original_data['brand'] + assert response.data['data']['description'] == original_data['description'] + + def test_partial_update_product(self, api_client, db): + + product_id = str(db['products']['laptop'].id) + + patch_data = { + 'price': 1099.99 + } + + response = api_client.patch(f'/api/products/{product_id}/update/', patch_data, format='json') + + assert response.status_code == status.HTTP_200_OK + assert float(response.data['data']['price']) == 1099.99 + assert response.data['data']['name'] == 'Laptop Pro' # Unchanged + + def test_update_nonexistent_product(self, api_client, db): + + fake_id = str(ObjectId()) + updated_data = { + 'name': 'Updated Name', + 'price': 99.99 + } + + response = api_client.put(f'/api/products/{fake_id}/update/', updated_data, format='json') + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert 'error' in response.data + + def test_update_with_invalid_data(self, api_client, db): + + product_id = str(db['products']['smartphone'].id) + + invalid_data = { + 'price': -50.00, + 'quantity': 'not-a-number' + } + + response = api_client.put(f'/api/products/{product_id}/update/', invalid_data, format='json') + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert 'errors' in response.data + assert 'price' in response.data['errors'] + assert 'quantity' in response.data['errors'] + + + def test_delete_product_success(self, api_client, db): + + product_id = str(db['products']['desk'].id) + + response = api_client.delete(f'/api/products/{product_id}/delete/') + + assert response.status_code == status.HTTP_204_NO_CONTENT + + # Verify it's gone + response = api_client.get(f'/api/products/{product_id}/') + assert response.status_code == status.HTTP_404_NOT_FOUND + + + + + def test_add_category_to_product_success(self, api_client, db): + + product_id = str(db['products']['laptop'].id) + category_id = str(db['categories']['furniture'].id) + + # Get product before modification for comparison + before_response = api_client.get(f'/api/products/{product_id}/') + original_categories = set(before_response.data['category']) + + response = api_client.put(f'/api/products/{product_id}/add-category/{category_id}/') + + assert response.status_code == status.HTTP_200_OK + assert 'message' in response.data + + # Verify the category was added + after_response = api_client.get(f'/api/products/{product_id}/') + updated_categories = set(after_response.data['category']) + assert 'Furniture' in updated_categories + assert 'Electronics' in updated_categories + assert updated_categories == original_categories.union({'Furniture'}) + + def test_add_existing_category(self, api_client, db): + + product_id = str(db['products']['smartwatch'].id) + category_id = str(db['categories']['electronics'].id) + + # The smartwatch already has the Electronics category + response = api_client.put(f'/api/products/{product_id}/add-category/{category_id}/') + + assert response.status_code == status.HTTP_200_OK or status.HTTP_400_BAD_REQUEST + if response.status_code == status.HTTP_400_BAD_REQUEST: + assert 'already has this category' in response.data['error'] + + def test_add_nonexistent_category(self, api_client, db): + + product_id = str(db['products']['laptop'].id) + fake_category_id = str(ObjectId()) + + response = api_client.put(f'/api/products/{product_id}/add-category/{fake_category_id}/') + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert 'error' in response.data + + def test_remove_category_from_product_success(self, api_client, db): + + product_id = str(db['products']['smartwatch'].id) + category_id = str(db['categories']['clothing'].id) + + # Get product before modification + before_response = api_client.get(f'/api/products/{product_id}/') + original_categories = set(before_response.data['category']) + + response = api_client.put(f'/api/products/{product_id}/remove-category/{category_id}/') + + assert response.status_code == status.HTTP_200_OK + assert 'message' in response.data + + # Verify the category was removed + after_response = api_client.get(f'/api/products/{product_id}/') + updated_categories = set(after_response.data['category']) + assert 'Clothing' not in updated_categories + assert 'Electronics' in updated_categories + assert updated_categories == original_categories - {'Clothing'} + + def test_remove_nonexistent_category_association(self, api_client, db): + + product_id = str(db['products']['laptop'].id) + category_id = str(db['categories']['clothing'].id) + + # Laptop doesn't have the Clothing category + response = api_client.put(f'/api/products/{product_id}/remove-category/{category_id}/') + + assert response.status_code == status.HTTP_400_BAD_REQUEST + # assert 'error' in response.data + assert 'message' in response.data + assert response.data['message'] == "Category not assigned to product." + + + def test_remove_last_category(self, api_client, db): + + product_id = str(db['products']['laptop'].id) + + # First, ensure laptop has only one category (Electronics) + electronics_category = str(db['categories']['electronics'].id) + + # Get all categories for laptop + response = api_client.get(f'/api/products/{product_id}/') + categories = response.data['category'] + + # If laptop has multiple categories, remove all except Electronics + if len(categories) > 1: + for category in categories: + if category != 'Electronics': + category_id = self._get_category_id_by_name(db, category) + api_client.put(f'/api/products/{product_id}/remove-category/{category_id}/') + + # Now try to remove the last category + response = api_client.put(f'/api/products/{product_id}/remove-category/{electronics_category}/') + + # Check if API prevents removing the last category or allows it + # This assertion depends on your API design + if response.status_code == status.HTTP_400_BAD_REQUEST: + assert 'last category' in response.data['error'].lower() or 'at least one category' in response.data['error'].lower() + + def test_create_product_with_extreme_values(self, api_client, db): + + extreme_product = { + 'name': 'Extreme Product', + 'description': 'Testing boundary values', + 'brand': 'TestBrand', + 'category': ['Electronics'], + 'price': 9999999.99, # Very high price + 'quantity': 1000000 # Very high quantity + } + + response = api_client.post('/api/products/create/', extreme_product, format='json') + + # System should either accept or reject with a meaningful error + if response.status_code == status.HTTP_201_CREATED: + assert float(response.data['data']['price']) == 9999999.99 + assert response.data['data']['quantity'] == 1000000 + elif response.status_code == status.HTTP_400_BAD_REQUEST: + assert 'error' in response.data + else: + print("Unexpected Response:", response.status_code, response.data) + assert False, "Expected status 201 or 400, got something else." + \ No newline at end of file diff --git a/backend/products/tests/run_tests.py b/backend/products/tests/run_tests.py new file mode 100644 index 00000000..a07710e9 --- /dev/null +++ b/backend/products/tests/run_tests.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python +# run_tests.py +import os +import sys +import pytest + +def run_integration_tests(): + + print("Starting MongoDB integration tests...") + + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + sys.path.insert(0, project_root) + + # Define test arguments + args = [ + '--verbose', + 'tests/test_api_integration.py', + '-v' + ] + + # Run the tests + result = pytest.main(args) + + # Return the exit code + return result + +if __name__ == "__main__": + sys.exit(run_integration_tests()) \ No newline at end of file diff --git a/backend/products/tests/seed_data.py b/backend/products/tests/seed_data.py new file mode 100644 index 00000000..df85933b --- /dev/null +++ b/backend/products/tests/seed_data.py @@ -0,0 +1,86 @@ +import mongoengine +from products.models.ProductModel import ProductCategory, Product +from decimal import Decimal + +def seed_database(): + + # Clear existing data + ProductCategory.objects.delete() + Product.objects.delete() + + # Create categories + electronics = ProductCategory( + title="Electronics", + description="Electronic devices and accessories" + ).save() + + clothing = ProductCategory( + title="Clothing", + description="Apparel and fashion items" + ).save() + + furniture = ProductCategory( + title="Furniture", + description="Home and office furniture" + ).save() + + # Create products + smartphone = Product( + name="Smartphone X", + description="Latest smartphone with advanced features", + brand="TechBrand", + category=[electronics], + price=Decimal('799.99'), + quantity=50 + ).save() + + laptop = Product( + name="Laptop Pro", + description="Professional laptop for developers", + brand="CodeMaster", + category=[electronics], + price=Decimal('1299.99'), + quantity=30 + ).save() + + tshirt = Product( + name="Cotton T-Shirt", + description="Comfortable cotton t-shirt", + brand="FashionCo", + category=[clothing], + price=Decimal('19.99'), + quantity=200 + ).save() + + desk = Product( + name="Office Desk", + description="Ergonomic office desk", + brand="OfficePro", + category=[furniture], + price=Decimal('249.99'), + quantity=15 + ).save() + + smartwatch = Product( + name="Smart Watch", + description="Fitness and notification tracking", + brand="TechBrand", + category=[electronics, clothing], # Cross-category product + price=Decimal('199.99'), + quantity=45 + ).save() + + return { + "categories": { + "electronics": electronics, + "clothing": clothing, + "furniture": furniture + }, + "products": { + "smartphone": smartphone, + "laptop": laptop, + "tshirt": tshirt, + "desk": desk, + "smartwatch": smartwatch + } + } \ No newline at end of file diff --git a/backend/products/tests/unit/test_category_service.py b/backend/products/tests/unit/test_category_service.py new file mode 100644 index 00000000..02f57b19 --- /dev/null +++ b/backend/products/tests/unit/test_category_service.py @@ -0,0 +1,104 @@ +import pytest +from unittest.mock import patch, MagicMock +from django.http import Http404 +from products.services.CategoryService import CategoryService +from products.repositories.Category import CategoryRepository + + +@pytest.mark.parametrize( + "title, description", + [ + ("Electronics", "Category for electronic items"), + ("Books", None), + ], +) + +@patch("products.repositories.Category.CategoryRepository.create") +def test_create_category(mock_create, title, description): + mock_create.return_value = {"title": title, "description": description} + + result = CategoryService.create_category(title, description) + + mock_create.assert_called_once_with(title, description) + assert result == {"title": title, "description": description} + + +@patch("products.repositories.Category.CategoryRepository.get_category_by_title") +@patch("products.repositories.Category.CategoryRepository.get_products_by_category") +def test_get_products_by_category_title_found(mock_get_products, mock_get_category): + mock_category = MagicMock() + mock_get_category.return_value = mock_category + mock_get_products.return_value = [{"name": "Laptop"}, {"name": "Smartphone"}] + + products, error = CategoryService.get_products_by_category_title("Electronics") + + assert products == [{"name": "Laptop"}, {"name": "Smartphone"}] + assert error is None + mock_get_category.assert_called_once_with("Electronics") + mock_get_products.assert_called_once_with(mock_category) + + +@patch("products.repositories.Category.CategoryRepository.get_category_by_title") +def test_get_products_by_category_title_not_found(mock_get_category): + mock_get_category.return_value = None + + products, error = CategoryService.get_products_by_category_title("Nonexistent") + + assert products is None + assert error == "Category not found" + mock_get_category.assert_called_once_with("Nonexistent") + + +@patch("products.repositories.Category.CategoryRepository.getCategoryById") +def test_get_category_by_id_found(mock_get_category): + mock_category = MagicMock() + mock_get_category.return_value = mock_category + + result = CategoryService.getCategoryById("123") + + assert result == mock_category + mock_get_category.assert_called_once_with("123") + + +@patch("products.repositories.Category.CategoryRepository.getCategoryById") +def test_get_category_by_id_not_found(mock_get_category): + mock_get_category.return_value = None + + with pytest.raises(Http404, match="Category not found"): + CategoryService.getCategoryById("invalid_id") + + mock_get_category.assert_called_once_with("invalid_id") + + +@patch("products.repositories.Category.CategoryRepository.get_all") +def test_get_all_categories(mock_get_all): + mock_get_all.return_value = [ + {"title": "Electronics"}, + {"title": "Books"}, + ] + + result = CategoryService.get_all_categories() + + assert isinstance(result, list) + assert len(result) == 2 + mock_get_all.assert_called_once() + + +@patch("products.repositories.Category.CategoryRepository.update") +def test_update_category(mock_update): + mock_update.return_value = True + + result = CategoryService.update_category("Old Title", "New Title", "New Description") + + assert result is True + mock_update.assert_called_once_with("Old Title", "New Title", "New Description") + + +@patch("products.repositories.Category.CategoryRepository.delete") +def test_delete_category(mock_delete): + mock_delete.return_value = True + + result = CategoryService.delete_category("Electronics") + + assert result is True + mock_delete.assert_called_once_with("Electronics") diff --git a/backend/products/tests/unit/test_product_service.py b/backend/products/tests/unit/test_product_service.py new file mode 100644 index 00000000..5c5013ec --- /dev/null +++ b/backend/products/tests/unit/test_product_service.py @@ -0,0 +1,258 @@ +import pytest +from unittest.mock import patch, MagicMock +from django.http import Http404 +from bson import ObjectId +from products.services.ProductService import ProductService +from rest_framework.exceptions import ErrorDetail + +@pytest.mark.django_db +@pytest.mark.django_db(transaction=True) + +class TestProductService: + + @patch("products.repositories.ProductRepository.ProductRepository.createProd") + @patch("products.serializers.ProductSerializer.is_valid") + @patch("products.serializers.ProductSerializer", autospec=True) + def test_create_product_success(self, mock_serializer_class, mock_is_valid, mock_create_prod, db): + # Create a mock instance of the ProductSerializer + mock_serializer_instance = mock_serializer_class.return_value + mock_serializer_instance.is_valid.return_value = True # Simulate that the serializer is valid + mock_serializer_instance.data = {"id": "12345", "name": "Test Product", "price": 100} # Return actual dictionary directly + + mock_product = MagicMock() + mock_product.id = "12345" + mock_product.name = "Test Product" + mock_product.price = 100 + mock_create_prod.return_value = mock_product + + product_data = { + "name": "Test Product", + "price": 100, + "brand": "Test Brand", + "category": [], + "quantity": 10 + } + + result = ProductService.createProd(product_data) + + assert result["success"] is True + assert result["data"]["name"] == "Test Product" + assert result["data"]["id"] == "12345" + assert result["data"]["price"] == 100 + + @patch("products.repositories.ProductRepository.ProductRepository.getProdById") + def test_get_product_by_id_found(self, mock_get): + + mock_product = MagicMock() + mock_product.id = ObjectId() + + # print(mock_product) + mock_get.return_value = mock_product + + result = ProductService.getProdById(mock_product.id) + # print(result) + assert result == mock_product + + + @patch("products.repositories.ProductRepository.ProductRepository.getProdById") + def test_get_product_by_id_not_found(self, mock_get): + + mock_get.return_value = None + + result = ProductService.getProdById(ObjectId()) + assert result is None + + + @patch("products.repositories.ProductRepository.ProductRepository.getAllProd") + def test_get_all_products(self, mock_get_all): + + mock_get_all.return_value = [{"name": "Product 1"}, {"name": "Product 2"}] + + result = ProductService.getAllProds() + # print(result) + mock_get_all.assert_called_once() + + assert isinstance(result, list) + assert len(result) == 2 + + @patch("products.repositories.ProductRepository.ProductRepository.getAllProd") + def test_get_all_products_empty(self, mock_get_all): + + mock_get_all.return_value = [] + + result = ProductService.getAllProds() + + mock_get_all.assert_called_once() + + assert isinstance(result, list) + assert len(result) == 0 + + @patch("products.repositories.ProductRepository.ProductRepository.deleteProd") + def test_delete_product_success(self, mock_delete): + + mock_delete.return_value = True + + result = ProductService.deleteProd(ObjectId()) + # print(result) + assert result["success"] is True + assert result["message"] == "Product deleted successfully" + + + @patch("products.repositories.ProductRepository.ProductRepository.deleteProd") + def test_delete_product_not_found(self, mock_delete): + + mock_delete.return_value = False + + with pytest.raises(Http404, match="Product not found"): + ProductService.deleteProd(ObjectId()) + + + @patch("products.repositories.ProductRepository.ProductRepository.getProdById") + @patch("products.serializers.ProductSerializer.save") + def test_update_product_success(self, mock_save, mock_get_product): + + mock_product = MagicMock() + mock_product.id = ObjectId() + + mock_get_product.return_value = mock_product + + + mock_serializer = MagicMock() + mock_serializer.is_valid.return_value = True # Validation succeeds + mock_save.return_value = mock_product + + with patch("products.serializers.ProductSerializer", return_value=mock_serializer): + updated_data = {"name": "Updated Product Name", "price": 200} + result = ProductService.updateProd(mock_product.id, updated_data) + + assert result == mock_product + + + + @patch("products.repositories.ProductRepository.ProductRepository.getProdById") + def test_update_product_not_found(self, mock_get_product): + + mock_get_product.return_value = None + + updated_data = {"name": "Updated Product"} + result = ProductService.updateProd(ObjectId(), updated_data) + + assert result is None + + @patch("products.repositories.ProductRepository.ProductRepository.getProdById") + def test_update_product_serializer_error(self, mock_get_product): + + mock_product = MagicMock() + mock_product.id = ObjectId() + + mock_get_product.return_value = mock_product # Simulate product found + + mock_serializer = MagicMock() + mock_serializer.is_valid.return_value = False # Validation fails + mock_serializer.errors = {"price": [ErrorDetail(string='Price must be greater than zero.', code='invalid')]} + + with patch("products.serializers.ProductSerializer", return_value=mock_serializer): + updated_data = {"price": -100} # Invalid price + # Now, match the error detail in the exception message + with pytest.raises(Exception, match=r"{'price': \[ErrorDetail\(string='Price must be greater than zero.', code='invalid'\)\]}"): + ProductService.updateProd(mock_product.id, updated_data) + + @patch("products.repositories.ProductRepository.ProductRepository.getProdById") + @patch("products.repositories.Category.CategoryRepository.getCategoryById") + @patch("products.repositories.ProductRepository.ProductRepository.update_product_categories") + def test_remove_category_from_product_success(self, mock_update_categories, mock_get_category, mock_get_product): + # Set up mocks + mock_product = MagicMock() + mock_category = MagicMock() + mock_product.id = ObjectId() + mock_category.id = ObjectId() + mock_product.category = [mock_category] + + mock_get_product.return_value = mock_product + mock_get_category.return_value = mock_category + + # Call the function under test + result = ProductService.remove_category_from_product(mock_product.id, mock_category.id) + + # Assertions + assert result == {"message": "Category removed from product successfully.", "status": 200} + mock_update_categories.assert_called_once() + + + @patch("products.repositories.ProductRepository.ProductRepository.getProdById") + @patch("products.repositories.Category.CategoryRepository.getCategoryById") + def test_remove_category_from_product_product_not_found(self, mock_get_category, mock_get_product): + + mock_get_product.return_value = None # Simulate product not found + + with pytest.raises(Http404, match="Product not found"): + ProductService.remove_category_from_product(ObjectId(), ObjectId()) + + + @patch("products.repositories.ProductRepository.ProductRepository.getProdById") + @patch("products.repositories.Category.CategoryRepository.getCategoryById") + def test_remove_category_from_product_category_not_found(self, mock_get_category, mock_get_product): + + mock_product = MagicMock() + mock_product.id = ObjectId() + + mock_get_product.return_value = mock_product + mock_get_category.return_value = None # Simulate category not found + + with pytest.raises(Http404, match="Category not found"): + ProductService.remove_category_from_product(mock_product.id, ObjectId()) + + + @patch("products.repositories.ProductRepository.ProductRepository.getProdById") + @patch("products.repositories.Category.CategoryRepository.getCategoryById") + def test_remove_category_from_product_category_not_assigned(self, mock_get_category, mock_get_product): + + mock_product = MagicMock() + mock_category = MagicMock() + mock_product.id = ObjectId() + mock_category.id = ObjectId() + mock_product.category = [mock_category] # Product has a different category + + mock_get_product.return_value = mock_product + mock_get_category.return_value = mock_category + + result = ProductService.remove_category_from_product(mock_product.id, ObjectId()) # Using a different category id + + assert result == {"message": "Category not assigned to product.", "status": 400} + + + @patch("products.repositories.ProductRepository.ProductRepository.getProdById") + @patch("products.repositories.Category.CategoryRepository.getCategoryById") + def test_add_category_to_product_success(self, mock_get_category, mock_get_product): + + mock_product = MagicMock() + mock_category = MagicMock() + mock_product.id = ObjectId() + mock_category.id = ObjectId() + mock_product.category = [] + + mock_get_product.return_value = mock_product + mock_get_category.return_value = mock_category + + result = ProductService.add_category_to_product(mock_product.id, mock_category.id) + + assert result == {"message": "Category added to product successfully.", "status": 200} + mock_product.save.assert_called_once() + + + @patch("products.repositories.ProductRepository.ProductRepository.getProdById") + @patch("products.repositories.Category.CategoryRepository.getCategoryById") + def test_add_category_to_product_category_already_assigned(self, mock_get_category, mock_get_product): + + mock_product = MagicMock() + mock_category = MagicMock() + mock_product.id = ObjectId() + mock_category.id = ObjectId() + mock_product.category = [mock_category] + + mock_get_product.return_value = mock_product + mock_get_category.return_value = mock_category + + result = ProductService.add_category_to_product(mock_product.id, mock_category.id) # Category is already assigned + + assert result == {"message": "Category already assigned to product.", "status": 400} diff --git a/backend/products/urls.py b/backend/products/urls.py new file mode 100644 index 00000000..455a1bfa --- /dev/null +++ b/backend/products/urls.py @@ -0,0 +1,32 @@ +from django.urls import path + +from .views.ProductView import ( + ProductCreate, + ProductList, + ProductDetail, + ProductUpdate, + ProductDelete, + # CheckCategoryView, + AddCategoryToProduct, + RemoveCategoryFromProduct, + +) + +from .views.CategoryView import (CategoryView , CategoryDetail, CategoryList) + +urlpatterns = [ + path('products/create/', ProductCreate.as_view(), name='create-product'), + path('products/', ProductList.as_view(), name='product-list'), + path('products//', ProductDetail.as_view(), name='product-detail'), + path('products//update/', ProductUpdate.as_view(), name='product-update'), + path('products//delete/', ProductDelete.as_view(), name='product-delete'), + # path("check-category/", CheckCategoryView.as_view(), name="check_category"), + path('categories/', CategoryView.as_view(), name='category-create'), + path('categories/all', CategoryList.as_view(), name='category-list'), + path('categories//update', CategoryView.as_view(), name='category-update'), + path('categories/title//', CategoryView.as_view(), name='category-title-detail'), + path('categories/id//', CategoryDetail.as_view(), name='category-id-detail'), + path('products//add-category//', AddCategoryToProduct.as_view(), name='add_category_to_product'), + path('products//remove-category//', RemoveCategoryFromProduct.as_view(), name='remove_category_from_product'), + +] diff --git a/backend/products/views/CategoryView.py b/backend/products/views/CategoryView.py new file mode 100644 index 00000000..19a2ab58 --- /dev/null +++ b/backend/products/views/CategoryView.py @@ -0,0 +1,93 @@ +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from rest_framework import generics +from rest_framework.exceptions import NotFound +from ..models.CategoryModel import ProductCategory +from ..serializers import ProductCategorySerializer +from ..serializers import ProductSerializer +from ..services.CategoryService import CategoryService +from rest_framework.pagination import PageNumberPagination +from bson import ObjectId +from rest_framework.exceptions import ValidationError +from mongoengine.errors import DoesNotExist , NotUniqueError + +class CategoryPagination(PageNumberPagination): + page_size = 2 + page_size_query_param = 'page_size' + max_page_size = 100 + +class CategoryView(APIView): + def post(self, request): + print("Received Data:", request.data) + try: + serializer = ProductCategorySerializer(data=request.data) + if serializer.is_valid(): + category = serializer.save() + print("Saved Data:", category.to_mongo().to_dict()) + return Response({"message": "Category created successfully", "category": serializer.data}, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except NotUniqueError: + return Response({'error': 'Category with this title already exists'}, status=status.HTTP_400_BAD_REQUEST) + + def get(self, request, title): + products, error = CategoryService.get_products_by_category_title(title) + + if error: + return Response({'error': 'Category not found'}, status=status.HTTP_404_NOT_FOUND) + + serializer = ProductSerializer(products, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + + def put(self, request, title): + print("PUT method reached") + category = ProductCategory.objects(title=title).first() + if not category: + return Response({"error": "Category not found"}, status=status.HTTP_404_NOT_FOUND) + + serializer = ProductCategorySerializer(instance=category, data=request.data) + + if serializer.is_valid(): + updated_category = serializer.save() + return Response({"message": "Category updated successfully", "category": serializer.data}, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, title): + category = ProductCategory.objects(title=title).first() + if not category: + return Response({"error": "Category not found"}, status=status.HTTP_404_NOT_FOUND) + + category.delete() + return Response({"message": "Category deleted successfully"}, status=status.HTTP_200_OK) + +class CategoryDetail(generics.RetrieveAPIView): + serializer_class = ProductCategorySerializer + lookup_field = "id" + + def get_object(self): + category_id = self.kwargs.get("id") + + if not ObjectId.is_valid(category_id): + raise ValidationError({"error": "Invalid ID format"}) + + try: + category = ProductCategory.objects.get(id=category_id) + except DoesNotExist: + raise NotFound("Category not found") + category = CategoryService.getCategoryById(category_id) + print("dfdf" , category) + if not category: + raise NotFound("Category not found") + return category + +class CategoryList(generics.ListAPIView): + serializer_class = ProductCategorySerializer + pagination_class = CategoryPagination + + def get_queryset(self): + queryset = CategoryService.get_all_categories() + print(f"Queryset: {queryset}") + if queryset is None: + return ProductCategory.objects.none() + return queryset diff --git a/backend/products/views/ProductView.py b/backend/products/views/ProductView.py new file mode 100644 index 00000000..5a48be54 --- /dev/null +++ b/backend/products/views/ProductView.py @@ -0,0 +1,173 @@ +from rest_framework.views import APIView +from rest_framework.exceptions import NotFound +from rest_framework.response import Response +from rest_framework import generics, status +from rest_framework.pagination import PageNumberPagination +from bson import ObjectId +from mongoengine.errors import DoesNotExist +from rest_framework.exceptions import ValidationError +from django.http import Http404 + + +from ..services.ProductService import ProductService +from ..serializers import ProductSerializer +from ..models.ProductModel import Product +from ..errorHandler import handle_exception +from ..models.CategoryModel import ProductCategory + +class ProductPagination(PageNumberPagination): + page_size = 2 + page_size_query_param = 'page_size' + max_page_size = 100 + +class ProductCreate(APIView): + def post(self, request): + try: + print("Received Data:", request.data) + serializer = ProductSerializer(data=request.data) + + if serializer.is_valid(): + product = serializer.save() + return Response( + {"message": "Product created successfully", "data": ProductSerializer(product).data}, + status=status.HTTP_201_CREATED + ) + else: + print("Validation Errors:", serializer.errors) + return Response({"error": serializer.errors}, status=status.HTTP_400_BAD_REQUEST) + + except Exception as e: + print(f"Exception Caught: {e}") + return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + + +class ProductList(generics.ListAPIView): + serializer_class = ProductSerializer + pagination_class = ProductPagination + + def get_queryset(self): + queryset = ProductService.getAllProds() + print(f"Queryset: {queryset}") + if queryset is None: + return Product.objects.none() + return queryset + +class ProductDetail(generics.RetrieveAPIView): + serializer_class = ProductSerializer + lookup_field = "id" + + def get_object(self): + prod_id = self.kwargs.get("id") + print(prod_id) + prod = ProductService.getProdById(prod_id) + if not prod: + raise Http404("Product not found") + return prod + + +class ProductUpdate(generics.UpdateAPIView): + queryset = Product.objects.all() + serializer_class = ProductSerializer + lookup_field = "id" + + def put(self, request, *args, **kwargs): + try: + prod_id = kwargs.get("id") + result = ProductService.updateProd(prod_id, request.data) + + if result is None: + return Response({"error": "Product not found"}, status=status.HTTP_404_NOT_FOUND) + + # if isinstance(result, dict) and "errors" in result: + # raise ValidationError(result["errors"]) + + return Response({ + "message": "Product updated successfully", + "data": ProductSerializer(result).data + }, status=status.HTTP_200_OK) + + except ValidationError as ve: + return Response(ve.detail, status=status.HTTP_400_BAD_REQUEST) + + except Exception as e: + + if isinstance(e.args[0], dict): + return Response({"errors": e.args[0]}, status=status.HTTP_400_BAD_REQUEST) + + return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + def patch(self, request, *args, **kwargs): + try: + prod_id = kwargs.get("id") + updated_product = ProductService.updateProd(prod_id, request.data) + + if not updated_product: + return Response({"error": "Product not found"}, status=status.HTTP_404_NOT_FOUND) + + return Response({ + "message": "Product updated successfully", + "data": ProductSerializer(updated_product).data + }, status=status.HTTP_200_OK) + + except Exception as e: + # Catch raw serializer errors raised from updateProd + if isinstance(e.args[0], dict): # Serializer errors are dicts + return Response({"errors": e.args[0]}, status=status.HTTP_400_BAD_REQUEST) + + return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + +class ProductDelete(generics.DestroyAPIView): + queryset = Product.objects.all() + serializer_class = ProductSerializer + lookup_field = "id" + + def delete(self, request, *args, **kwargs): + try: + prod_id = kwargs.get("id") + product = ProductService.getProdById(prod_id) + + if not product: + return Response({"error": "Product not found"}, status=status.HTTP_404_NOT_FOUND) + + product.delete() + return Response({"message": "Product deleted successfully"}, status=status.HTTP_204_NO_CONTENT) + + except ValueError as ve: + return Response({"error": str(ve)}, status=status.HTTP_400_BAD_REQUEST) + + except Exception as e: + return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +class AddCategoryToProduct(generics.UpdateAPIView): + + def put(self, request, *args, **kwargs): + product_id = kwargs.get("product_id") + category_id = kwargs.get("category_id") + + try: + response_data = ProductService.add_category_to_product(product_id, category_id) + + if response_data.get("status") == 400: + return Response({"error": response_data.get("message")}, status=status.HTTP_400_BAD_REQUEST) + + return Response({"message": response_data.get("message")}, status=status.HTTP_200_OK) + + except (NotFound, Http404) as nf: + return Response({"error": str(nf)}, status=status.HTTP_404_NOT_FOUND) + + except Exception as e: + return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +class RemoveCategoryFromProduct(generics.UpdateAPIView): + + def put(self, request, *args, **kwargs): + try: + product_id = kwargs.get("product_id") + category_id = kwargs.get("category_id") + response = ProductService.remove_category_from_product(product_id, category_id) + return Response({"message": response["message"]}, status=response["status"]) + except Exception as e: + return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) \ No newline at end of file diff --git a/backend/products/views/__init__.py b/backend/products/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 00000000..4e9e873f --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +DJANGO_SETTINGS_MODULE = django_app.settings