A small but real Express/Prisma/PostgreSQL recipe API built around one deliberate modeling decision -- ingredients and steps get their own indexed tables with a foreign key back to the recipe instead of a JSON blob -- with ratio math, query composition, and transaction-boundary correctness as the actual engineering problems under test.
Nobody has started this yet — be first.
maxCookingTime filter wrongly excludes recipes at the exact limit
A user browsing "recipes I can make in 30 minutes or less" silently loses every recipe that takes exactly 30 minutes. There's no error, no crash -- the recipe just never shows up. For a recipe discovery product, that's a content-visibility bug: recipes with suspiciously round cooking times (15, 20, 30, 45, 60 minutes -- exactly the numbers people are most likely to search for) are the ones most likely to vanish from filtered results. It under-serves recipe authors (their recipe is invisible under the filter a user would most naturally reach for) and quietly shrinks search results with no way for a user to tell whether the filter is working correctly.
About this project
A Recipe API where a recipe has many ingredients and many steps, and the interesting decision is how that relationship is stored: Ingredient.recipeId and Step.recipeId reference as real foreign keys with , rather than nesting ingredients/steps as JSON inside the recipe row. That one choice is what makes an indexed, case-insensitive query instead of a full-table JSON scan, and what lets a single remove a recipe's children with no application-level cleanup step to forget. Creating a recipe writes the recipe, its ingredients, and its steps as one Prisma nested -- there is no code path that produces a recipe with zero ingredients because its ingredient insert silently failed.
Scaling a recipe up shrinks it, and scaling it down grows it
This is the kind of bug a user discovers mid-dinner-party. Someone doubles a recipe from 4 servings to 8 expecting twice the ingredients, and instead gets half -- 250g of chicken instead of 1000g. If they trust the app and don't sanity-check the numbers, they end up under-provisioned for guests, or (scaling down) drowning a 2-person meal in 4x the ingredients. It erodes trust in the one feature (scale) whose entire value proposition is "do the arithmetic correctly so the user doesn't have to." The README's own curl walkthrough documents the correct output (500g -> 1000g for 4 -> 8 servings) -- the running code no longer matches its own docs.
Recipe.idonDelete: CascadeGET /recipes/search?ingredient=chickenDELETE /recipes/:idcreateThe tickets sit on top of that foundation and are all real engineering problems, not busywork: an inclusive-vs-exclusive boundary on the maxCookingTime filter, a scale endpoint whose servings ratio has its numerator and denominator swapped (scaling up shrinks quantities instead of growing them), a mode: 'insensitive' option quietly missing from the ingredient-search filter so a documented case-insensitive feature only works when the query happens to match the stored casing, pagination that has to compose correctly with existing filtering rather than replace it (total/totalPages computed from the same where clause as the paginated query), and a sortBy/order allow-list that resists the tempting-but-unsafe shortcut of building orderBy straight from unvalidated client input.
The centerpiece is a real data-loss bug: PUT /recipes/:id deletes the recipe's existing ingredients and steps through the plain prisma client, then tries to recreate them through the tx client inside a prisma.$transaction(...) block -- except the deletes were never inside that transaction to begin with. When the recreate fails for any reason (a duplicate stepNumber, which violates a real unique constraint), the recreate rolls back but the deletes, having already committed as independent statements, do not. The request fails and the response looks like a clean error -- but the recipe is left with zero ingredients and zero steps anyway. It is invisible on the happy path and only shows itself when the second half of a two-part write fails, which is exactly why a dedicated test for the failure path (not just the success path) is what catches it.
Seven tickets -- four bugs, three enhancements -- are deliberately injected into an otherwise fully working, Jest-and-Supertest-tested codebase that runs its suite against a real PostgreSQL database, not a mock. Each ticket has a dedicated test under practice-tickets/tests/ that fails for its own specific, documented reason against the code as shipped, and goes green once the fix is correct.