Skip to content

Commit 4486b60

Browse files
committed
Add tests and utility functions for Items app
1 parent fea54f8 commit 4486b60

5 files changed

Lines changed: 295 additions & 0 deletions

File tree

src/items/tests/__init__.py

Whitespace-only changes.

src/items/tests/test_crud.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
from typing import Callable
2+
from unittest.mock import patch
3+
4+
import pytest
5+
from django.contrib.auth.models import User
6+
from django.urls import reverse
7+
from rest_framework import status
8+
from rest_framework.test import APIClient
9+
10+
from items.models import Item
11+
12+
13+
@pytest.fixture
14+
def api_client() -> APIClient:
15+
"""Fixture to provide an API client for testing."""
16+
return APIClient()
17+
18+
19+
@pytest.fixture
20+
def user(db) -> User:
21+
"""Fixture to create a test user."""
22+
return User.objects.create_user(username="testuser", password="testpass")
23+
24+
25+
@pytest.fixture
26+
def authenticated_client(api_client: APIClient, user: User) -> APIClient:
27+
"""Return an API client logged in as the test user."""
28+
api_client.force_authenticate(user=user)
29+
return api_client
30+
31+
32+
@pytest.fixture
33+
def create_item() -> Callable[..., Item]:
34+
"""Fixture to create an item in the database."""
35+
36+
def _create_item(**kwargs) -> Item:
37+
return Item.objects.create(**kwargs)
38+
39+
return _create_item
40+
41+
42+
@pytest.mark.django_db
43+
def test_list_items(
44+
authenticated_client: APIClient, create_item: Callable[..., Item]
45+
) -> None:
46+
create_item(name="Item 1", description="desc", price=10.5)
47+
create_item(name="Item 2", description="desc2", price=20.0)
48+
49+
url = reverse("item-list")
50+
response = authenticated_client.get(url)
51+
52+
assert response.status_code == status.HTTP_200_OK
53+
assert len(response.data) >= 2
54+
55+
56+
@pytest.mark.django_db
57+
def test_create_item(authenticated_client: APIClient) -> None:
58+
url = reverse("item-list")
59+
data = {"name": "New Item", "description": "New description", "price": "15.99"}
60+
response = authenticated_client.post(url, data, format="json")
61+
assert response.status_code == status.HTTP_201_CREATED
62+
assert response.data["name"] == data["name"]
63+
assert float(response.data["price"]) == float(data["price"])
64+
65+
66+
@pytest.mark.django_db
67+
def test_retrieve_item(
68+
authenticated_client: APIClient, create_item: Callable[..., Item]
69+
) -> None:
70+
"""Test retrieving a single item."""
71+
item = create_item(name="Retrieve Item", description="desc", price=5.5)
72+
url = reverse("item-detail", args=[item.id])
73+
response = authenticated_client.get(url)
74+
assert response.status_code == status.HTTP_200_OK
75+
assert response.data["id"] == item.id
76+
assert response.data["name"] == item.name
77+
78+
79+
@pytest.mark.django_db
80+
def test_update_item(
81+
authenticated_client: APIClient, create_item: Callable[..., Item]
82+
) -> None:
83+
"""Test updating an item."""
84+
item = create_item(name="Old Name", description="desc", price=7.0)
85+
url = reverse("item-detail", args=[item.id])
86+
data = {"name": "Updated Name", "description": "Updated desc", "price": "8.50"}
87+
response = authenticated_client.put(url, data, format="json")
88+
assert response.status_code == status.HTTP_200_OK
89+
assert response.data["name"] == data["name"]
90+
assert float(response.data["price"]) == float(data["price"])
91+
92+
93+
@pytest.mark.django_db
94+
def test_delete_item(
95+
authenticated_client: APIClient, create_item: Callable[..., Item]
96+
) -> None:
97+
"""Test deleting an item."""
98+
item = create_item(name="Delete Me", description="desc", price=10.0)
99+
url = reverse("item-detail", args=[item.id])
100+
response = authenticated_client.delete(url)
101+
assert response.status_code == status.HTTP_204_NO_CONTENT
102+
assert not Item.objects.filter(id=item.id).exists()
103+
104+
105+
@pytest.mark.django_db
106+
@patch("items.views.simulate_external_price_sync_for_item.delay")
107+
def test_sync_price(
108+
mock_task_delay, authenticated_client: APIClient, create_item: Callable[..., Item]
109+
) -> None:
110+
"""Test syncing price for a single item."""
111+
item = create_item(name="Test Item", description="desc", price=10.0)
112+
mock_task_delay.reset_mock()
113+
mock_task_delay.return_value.id = "fake-task-id"
114+
115+
url = reverse("item-sync-price", args=[item.id])
116+
response = authenticated_client.post(url)
117+
118+
assert response.status_code == status.HTTP_202_ACCEPTED
119+
assert response.data["task_id"] == "fake-task-id"
120+
mock_task_delay.assert_called_once_with(item.id)
121+
122+
123+
@pytest.mark.django_db
124+
@patch("items.views.hourly_external_price_sync.delay")
125+
def test_sync_all_prices(mock_task_delay, authenticated_client: APIClient) -> None:
126+
"""Test syncing prices for all items."""
127+
mock_task_delay.reset_mock()
128+
mock_task_delay.return_value.id = "fake-batch-task-id"
129+
130+
url = reverse("item-sync-all-prices")
131+
response = authenticated_client.post(url)
132+
133+
assert response.status_code == status.HTTP_202_ACCEPTED
134+
assert response.data["task_id"] == "fake-batch-task-id"
135+
mock_task_delay.assert_called_once()
136+
137+
138+
@pytest.mark.django_db
139+
@patch("items.views.AsyncResult")
140+
def test_task_status(mock_async_result, authenticated_client: APIClient) -> None:
141+
"""Test checking the status of a Celery task."""
142+
mock_result_instance = mock_async_result.return_value
143+
mock_result_instance.status = "SUCCESS"
144+
mock_result_instance.ready.return_value = True
145+
mock_result_instance.result = "done"
146+
147+
task_id = "fake-task-id"
148+
url = reverse("item-task-status", args=[task_id])
149+
response = authenticated_client.get(url)
150+
151+
assert response.status_code == status.HTTP_200_OK
152+
assert response.data == {
153+
"task_id": task_id,
154+
"status": "SUCCESS",
155+
"result": "done",
156+
}
157+
mock_async_result.assert_called_once_with(task_id)

