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

1""" 

2Tests for recipe user features: collections, favorites, and history. 

3""" 

4 

5import json 

6 

7from django.test import TestCase, Client 

8 

9from apps.profiles.models import Profile 

10from apps.recipes.models import ( 

11 Recipe, 

12 RecipeCollection, 

13 RecipeCollectionItem, 

14 RecipeFavorite, 

15 RecipeViewHistory, 

16) 

17 

18 

19class BaseTestCase(TestCase): 

20 """Base test case with common setup.""" 

21 

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() 

48 

49 

50class CollectionTests(BaseTestCase): 

51 """Tests for collection CRUD operations.""" 

52 

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), []) 

58 

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) 

71 

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) 

84 

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') 

89 

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) 

94 

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 ) 

107 

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') 

114 

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) 

119 

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 ) 

126 

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') 

136 

137 def test_delete_collection(self): 

138 """Delete a collection.""" 

139 collection = RecipeCollection.objects.create( 

140 profile=self.profile, 

141 name='To Delete', 

142 ) 

143 

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()) 

147 

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 ) 

154 

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) 

164 

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 ) 

176 

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) 

185 

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 ) 

197 

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) 

204 

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 ) 

216 

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 ) 

226 

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 ) 

237 

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) 

242 

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) 

246 

247 

248class FavoriteTests(BaseTestCase): 

249 """Tests for favorites functionality.""" 

250 

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), []) 

256 

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') 

267 

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 ) 

274 

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) 

281 

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) 

286 

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) 

291 

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 ) 

298 

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 ) 

306 

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) 

311 

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 ) 

322 

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) 

327 

328 

329class HistoryTests(BaseTestCase): 

330 """Tests for view history functionality.""" 

331 

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), []) 

337 

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') 

348 

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 ) 

356 

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) 

364 

365 # Should still only have one history entry 

366 self.assertEqual( 

367 RecipeViewHistory.objects.filter(profile=self.profile).count(), 1 

368 ) 

369 

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) 

374 

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) 

379 

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) 

384 

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) 

389 

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) 

394 

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 ) 

400 

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 ) 

411 

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) 

416 

417 

418class NoProfileTests(TestCase): 

419 """Tests for endpoints when no profile is selected.""" 

420 

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 ) 

434 

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) 

439 

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) 

444 

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) 

449 

450 

451class QuantityTidyingTests(TestCase): 

452 """Tests for ingredient quantity tidying utilities (QA-029).""" 

453 

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') 

458 

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') 

463 

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') 

469 

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') 

475 

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') 

480 

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') 

487 

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') 

493 

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') 

500 

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') 

506 

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') 

512 

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') 

518 

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') 

524 

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') 

530 

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') 

536 

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') 

551 

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(''), '') 

556 

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') 

← Back to Dashboard