Coverage for apps / recipes / tests.py: 0%
254 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 00:40 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 00:40 +0000
1"""
2Tests for recipe user features: collections, favorites, and history.
3"""
5import json
7from django.test import TestCase, Client
9from apps.profiles.models import Profile
10from apps.recipes.models import (
11 Recipe,
12 RecipeCollection,
13 RecipeCollectionItem,
14 RecipeFavorite,
15 RecipeViewHistory,
16)
19class BaseTestCase(TestCase):
20 """Base test case with common setup."""
22 def setUp(self):
23 self.client = Client()
24 self.profile = Profile.objects.create(
25 name='Test User',
26 avatar_color='#d97850',
27 )
28 self.recipe = Recipe.objects.create(
29 profile=self.profile,
30 title='Chocolate Chip Cookies',
31 host='example.com',
32 canonical_url='https://example.com/cookies',
33 ingredients=['flour', 'sugar', 'chocolate chips'],
34 instructions=['Mix ingredients', 'Bake at 350F'],
35 )
36 self.recipe2 = Recipe.objects.create(
37 profile=self.profile,
38 title='Vanilla Cake',
39 host='example.com',
40 canonical_url='https://example.com/cake',
41 ingredients=['flour', 'sugar', 'vanilla'],
42 instructions=['Mix', 'Bake'],
43 )
44 # Select profile in session
45 session = self.client.session
46 session['profile_id'] = self.profile.id
47 session.save()
50class CollectionTests(BaseTestCase):
51 """Tests for collection CRUD operations."""
53 def test_list_collections_empty(self):
54 """List collections returns empty list initially."""
55 response = self.client.get('/api/collections/')
56 self.assertEqual(response.status_code, 200)
57 self.assertEqual(json.loads(response.content), [])
59 def test_create_collection(self):
60 """Create a new collection."""
61 response = self.client.post(
62 '/api/collections/',
63 data=json.dumps({'name': 'Desserts', 'description': 'Sweet treats'}),
64 content_type='application/json',
65 )
66 self.assertEqual(response.status_code, 201)
67 data = json.loads(response.content)
68 self.assertEqual(data['name'], 'Desserts')
69 self.assertEqual(data['description'], 'Sweet treats')
70 self.assertEqual(data['recipe_count'], 0)
72 def test_create_collection_duplicate_name(self):
73 """Creating a collection with duplicate name fails."""
74 RecipeCollection.objects.create(
75 profile=self.profile,
76 name='Desserts',
77 )
78 response = self.client.post(
79 '/api/collections/',
80 data=json.dumps({'name': 'Desserts'}),
81 content_type='application/json',
82 )
83 self.assertEqual(response.status_code, 400)
85 def test_list_collections(self):
86 """List collections returns existing collections."""
87 RecipeCollection.objects.create(profile=self.profile, name='Desserts')
88 RecipeCollection.objects.create(profile=self.profile, name='Quick Meals')
90 response = self.client.get('/api/collections/')
91 self.assertEqual(response.status_code, 200)
92 data = json.loads(response.content)
93 self.assertEqual(len(data), 2)
95 def test_get_collection(self):
96 """Get a single collection with its recipes."""
97 collection = RecipeCollection.objects.create(
98 profile=self.profile,
99 name='Baking',
100 description='Baked goods',
101 )
102 RecipeCollectionItem.objects.create(
103 collection=collection,
104 recipe=self.recipe,
105 order=1,
106 )
108 response = self.client.get(f'/api/collections/{collection.id}/')
109 self.assertEqual(response.status_code, 200)
110 data = json.loads(response.content)
111 self.assertEqual(data['name'], 'Baking')
112 self.assertEqual(len(data['recipes']), 1)
113 self.assertEqual(data['recipes'][0]['recipe']['title'], 'Chocolate Chip Cookies')
115 def test_get_collection_not_found(self):
116 """Getting a non-existent collection returns 404."""
117 response = self.client.get('/api/collections/999/')
118 self.assertEqual(response.status_code, 404)
120 def test_update_collection(self):
121 """Update a collection's name and description."""
122 collection = RecipeCollection.objects.create(
123 profile=self.profile,
124 name='Desserts',
125 )
127 response = self.client.put(
128 f'/api/collections/{collection.id}/',
129 data=json.dumps({'name': 'Sweet Desserts', 'description': 'Updated'}),
130 content_type='application/json',
131 )
132 self.assertEqual(response.status_code, 200)
133 data = json.loads(response.content)
134 self.assertEqual(data['name'], 'Sweet Desserts')
135 self.assertEqual(data['description'], 'Updated')
137 def test_delete_collection(self):
138 """Delete a collection."""
139 collection = RecipeCollection.objects.create(
140 profile=self.profile,
141 name='To Delete',
142 )
144 response = self.client.delete(f'/api/collections/{collection.id}/')
145 self.assertEqual(response.status_code, 204)
146 self.assertFalse(RecipeCollection.objects.filter(id=collection.id).exists())
148 def test_add_recipe_to_collection(self):
149 """Add a recipe to a collection."""
150 collection = RecipeCollection.objects.create(
151 profile=self.profile,
152 name='Baking',
153 )
155 response = self.client.post(
156 f'/api/collections/{collection.id}/recipes/',
157 data=json.dumps({'recipe_id': self.recipe.id}),
158 content_type='application/json',
159 )
160 self.assertEqual(response.status_code, 201)
161 data = json.loads(response.content)
162 self.assertEqual(data['recipe']['title'], 'Chocolate Chip Cookies')
163 self.assertEqual(data['order'], 1)
165 def test_add_recipe_to_collection_increments_order(self):
166 """Adding recipes to a collection increments their order."""
167 collection = RecipeCollection.objects.create(
168 profile=self.profile,
169 name='Baking',
170 )
171 RecipeCollectionItem.objects.create(
172 collection=collection,
173 recipe=self.recipe,
174 order=1,
175 )
177 response = self.client.post(
178 f'/api/collections/{collection.id}/recipes/',
179 data=json.dumps({'recipe_id': self.recipe2.id}),
180 content_type='application/json',
181 )
182 self.assertEqual(response.status_code, 201)
183 data = json.loads(response.content)
184 self.assertEqual(data['order'], 2)
186 def test_add_duplicate_recipe_to_collection(self):
187 """Adding the same recipe twice fails."""
188 collection = RecipeCollection.objects.create(
189 profile=self.profile,
190 name='Baking',
191 )
192 RecipeCollectionItem.objects.create(
193 collection=collection,
194 recipe=self.recipe,
195 order=1,
196 )
198 response = self.client.post(
199 f'/api/collections/{collection.id}/recipes/',
200 data=json.dumps({'recipe_id': self.recipe.id}),
201 content_type='application/json',
202 )
203 self.assertEqual(response.status_code, 400)
205 def test_remove_recipe_from_collection(self):
206 """Remove a recipe from a collection."""
207 collection = RecipeCollection.objects.create(
208 profile=self.profile,
209 name='Baking',
210 )
211 RecipeCollectionItem.objects.create(
212 collection=collection,
213 recipe=self.recipe,
214 order=1,
215 )
217 response = self.client.delete(
218 f'/api/collections/{collection.id}/recipes/{self.recipe.id}/'
219 )
220 self.assertEqual(response.status_code, 204)
221 self.assertFalse(
222 RecipeCollectionItem.objects.filter(
223 collection=collection, recipe=self.recipe
224 ).exists()
225 )
227 def test_collection_isolation_between_profiles(self):
228 """Collections are isolated between profiles."""
229 other_profile = Profile.objects.create(
230 name='Other User',
231 avatar_color='#8fae6f',
232 )
233 other_collection = RecipeCollection.objects.create(
234 profile=other_profile,
235 name='Other Collection',
236 )
238 # Should not see other profile's collection
239 response = self.client.get('/api/collections/')
240 data = json.loads(response.content)
241 self.assertEqual(len(data), 0)
243 # Should not be able to access other profile's collection
244 response = self.client.get(f'/api/collections/{other_collection.id}/')
245 self.assertEqual(response.status_code, 404)
248class FavoriteTests(BaseTestCase):
249 """Tests for favorites functionality."""
251 def test_list_favorites_empty(self):
252 """List favorites returns empty list initially."""
253 response = self.client.get('/api/favorites/')
254 self.assertEqual(response.status_code, 200)
255 self.assertEqual(json.loads(response.content), [])
257 def test_add_favorite(self):
258 """Add a recipe to favorites."""
259 response = self.client.post(
260 '/api/favorites/',
261 data=json.dumps({'recipe_id': self.recipe.id}),
262 content_type='application/json',
263 )
264 self.assertEqual(response.status_code, 201)
265 data = json.loads(response.content)
266 self.assertEqual(data['recipe']['title'], 'Chocolate Chip Cookies')
268 def test_add_duplicate_favorite(self):
269 """Adding the same recipe twice fails."""
270 RecipeFavorite.objects.create(
271 profile=self.profile,
272 recipe=self.recipe,
273 )
275 response = self.client.post(
276 '/api/favorites/',
277 data=json.dumps({'recipe_id': self.recipe.id}),
278 content_type='application/json',
279 )
280 self.assertEqual(response.status_code, 400)
282 def test_list_favorites(self):
283 """List favorites returns favorited recipes."""
284 RecipeFavorite.objects.create(profile=self.profile, recipe=self.recipe)
285 RecipeFavorite.objects.create(profile=self.profile, recipe=self.recipe2)
287 response = self.client.get('/api/favorites/')
288 self.assertEqual(response.status_code, 200)
289 data = json.loads(response.content)
290 self.assertEqual(len(data), 2)
292 def test_remove_favorite(self):
293 """Remove a recipe from favorites."""
294 RecipeFavorite.objects.create(
295 profile=self.profile,
296 recipe=self.recipe,
297 )
299 response = self.client.delete(f'/api/favorites/{self.recipe.id}/')
300 self.assertEqual(response.status_code, 204)
301 self.assertFalse(
302 RecipeFavorite.objects.filter(
303 profile=self.profile, recipe=self.recipe
304 ).exists()
305 )
307 def test_remove_favorite_not_found(self):
308 """Removing a non-favorited recipe returns 404."""
309 response = self.client.delete(f'/api/favorites/{self.recipe.id}/')
310 self.assertEqual(response.status_code, 404)
312 def test_favorites_isolation_between_profiles(self):
313 """Favorites are isolated between profiles."""
314 other_profile = Profile.objects.create(
315 name='Other User',
316 avatar_color='#8fae6f',
317 )
318 RecipeFavorite.objects.create(
319 profile=other_profile,
320 recipe=self.recipe,
321 )
323 # Should not see other profile's favorites
324 response = self.client.get('/api/favorites/')
325 data = json.loads(response.content)
326 self.assertEqual(len(data), 0)
329class HistoryTests(BaseTestCase):
330 """Tests for view history functionality."""
332 def test_list_history_empty(self):
333 """List history returns empty list initially."""
334 response = self.client.get('/api/history/')
335 self.assertEqual(response.status_code, 200)
336 self.assertEqual(json.loads(response.content), [])
338 def test_record_view(self):
339 """Record a recipe view."""
340 response = self.client.post(
341 '/api/history/',
342 data=json.dumps({'recipe_id': self.recipe.id}),
343 content_type='application/json',
344 )
345 self.assertEqual(response.status_code, 201)
346 data = json.loads(response.content)
347 self.assertEqual(data['recipe']['title'], 'Chocolate Chip Cookies')
349 def test_record_view_updates_timestamp(self):
350 """Recording the same view updates the timestamp."""
351 self.client.post(
352 '/api/history/',
353 data=json.dumps({'recipe_id': self.recipe.id}),
354 content_type='application/json',
355 )
357 # Record again - should return 200, not 201
358 response = self.client.post(
359 '/api/history/',
360 data=json.dumps({'recipe_id': self.recipe.id}),
361 content_type='application/json',
362 )
363 self.assertEqual(response.status_code, 200)
365 # Should still only have one history entry
366 self.assertEqual(
367 RecipeViewHistory.objects.filter(profile=self.profile).count(), 1
368 )
370 def test_list_history(self):
371 """List history returns viewed recipes."""
372 RecipeViewHistory.objects.create(profile=self.profile, recipe=self.recipe)
373 RecipeViewHistory.objects.create(profile=self.profile, recipe=self.recipe2)
375 response = self.client.get('/api/history/')
376 self.assertEqual(response.status_code, 200)
377 data = json.loads(response.content)
378 self.assertEqual(len(data), 2)
380 def test_list_history_with_limit(self):
381 """List history respects limit parameter."""
382 RecipeViewHistory.objects.create(profile=self.profile, recipe=self.recipe)
383 RecipeViewHistory.objects.create(profile=self.profile, recipe=self.recipe2)
385 response = self.client.get('/api/history/?limit=1')
386 self.assertEqual(response.status_code, 200)
387 data = json.loads(response.content)
388 self.assertEqual(len(data), 1)
390 def test_clear_history(self):
391 """Clear all view history."""
392 RecipeViewHistory.objects.create(profile=self.profile, recipe=self.recipe)
393 RecipeViewHistory.objects.create(profile=self.profile, recipe=self.recipe2)
395 response = self.client.delete('/api/history/')
396 self.assertEqual(response.status_code, 204)
397 self.assertEqual(
398 RecipeViewHistory.objects.filter(profile=self.profile).count(), 0
399 )
401 def test_history_isolation_between_profiles(self):
402 """History is isolated between profiles."""
403 other_profile = Profile.objects.create(
404 name='Other User',
405 avatar_color='#8fae6f',
406 )
407 RecipeViewHistory.objects.create(
408 profile=other_profile,
409 recipe=self.recipe,
410 )
412 # Should not see other profile's history
413 response = self.client.get('/api/history/')
414 data = json.loads(response.content)
415 self.assertEqual(len(data), 0)
418class NoProfileTests(TestCase):
419 """Tests for endpoints when no profile is selected."""
421 def setUp(self):
422 self.client = Client()
423 # Create a profile to own the recipe (not selected in session)
424 self.temp_profile = Profile.objects.create(
425 name='Temp User',
426 avatar_color='#d97850',
427 )
428 self.recipe = Recipe.objects.create(
429 profile=self.temp_profile,
430 title='Test Recipe',
431 host='example.com',
432 canonical_url='https://example.com/recipe',
433 )
435 def test_favorites_requires_profile(self):
436 """Favorites endpoints require a selected profile."""
437 response = self.client.get('/api/favorites/')
438 self.assertEqual(response.status_code, 404)
440 def test_collections_requires_profile(self):
441 """Collections endpoints require a selected profile."""
442 response = self.client.get('/api/collections/')
443 self.assertEqual(response.status_code, 404)
445 def test_history_requires_profile(self):
446 """History endpoints require a selected profile."""
447 response = self.client.get('/api/history/')
448 self.assertEqual(response.status_code, 404)
451class QuantityTidyingTests(TestCase):
452 """Tests for ingredient quantity tidying utilities (QA-029)."""
454 def test_decimal_to_fraction_half(self):
455 """Convert 0.5 to 1/2."""
456 from apps.recipes.utils import decimal_to_fraction
457 self.assertEqual(decimal_to_fraction(0.5), '1/2')
459 def test_decimal_to_fraction_quarter(self):
460 """Convert 0.25 to 1/4."""
461 from apps.recipes.utils import decimal_to_fraction
462 self.assertEqual(decimal_to_fraction(0.25), '1/4')
464 def test_decimal_to_fraction_third(self):
465 """Convert 0.333... to 1/3."""
466 from apps.recipes.utils import decimal_to_fraction
467 self.assertEqual(decimal_to_fraction(0.333), '1/3')
468 self.assertEqual(decimal_to_fraction(0.33), '1/3')
470 def test_decimal_to_fraction_two_thirds(self):
471 """Convert 0.666... to 2/3."""
472 from apps.recipes.utils import decimal_to_fraction
473 self.assertEqual(decimal_to_fraction(0.666), '2/3')
474 self.assertEqual(decimal_to_fraction(0.67), '2/3')
476 def test_decimal_to_fraction_three_quarters(self):
477 """Convert 0.75 to 3/4."""
478 from apps.recipes.utils import decimal_to_fraction
479 self.assertEqual(decimal_to_fraction(0.75), '3/4')
481 def test_decimal_to_fraction_mixed_number(self):
482 """Convert 1.5 to 1 1/2."""
483 from apps.recipes.utils import decimal_to_fraction
484 self.assertEqual(decimal_to_fraction(1.5), '1 1/2')
485 self.assertEqual(decimal_to_fraction(2.25), '2 1/4')
486 self.assertEqual(decimal_to_fraction(1.333), '1 1/3')
488 def test_decimal_to_fraction_whole_number(self):
489 """Whole numbers stay as-is."""
490 from apps.recipes.utils import decimal_to_fraction
491 self.assertEqual(decimal_to_fraction(2.0), '2')
492 self.assertEqual(decimal_to_fraction(3.0), '3')
494 def test_tidy_ingredient_cups(self):
495 """Tidy cup measurements."""
496 from apps.recipes.utils import tidy_ingredient
497 self.assertEqual(tidy_ingredient('0.5 cup sugar'), '1/2 cup sugar')
498 self.assertEqual(tidy_ingredient('1.5 cups flour'), '1 1/2 cups flour')
499 self.assertEqual(tidy_ingredient('0.333 cup milk'), '1/3 cup milk')
501 def test_tidy_ingredient_tablespoons(self):
502 """Tidy tablespoon measurements."""
503 from apps.recipes.utils import tidy_ingredient
504 self.assertEqual(tidy_ingredient('0.5 tablespoon oil'), '1/2 tablespoon oil')
505 self.assertEqual(tidy_ingredient('1.5 tbsp butter'), '1 1/2 tbsp butter')
507 def test_tidy_ingredient_teaspoons(self):
508 """Tidy teaspoon measurements."""
509 from apps.recipes.utils import tidy_ingredient
510 self.assertEqual(tidy_ingredient('0.25 teaspoon salt'), '1/4 teaspoon salt')
511 self.assertEqual(tidy_ingredient('0.5 tsp vanilla'), '1/2 tsp vanilla')
513 def test_tidy_ingredient_keeps_grams(self):
514 """Grams should stay as decimals."""
515 from apps.recipes.utils import tidy_ingredient
516 self.assertEqual(tidy_ingredient('225g butter'), '225 g butter')
517 self.assertEqual(tidy_ingredient('150.5 grams flour'), '150.5 grams flour')
519 def test_tidy_ingredient_keeps_ml(self):
520 """Milliliters should stay as decimals."""
521 from apps.recipes.utils import tidy_ingredient
522 self.assertEqual(tidy_ingredient('250 ml milk'), '250 ml milk')
523 self.assertEqual(tidy_ingredient('37.5 ml water'), '37.5 ml water')
525 def test_tidy_ingredient_countable_items(self):
526 """Countable items get fractions."""
527 from apps.recipes.utils import tidy_ingredient
528 self.assertEqual(tidy_ingredient('1.5 cloves garlic'), '1 1/2 cloves garlic')
529 self.assertEqual(tidy_ingredient('0.5 bunch parsley'), '1/2 bunch parsley')
531 def test_tidy_ingredient_no_unit(self):
532 """Ingredients without recognized units get tidied."""
533 from apps.recipes.utils import tidy_ingredient
534 self.assertEqual(tidy_ingredient('1.5 large eggs'), '1 1/2 large eggs')
535 self.assertEqual(tidy_ingredient('0.5 medium onion'), '1/2 medium onion')
537 def test_tidy_quantities_list(self):
538 """Tidy a list of ingredients."""
539 from apps.recipes.utils import tidy_quantities
540 ingredients = [
541 '0.5 cup sugar',
542 '1.333 cups flour',
543 '225g butter',
544 '0.25 teaspoon salt',
545 ]
546 result = tidy_quantities(ingredients)
547 self.assertEqual(result[0], '1/2 cup sugar')
548 self.assertEqual(result[1], '1 1/3 cups flour')
549 self.assertEqual(result[2], '225 g butter')
550 self.assertEqual(result[3], '1/4 teaspoon salt')
552 def test_tidy_ingredient_empty_string(self):
553 """Empty string returns empty string."""
554 from apps.recipes.utils import tidy_ingredient
555 self.assertEqual(tidy_ingredient(''), '')
557 def test_tidy_ingredient_no_number(self):
558 """Ingredient without number returns as-is."""
559 from apps.recipes.utils import tidy_ingredient
560 self.assertEqual(tidy_ingredient('salt to taste'), 'salt to taste')
561 self.assertEqual(tidy_ingredient('fresh parsley'), 'fresh parsley')