src/items/tests/test_tasks.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from decimal import Decimal
2+
from typing import List
3+
from unittest.mock import MagicMock
4+
5+
import pytest
6+
7+
from items.models import Item
8+
from items.tasks import (
9+
hourly_external_price_sync,
10+
simulate_external_price_sync_for_item,
11+
)
12+
from items.utils.price_sync import sync_all_items
13+
14+
15+
@pytest.mark.django_db
16+
def test_simulate_external_price_sync_for_item(mocker: MagicMock) -> None:
17+
"""Test the simulation of external price synchronization for a specific item."""
18+
item: Item = Item.objects.create(name="Test Item", price=Decimal("100.00"))
19+
mock_sync: MagicMock = mocker.patch(
20+
"items.tasks.sync_item_by_id", return_value="mocked result"
21+
)
22+
23+
result: str = simulate_external_price_sync_for_item(item.id)
24+
25+
assert result == "mocked result"
26+
mock_sync.assert_called_once_with(item.id)
27+
28+
29+
@pytest.mark.django_db
30+
def test_hourly_external_price_sync(mocker: MagicMock) -> None:
31+
"""Test the hourly external price synchronization task."""
32+
mock_sync_all: MagicMock = mocker.patch(
33+
"items.tasks.sync_all_items", return_value=3
34+
)
35+
36+
result: str = hourly_external_price_sync()
37+
38+
assert result == "Updated external_price for 3 items."
39+
mock_sync_all.assert_called_once_with()
40+
41+
42+
@pytest.mark.django_db
43+
def test_sync_item_by_id_updates_price() -> None:
44+
"""Test that the external_price is updated for a specific item."""
45+
item: Item = Item.objects.create(
46+
name="Item1",
47+
price=Decimal("50.00"),
48+
external_price=Decimal("50.00"),
49+
)
50+
51+
result: str = simulate_external_price_sync_for_item(item.id)
52+
item.refresh_from_db()
53+
54+
assert "updated to" in result
55+
assert item.external_price != Decimal("50.00")
56+
57+
58+
@pytest.mark.django_db
59+
def test_sync_all_items_updates_all() -> None:
60+
"""Test that all items are updated."""
61+
items: List[Item] = [
62+
Item.objects.create(
63+
name=f"Item{i}",
64+
price=Decimal("100.00"),
65+
external_price=Decimal("100.00"),
66+
)
67+
for i in range(5)
68+
]
69+
70+
count: int = sync_all_items(batch_size=2)
71+
72+
assert count == len(items)
73+
for item in Item.objects.all():
74+
assert item.external_price is not None

src/items/utils/__init__.py

Whitespace-only changes.

src/items/utils/price_sync.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import random
2+
import time
3+
from decimal import ROUND_HALF_UP, Decimal
4+
from typing import Iterator, List
5+
6+
from django.db import transaction
7+
8+
from items.models import Item
9+
10+
11+
def simulate_external_price(base_price: Decimal) -> Decimal:
12+
"""
13+
Generate a simulated external price with ±10% variation.
14+
"""
15+
variation = random.uniform(-0.10, 0.10)
16+
new_price = base_price * Decimal(1 + variation)
17+
return new_price.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
18+
19+
20+
def chunked(iterable: List[Item], size: int) -> Iterator[List[Item]]:
21+
"""
22+
Yield successive chunks from a list of Items.
23+
"""
24+
for i in range(0, len(iterable), size):
25+
yield iterable[i : i + size]
26+
27+
28+
def sync_item_by_id(item_id: int, simulate_delay: bool = True) -> str:
29+
"""
30+
Sync external_price for a single Item by ID.
31+
Optionally simulate network/API delay.
32+
"""
33+
try:
34+
with transaction.atomic():
35+
item = Item.objects.select_for_update().get(pk=item_id)
36+
37+
if simulate_delay:
38+
time.sleep(random.uniform(0.1, 0.5))
39+
40+
item.external_price = simulate_external_price(item.price)
41+
item.save()
42+
43+
return f"External price for '{item.name}' updated to {item.external_price}"
44+
45+
except Item.DoesNotExist:
46+
return f"Item with ID {item_id} does not exist."
47+
48+
49+
def sync_all_items(batch_size: int = 500) -> int:
50+
"""
51+
Update external_price for all Items in the database in batches.
52+
Returns the number of updated items.
53+
"""
54+
updated_items: List[Item] = []
55+
56+
for item in Item.objects.all().iterator():
57+
item.external_price = simulate_external_price(item.price)
58+
updated_items.append(item)
59+
60+
for batch in chunked(updated_items, batch_size):
61+
with transaction.atomic():
62+
Item.objects.bulk_update(batch, ["external_price"])
63+
64+
return len(updated_items)

0 commit comments

Comments
 (0)