diff --git a/bbs/tests/conftest.py b/bbs/tests/conftest.py new file mode 100644 index 0000000..7425ba2 --- /dev/null +++ b/bbs/tests/conftest.py @@ -0,0 +1,40 @@ +import pytest +from django.contrib.auth.models import User as DjangoUser +from django.test import Client +from strawberry.django.test import GraphQLTestClient + +from bbs.models import Order, OrderLine, Product, Transaction, UserProfile + + +@pytest.fixture +def product(db): # noqa: ARG001 + return Product.objects.create(name="borg", sale_price_ext=35, sale_price_int=30) + + +@pytest.fixture +def user(db): # noqa: ARG001 + u = DjangoUser.objects.create_user( + username="test@example.com", + email="test@example.com", + first_name="Test", + last_name="User", + ) + UserProfile.objects.create(user=u, balance=10) + return u + + +@pytest.fixture +def order(product): + o = Order.objects.create(order_sum=35) + OrderLine.objects.create(order=o, product=product, amount=1, unit_price=35) + return o + + +@pytest.fixture +def transaction(user): + return Transaction.objects.create(user=user, amount=100) + + +@pytest.fixture +def gql_client(client: Client): + return GraphQLTestClient(client) diff --git a/bbs/tests/test_management.py b/bbs/tests/test_management.py index d5d833c..33a2b96 100644 --- a/bbs/tests/test_management.py +++ b/bbs/tests/test_management.py @@ -1,17 +1,9 @@ import pytest -from django.contrib.auth import get_user_model from django.core.management import call_command from bbs.models import UserProfile -@pytest.fixture -def user(db): # noqa: ARG001 - user = get_user_model().objects.create_user(username="profile-image-user", email="image@example.com") - UserProfile.objects.create(user=user) - return user - - def test_profile_images_updates_missing_image(user): UserProfile.objects.filter(pk=user.profile.pk).update(image="") diff --git a/bbs/tests/test_models.py b/bbs/tests/test_models.py index 3b4e854..b6d5c68 100644 --- a/bbs/tests/test_models.py +++ b/bbs/tests/test_models.py @@ -1,12 +1,313 @@ +import hashlib from datetime import UTC, datetime +from decimal import Decimal -from django.contrib.auth import get_user_model +import pytest +from django.contrib.auth.models import User as DjangoUser -from bbs.models import UserProfile +from bbs.models import ( + EXTERNAL_USER, + InventoryTransaction, + Order, + OrderLine, + Product, + UserProfile, + _format_counts, +) + +# _format_counts +# ============== -def test_last_purchase_date_without_orders_is_timezone_aware(db): # noqa: ARG001 - user = get_user_model().objects.create_user(username="no-purchases") - profile = UserProfile.objects.create(user=user) +def test_format_counts_with_last_name(): + rows = [{"order__customer__first_name": "Alice", "order__customer__last_name": "Smith", "count": 3}] + result = _format_counts(rows) + assert result[0]["name"] == "Alice S" - assert profile.last_purchase_date() == datetime.min.replace(tzinfo=UTC) + +def test_format_counts_no_last_name(): + rows = [{"order__customer__first_name": "Bob", "order__customer__last_name": "", "count": 1}] + result = _format_counts(rows) + assert result[0]["name"] == "Bob" + + +def test_format_counts_external(): + rows = [{"order__customer__first_name": None, "order__customer__last_name": None, "count": 2}] + result = _format_counts(rows) + assert result[0]["name"] == EXTERNAL_USER + + +# Product +# ======= + + +def test_product_str(product): + assert str(product) == "borg" + + +def test_image_url_without_image(product): + assert product.image_url == "" + + +def test_image_url_with_image(product): + product.image = "products/test.jpg" + assert product.image_url == product.image.url + + +def test_wholesale_unit_price_no_inventory(product): + product.inventory_amount = 0 + assert product.wholesale_unit_price == 0 + + +def test_wholesale_unit_price_no_transactions(product): + product.inventory_amount = 5 + assert product.wholesale_unit_price == 0 + + +def test_wholesale_unit_price_within_batch(user, product): + product.inventory_amount = 5 + product.save() + InventoryTransaction.objects.create(user=user, product=product, amount=10, unit_price=Decimal("8.0")) + assert product.wholesale_unit_price == Decimal("8.0") + + +def test_wholesale_unit_price_exceeds_all_batches(user, product): + product.inventory_amount = 20 + product.save() + InventoryTransaction.objects.create(user=user, product=product, amount=5, unit_price=Decimal("8.0")) + InventoryTransaction.objects.create(user=user, product=product, amount=5, unit_price=Decimal("6.0")) + # inventory(20) > total transactions(10), falls back to first transaction's unit_price + assert product.wholesale_unit_price == Decimal("8.0") + + +def test_wholesale_value(user, product): + product.inventory_amount = 5 + product.save() + InventoryTransaction.objects.create(user=user, product=product, amount=10, unit_price=Decimal("8.0")) + assert product.wholesale_value == product.wholesale_unit_price * 5 + + +def test_user_counts(user, product): + o = Order.objects.create(customer=user, order_sum=60) + OrderLine.objects.create(order=o, product=product, amount=2, unit_price=30) + counts = product.user_counts() + assert len(counts) == 1 + assert counts[0]["name"] == "Test U" + assert counts[0]["count"] == 2 + + +# OrderManager +# ============ + + +def test_count_by_created_period_yearly(order): # noqa: ARG001 + qs = list(Order.objects.count_by_created_period("yearly")) + assert len(qs) == 1 + assert "period" in qs[0] + + +def test_count_by_created_period_monthly(order): # noqa: ARG001 + qs = list(Order.objects.count_by_created_period("monthly")) + assert len(qs) == 1 + + +def test_count_by_created_period_daily(order): # noqa: ARG001 + qs = list(Order.objects.count_by_created_period("daily")) + assert len(qs) == 1 + + +def test_count_by_created_period_hourly(order): # noqa: ARG001 + qs = list(Order.objects.count_by_created_period("hourly")) + assert len(qs) == 1 + + +def test_count_by_created_period_invalid(): + with pytest.raises(AssertionError): + Order.objects.count_by_created_period("weekly") + + +# Order +# ===== + + +def test_order_is_external_true(db): # noqa: ARG001 + o = Order.objects.create(order_sum=10) + assert o.is_external() is True + + +def test_order_is_external_false(user): + o = Order.objects.create(customer=user, order_sum=10) + assert o.is_external() is False + + +def test_order_receipt_single_line(order): + ol = order.orderlines.first() + assert order.reciept() == str(ol) + + +def test_order_receipt_multiple_lines(product): + product2 = Product.objects.create(name="cola", sale_price_ext=20, sale_price_int=15) + o = Order.objects.create(order_sum=55) + OrderLine.objects.create(order=o, product=product, amount=1, unit_price=35) + OrderLine.objects.create(order=o, product=product2, amount=1, unit_price=20) + receipt = o.reciept() + assert "borg" in receipt + assert "cola" in receipt + assert ", " in receipt + + +def test_order_str_external(db): # noqa: ARG001 + o = Order.objects.create(order_sum=Decimal("15.0")) + assert EXTERNAL_USER in str(o) + assert "15.0" in str(o) + + +def test_order_str_with_customer(user): + o = Order.objects.create(customer=user, order_sum=Decimal("20.0")) + assert str(user) in str(o) + + +# OrderLine / OrderLineManager +# ============================ + + +def test_orderline_price(order): + ol = order.orderlines.first() + assert ol.price == ol.amount * ol.unit_price + + +def test_orderline_str(order): + ol = order.orderlines.first() + assert str(ol) == f"{ol.amount} {ol.product} ({ol.unit_price} kr per)" + + +def test_product_order_stats(order): # noqa: ARG001 + qs = list(OrderLine.objects.all().product_order_stats()) + assert len(qs) == 1 + assert qs[0]["product_name"] == "borg" + assert qs[0]["count"] == 1 + + +def test_as_series_single_product(order): # noqa: ARG001 + qs = OrderLine.objects.all().product_order_stats() + series = qs.as_series() + assert len(series) == 1 + assert series[0]["product_name"] == "borg" + assert len(series[0]["data"]) == 1 + assert "x" in series[0]["data"][0] + assert "y" in series[0]["data"][0] + + +def test_as_series_multiple_products(user, product): + product2 = Product.objects.create(name="cola", sale_price_ext=20, sale_price_int=15) + o = Order.objects.create(customer=user, order_sum=55) + OrderLine.objects.create(order=o, product=product, amount=2, unit_price=30) + OrderLine.objects.create(order=o, product=product2, amount=1, unit_price=20) + qs = OrderLine.objects.all().product_order_stats() + series = qs.as_series() + assert {s["product_name"] for s in series} == {"borg", "cola"} + + +# Transaction +# =========== + + +def test_transaction_str(transaction, user): + result = str(transaction) + assert str(transaction.id) in result + assert str(user) in result + assert str(transaction.amount) in result + + +# InventoryTransaction +# ==================== + + +def test_inventory_transaction_price(user, product): + it = InventoryTransaction.objects.create(user=user, product=product, amount=3, unit_price=Decimal("10.0")) + assert it.price == Decimal("30.0") + + +def test_inventory_transaction_str(user, product): + it = InventoryTransaction.objects.create(user=user, product=product, amount=3, unit_price=Decimal("10.0")) + result = str(it) + assert str(it.id) in result + assert str(user) in result + assert "borg" in result + + +# UserProfile +# =========== + + +def test_userprofile_str(user): + assert str(user.profile) == str(user) + + +def test_last_purchase_none(user): + assert user.profile.last_purchase() is None + + +def test_last_purchase_returns_latest(user): + Order.objects.create(customer=user, order_sum=10) + o2 = Order.objects.create(customer=user, order_sum=20) + assert user.profile.last_purchase() == o2 + + +def test_last_purchase_date_no_orders(user): + assert user.profile.last_purchase_date() == datetime.min.replace(tzinfo=UTC) + + +def test_last_purchase_date_with_orders(user): + o = Order.objects.create(customer=user, order_sum=10) + assert user.profile.last_purchase_date() == o.created + + +def test_top_months(user): + Order.objects.create(customer=user, order_sum=50) + Order.objects.create(customer=user, order_sum=30) + result = list(user.profile.top_months()) + assert len(result) == 1 + assert result[0]["sum"] == Decimal("80.0") + + +def test_order_sum_total_no_orders(user): + assert user.profile.order_sum_total() == 0 + + +def test_order_sum_total_with_orders(user): + Order.objects.create(customer=user, order_sum=Decimal("25.0")) + Order.objects.create(customer=user, order_sum=Decimal("15.0")) + assert user.profile.order_sum_total() == Decimal("40.0") + + +def test_userprofile_product_order_stats(user, product): + o = Order.objects.create(customer=user, order_sum=30) + OrderLine.objects.create(order=o, product=product, amount=1, unit_price=30) + result = list(user.profile.product_order_stats()) + assert len(result) == 1 + assert result[0]["product_name"] == "borg" + + +def test_image_url_with_email(user): + expected_hash = hashlib.sha256(user.email.lower().strip().encode()).hexdigest() + assert expected_hash in user.profile.image_url() + + +def test_image_url_no_email(db): # noqa: ARG001 + u = DjangoUser.objects.create_user(username="noemail", email="") + profile = UserProfile.objects.create(user=u, balance=0) + assert profile.image_url() == "" + + +def test_save_auto_populates_image(db): # noqa: ARG001 + u = DjangoUser.objects.create_user(username="auto@test.com", email="auto@test.com") + profile = UserProfile.objects.create(user=u, balance=0) + assert profile.image != "" + assert "gravatar" in profile.image + + +def test_save_preserves_existing_image(db): # noqa: ARG001 + u = DjangoUser.objects.create_user(username="img@test.com", email="img@test.com") + profile = UserProfile.objects.create(user=u, balance=0, image="https://example.com/custom.jpg") + assert profile.image == "https://example.com/custom.jpg" diff --git a/bbs/tests/test_mutations.py b/bbs/tests/test_mutations.py index d534248..1c0bc5f 100644 --- a/bbs/tests/test_mutations.py +++ b/bbs/tests/test_mutations.py @@ -1,20 +1,51 @@ -import pytest from django.contrib.auth import get_user_model -from django.test import Client from django.utils import timezone -from strawberry.django.test import GraphQLTestClient -from bbs.models import InventoryTransaction, Order, OrderLine, Product, Transaction +from bbs.models import InventoryTransaction, Order, OrderLine, Transaction, UserProfile +CREATE_ORDER = """ +mutation CreateOrder($customerId: ID!, $isExternal: Boolean!, $orderLines: [OrderLineInput!]!) { + createOrder(customerId: $customerId, isExternal: $isExternal, orderLines: $orderLines) { + ... on CreateOrderSuccess { + order { id orderSum orderlines { id product { id } } } + } + ... on InsufficientFunds { + amountLacking + error + } + ... on FormErrors { + message + } + } +} +""" -@pytest.fixture(name="gql_client") -def gql_client_fixture(client: Client): - return GraphQLTestClient(client) +CREATE_DEPOSIT = """ +mutation CreateDeposit($userId: ID!, $amount: Int!) { + createDeposit(userId: $userId, amount: $amount) { + ... on CreateDepositSuccess { + transaction { id amount } + } + ... on AboveMaxSaldo { + maxSaldo + above + } + ... on FormErrors { + message + } + } +} +""" - -@pytest.fixture(name="product") -def product_fix(db): # noqa: ARG001 - return Product.objects.create(name="borg", sale_price_ext=35, sale_price_int=30) +CREATE_USER = """ +mutation CreateUser($input: UserInput!) { + createUser(input: $input) { + id + firstName + lastName + } +} +""" def test_user_not_found(db, gql_client): # noqa: ARG001 @@ -206,3 +237,125 @@ def test_inventory_report_aggregates_period_data(product, gql_client): assert not products_result.errors assert products_result.data["allProducts"][0]["active"] assert products_result.data["inventoryTransactionList"][0]["amount"] == 5 + + +def test_create_order_no_customer(product, gql_client): + res = gql_client.query( + CREATE_ORDER, + { + "customerId": "999", + "isExternal": False, + "orderLines": [{"productId": product.id, "amount": 1}], + }, + ) + assert not res.errors + assert res.data["createOrder"]["message"] + + +def test_create_order_empty_lines(product, gql_client): # noqa: ARG001 + res = gql_client.query( + CREATE_ORDER, + { + "customerId": "external", + "isExternal": True, + "orderLines": [], + }, + ) + assert not res.errors + assert res.data["createOrder"]["message"] + + +def test_create_order_insufficient_funds(user, product, gql_client): + res = gql_client.query( + CREATE_ORDER, + { + "customerId": str(user.id), + "isExternal": False, + "orderLines": [{"productId": product.id, "amount": 1}], + }, + ) + assert not res.errors + assert res.data["createOrder"]["amountLacking"] == 20 + assert res.data["createOrder"]["error"] is True + assert Order.objects.count() == 0 + product.refresh_from_db() + assert product.inventory_amount == 0 + + +def test_create_order_internal_success(user, product, gql_client): + user.profile.balance = 100 + user.profile.save() + res = gql_client.query( + CREATE_ORDER, + { + "customerId": str(user.id), + "isExternal": False, + "orderLines": [{"productId": product.id, "amount": 1}], + }, + ) + assert not res.errors + assert res.data["createOrder"]["order"] + user.profile.refresh_from_db() + assert user.profile.balance == 70 + product.refresh_from_db() + assert product.inventory_amount == -1 + + +def test_create_order_exact_balance(user, product, gql_client): + user.profile.balance = 30 + user.profile.save() + res = gql_client.query( + CREATE_ORDER, + { + "customerId": str(user.id), + "isExternal": False, + "orderLines": [{"productId": product.id, "amount": 1}], + }, + ) + assert not res.errors + assert res.data["createOrder"]["order"] + user.profile.refresh_from_db() + assert user.profile.balance == 0 + + +def test_create_deposit(user, gql_client): + res = gql_client.query(CREATE_DEPOSIT, {"userId": str(user.id), "amount": 100}) + assert not res.errors + assert res.data["createDeposit"]["transaction"] + assert Transaction.objects.count() == 1 + user.profile.refresh_from_db() + assert user.profile.balance == 110 + + +def test_create_deposit_invalid_user(db, gql_client): # noqa: ARG001 + res = gql_client.query(CREATE_DEPOSIT, {"userId": "999", "amount": 100}) + assert not res.errors + assert res.data["createDeposit"]["message"] + + +def test_create_deposit_invalid_amount(user, gql_client): + res = gql_client.query(CREATE_DEPOSIT, {"userId": str(user.id), "amount": 0}) + assert not res.errors + assert res.data["createDeposit"]["message"] + + +def test_create_deposit_above_max_saldo(user, gql_client): + res = gql_client.query(CREATE_DEPOSIT, {"userId": str(user.id), "amount": 1000}) + assert not res.errors + assert res.data["createDeposit"]["maxSaldo"] is not None + assert res.data["createDeposit"]["above"] == 10 + assert Transaction.objects.count() == 0 + + +def test_create_deposit_1337(user, gql_client): + # balance=10, so amount=1327 makes balance+amount==1337, bypassing max saldo + res = gql_client.query(CREATE_DEPOSIT, {"userId": str(user.id), "amount": 1327}) + assert not res.errors + assert res.data["createDeposit"]["transaction"] + + +def test_create_user(db, gql_client): # noqa: ARG001 + res = gql_client.query(CREATE_USER, {"input": {"email": "new@example.com", "firstName": "New", "lastName": "User"}}) + assert not res.errors + assert get_user_model().objects.filter(username="new@example.com").exists() + assert UserProfile.objects.count() == 1 diff --git a/bbs/tests/test_queries.py b/bbs/tests/test_queries.py new file mode 100644 index 0000000..b2728fa --- /dev/null +++ b/bbs/tests/test_queries.py @@ -0,0 +1,63 @@ +ALL_USERS = "query { allUsers { id firstName lastName } }" +USER = "query User($userId: ID!) { user(userId: $userId) { id firstName lastName } }" +USER_PROFILE = "query User($userId: ID!) { user(userId: $userId) { profile { balance topMonths { period count sum } productOrderStats { productName data { x y } } } } }" +ALL_PRODUCTS = "query { allProducts { id name } }" +ALL_ORDERS = "query { allOrders { id orderSum } }" +ORDER_LIST = "query { orderList { id orderSum } }" +ORDER_STATS = "query { orderStats { yearly { period count } monthly { period count } daily { period count } hourly { period count } } }" +PRODUCT_STATS = "query { productStats { productName data { x y } } }" +TRANSACTION_LIST = "query { transactionList { id amount } }" + + +def test_user(user, gql_client): + res = gql_client.query(USER, {"userId": str(user.id)}) + assert not res.errors + assert res.data["user"]["id"] == str(user.id) + + +def test_all_users(user, gql_client): # noqa: ARG001 + res = gql_client.query(ALL_USERS) + assert not res.errors + assert len(res.data["allUsers"]) == 1 + + +def test_all_products(product, gql_client): # noqa: ARG001 + res = gql_client.query(ALL_PRODUCTS) + assert not res.errors + assert len(res.data["allProducts"]) == 1 + + +def test_all_orders(order, gql_client): # noqa: ARG001 + res = gql_client.query(ALL_ORDERS) + assert not res.errors + assert len(res.data["allOrders"]) == 1 + + +def test_order_list(order, gql_client): # noqa: ARG001 + res = gql_client.query(ORDER_LIST) + assert not res.errors + assert len(res.data["orderList"]) == 1 + + +def test_order_stats(db, gql_client): # noqa: ARG001 + res = gql_client.query(ORDER_STATS) + assert not res.errors + assert "daily" in res.data["orderStats"] + + +def test_product_stats(order, gql_client): # noqa: ARG001 + res = gql_client.query(PRODUCT_STATS) + assert not res.errors + assert res.data["productStats"] + + +def test_transaction_list(transaction, gql_client): # noqa: ARG001 + res = gql_client.query(TRANSACTION_LIST) + assert not res.errors + assert len(res.data["transactionList"]) == 1 + + +def test_user_profile(user, gql_client): + res = gql_client.query(USER_PROFILE, {"userId": str(user.id)}) + assert not res.errors + assert res.data["user"]["profile"]["balance"] is not None