From e2599b81f471cf1e76cd64552d6c0cdc913a2774 Mon Sep 17 00:00:00 2001 From: Paul Huliganga Date: Tue, 24 Mar 2026 00:13:58 -0400 Subject: [PATCH] feat(api): implement recipe CRUD API endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Recipe type definitions (Recipe, CreateRecipeInput, UpdateRecipeInput) - Implement RecipeRepository for database operations (CRUD + search) - Implement RecipeService with business logic validation - Create Express routes with Zod validation schemas - Add database initialization module with auto-save functionality - Implement comprehensive integration tests (16 tests, all passing) - Support pagination, search filtering, and proper error handling - Follow layered architecture pattern (Routes → Services → Repositories) All tests passing (16/16). TypeScript strict mode enabled. --- package-lock.json | 455 ++++++++++++++++++- package.json | 8 +- src/backend/db/database.ts | 69 +++ src/backend/index.ts | 77 +++- src/backend/repositories/RecipeRepository.ts | 199 ++++++++ src/backend/routes/recipes.ts | 261 +++++++++++ src/backend/services/RecipeService.ts | 73 +++ src/backend/tests/recipes.test.ts | 291 ++++++++++++ src/backend/types/recipe.ts | 49 ++ 9 files changed, 1466 insertions(+), 16 deletions(-) create mode 100644 src/backend/db/database.ts create mode 100644 src/backend/repositories/RecipeRepository.ts create mode 100644 src/backend/routes/recipes.ts create mode 100644 src/backend/services/RecipeService.ts create mode 100644 src/backend/tests/recipes.test.ts create mode 100644 src/backend/types/recipe.ts diff --git a/package-lock.json b/package-lock.json index 1b6452b..df363bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,11 @@ "zod": "^3.22.4" }, "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.11.5", + "@types/sql.js": "^1.4.10", + "@types/supertest": "^6.0.2", + "supertest": "^6.3.4", "ts-node": "^10.9.2", "typescript": "^5.3.3", "vitest": "^1.2.3" @@ -463,6 +468,29 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", @@ -848,6 +876,41 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/emscripten": { + "version": "1.41.5", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", + "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -855,15 +918,143 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/node": { - "version": "25.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "undici-types": "~7.18.0" + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/sql.js": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/sql.js/-/sql.js-1.4.10.tgz", + "integrity": "sha512-E7XnsrWm01Uvp0/0+iRI9ZwO/BvKyiiHUpcVKJenVVH2pUdZndsgQ5BWXNxKaEO+bkKbvU29Ky9o21juMip1ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/emscripten": "*", + "@types/node": "*" + } + }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" } }, "node_modules/@vitest/expect": { @@ -1005,6 +1196,13 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", @@ -1015,6 +1213,13 @@ "node": "*" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -1119,6 +1324,29 @@ "node": "*" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/confbox": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", @@ -1162,6 +1390,13 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -1206,6 +1441,16 @@ "node": ">=6" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1225,6 +1470,17 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -1304,6 +1560,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1438,6 +1710,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -1456,6 +1735,39 @@ "node": ">= 0.8" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.5.tgz", + "integrity": "sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0", + "qs": "^6.11.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1582,6 +1894,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -1918,6 +2246,16 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/onetime": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", @@ -2192,6 +2530,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", @@ -2423,6 +2774,82 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/superagent": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz", + "integrity": "sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==", + "deprecated": "Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.4", + "debug": "^4.3.4", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^2.1.2", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.0", + "semver": "^7.3.8" + }, + "engines": { + "node": ">=6.4.0 <13 || >=14" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "6.3.4", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-6.3.4.tgz", + "integrity": "sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==", + "deprecated": "Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net", + "dev": true, + "license": "MIT", + "dependencies": { + "methods": "^1.1.2", + "superagent": "^8.1.2" + }, + "engines": { + "node": ">=6.4.0" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2548,12 +2975,11 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/unpipe": { "version": "1.0.0", @@ -2821,6 +3247,13 @@ "node": ">=8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index c78621c..615cea4 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "scripts": { "dev": "ts-node src/backend/index.ts", "build": "tsc", - "test": "vitest", + "test": "vitest run", + "test:watch": "vitest", "migrate": "ts-node-esm src/backend/db/migrate.ts" }, "dependencies": { @@ -16,6 +17,11 @@ "zod": "^3.22.4" }, "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.11.5", + "@types/sql.js": "^1.4.10", + "@types/supertest": "^6.0.2", + "supertest": "^6.3.4", "ts-node": "^10.9.2", "typescript": "^5.3.3", "vitest": "^1.2.3" diff --git a/src/backend/db/database.ts b/src/backend/db/database.ts new file mode 100644 index 0000000..70204ad --- /dev/null +++ b/src/backend/db/database.ts @@ -0,0 +1,69 @@ +import initSqlJs, { Database } from 'sql.js'; +import { readFileSync, existsSync, mkdirSync } from 'fs'; +import { dirname } from 'path'; + +let dbInstance: Database | null = null; + +/** + * Initialize and return the database instance + */ +export async function getDatabase(dbPath: string = 'data/recipes.db'): Promise { + if (dbInstance) { + return dbInstance; + } + + const SQL = await initSqlJs(); + + // Load existing database or create new one + if (existsSync(dbPath)) { + const buffer = readFileSync(dbPath); + dbInstance = new SQL.Database(buffer); + } else { + // Create new empty database + dbInstance = new SQL.Database(); + + // Apply schema + const schemaPath = new URL('../db/schema.sql', import.meta.url).pathname; + const schema = readFileSync(schemaPath, 'utf-8'); + dbInstance.exec(schema); + + // Ensure data directory exists + const dir = dirname(dbPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + } + + return dbInstance; +} + +/** + * Save the database to disk + */ +export function saveDatabase(dbPath: string = 'data/recipes.db'): void { + if (!dbInstance) { + throw new Error('Database not initialized'); + } + + const data = dbInstance.export(); + const buffer = Buffer.from(data); + + // Ensure data directory exists + const dir = dirname(dbPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + // Write to disk + require('fs').writeFileSync(dbPath, buffer); +} + +/** + * Close the database connection + */ +export function closeDatabase(): void { + if (dbInstance) { + dbInstance.close(); + dbInstance = null; + } +} diff --git a/src/backend/index.ts b/src/backend/index.ts index 2f971c2..514857b 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -1,12 +1,81 @@ import express from 'express'; +import { getDatabase, saveDatabase } from './db/database.js'; +import { createRecipeRoutes } from './routes/recipes.js'; const app = express(); const port = 3000; +const DB_PATH = 'data/recipes.db'; +// Middleware +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +// CORS headers for local development +app.use((req, res, next) => { + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.header('Access-Control-Allow-Headers', 'Content-Type'); + + if (req.method === 'OPTIONS') { + res.sendStatus(200); + } else { + next(); + } +}); + +// Health check endpoint app.get('/', (req, res) => { - res.send('Hello World from Recipe Manager backend!'); + res.json({ + success: true, + message: 'Recipe Manager API is running', + version: '0.1.0', + }); }); -app.listen(port, () => { - console.log(`Server running on http://localhost:${port}`); -}); +// Initialize database and routes +async function startServer() { + try { + const db = await getDatabase(DB_PATH); + + // Mount recipe routes + app.use('/api/recipes', createRecipeRoutes(db)); + + // Save database periodically (every 5 seconds) + setInterval(() => { + try { + saveDatabase(DB_PATH); + } catch (error) { + console.error('Error saving database:', error); + } + }, 5000); + + // Save database on exit + process.on('SIGINT', () => { + console.log('\nSaving database before exit...'); + saveDatabase(DB_PATH); + process.exit(0); + }); + + process.on('SIGTERM', () => { + console.log('\nSaving database before exit...'); + saveDatabase(DB_PATH); + process.exit(0); + }); + + app.listen(port, () => { + console.log(`✓ Recipe Manager API running on http://localhost:${port}`); + console.log(`✓ Database: ${DB_PATH}`); + console.log(`✓ Endpoints:`); + console.log(` GET /api/recipes - List recipes`); + console.log(` GET /api/recipes/:id - Get recipe by ID`); + console.log(` POST /api/recipes - Create recipe`); + console.log(` PUT /api/recipes/:id - Update recipe`); + console.log(` DELETE /api/recipes/:id - Delete recipe`); + }); + } catch (error) { + console.error('Failed to start server:', error); + process.exit(1); + } +} + +startServer(); diff --git a/src/backend/repositories/RecipeRepository.ts b/src/backend/repositories/RecipeRepository.ts new file mode 100644 index 0000000..4eb8565 --- /dev/null +++ b/src/backend/repositories/RecipeRepository.ts @@ -0,0 +1,199 @@ +import type { Database, SqlValue } from 'sql.js'; +import type { Recipe, CreateRecipeInput, UpdateRecipeInput, RecipeFilters } from '../types/recipe.js'; + +/** + * RecipeRepository handles all database operations for recipes. + */ +export class RecipeRepository { + constructor(private db: Database) {} + + /** + * Find all recipes with optional filtering and pagination + */ + findAll(filters: RecipeFilters = {}): Recipe[] { + const { search, offset = 0, limit = 50 } = filters; + + let sql = 'SELECT * FROM recipes'; + const params: SqlValue[] = []; + + if (search) { + sql += ' WHERE title LIKE ? OR description LIKE ? OR ingredients LIKE ?'; + const searchPattern = `%${search}%`; + params.push(searchPattern, searchPattern, searchPattern); + } + + sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'; + params.push(limit, offset); + + const result = this.db.exec(sql, params); + if (!result.length) return []; + + return this.rowsToRecipes(result[0]); + } + + /** + * Find a recipe by ID + */ + findById(id: number): Recipe | null { + const result = this.db.exec('SELECT * FROM recipes WHERE id = ?', [id]); + if (!result.length || !result[0].values.length) return null; + + const recipes = this.rowsToRecipes(result[0]); + return recipes[0] || null; + } + + /** + * Create a new recipe + */ + create(input: CreateRecipeInput): Recipe { + const now = Math.floor(Date.now() / 1000); + + const sql = ` + INSERT INTO recipes ( + title, description, ingredients, instructions, + source_url, notes, servings, prep_time_minutes, + cook_time_minutes, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `; + + this.db.run(sql, [ + input.title, + input.description || null, + JSON.stringify(input.ingredients), + JSON.stringify(input.instructions), + input.source_url || null, + input.notes || null, + input.servings || null, + input.prep_time_minutes || null, + input.cook_time_minutes || null, + now, + now, + ]); + + // Get the last inserted ID + const result = this.db.exec('SELECT last_insert_rowid() as id'); + const id = result[0].values[0][0] as number; + + return this.findById(id)!; + } + + /** + * Update an existing recipe + */ + update(id: number, input: UpdateRecipeInput): Recipe | null { + const existing = this.findById(id); + if (!existing) return null; + + const now = Math.floor(Date.now() / 1000); + const fields: string[] = []; + const params: SqlValue[] = []; + + // Build dynamic UPDATE query based on provided fields + if (input.title !== undefined) { + fields.push('title = ?'); + params.push(input.title); + } + if (input.description !== undefined) { + fields.push('description = ?'); + params.push(input.description); + } + if (input.ingredients !== undefined) { + fields.push('ingredients = ?'); + params.push(JSON.stringify(input.ingredients)); + } + if (input.instructions !== undefined) { + fields.push('instructions = ?'); + params.push(JSON.stringify(input.instructions)); + } + if (input.source_url !== undefined) { + fields.push('source_url = ?'); + params.push(input.source_url); + } + if (input.notes !== undefined) { + fields.push('notes = ?'); + params.push(input.notes); + } + if (input.servings !== undefined) { + fields.push('servings = ?'); + params.push(input.servings); + } + if (input.prep_time_minutes !== undefined) { + fields.push('prep_time_minutes = ?'); + params.push(input.prep_time_minutes); + } + if (input.cook_time_minutes !== undefined) { + fields.push('cook_time_minutes = ?'); + params.push(input.cook_time_minutes); + } + + // Always update updated_at + fields.push('updated_at = ?'); + params.push(now); + + // Add ID to params for WHERE clause + params.push(id); + + const sql = `UPDATE recipes SET ${fields.join(', ')} WHERE id = ?`; + this.db.run(sql, params); + + return this.findById(id); + } + + /** + * Delete a recipe + */ + delete(id: number): boolean { + const existing = this.findById(id); + if (!existing) return false; + + this.db.run('DELETE FROM recipes WHERE id = ?', [id]); + return true; + } + + /** + * Count total recipes (for pagination) + */ + count(filters: RecipeFilters = {}): number { + const { search } = filters; + + let sql = 'SELECT COUNT(*) as count FROM recipes'; + const params: SqlValue[] = []; + + if (search) { + sql += ' WHERE title LIKE ? OR description LIKE ? OR ingredients LIKE ?'; + const searchPattern = `%${search}%`; + params.push(searchPattern, searchPattern, searchPattern); + } + + const result = this.db.exec(sql, params); + return result[0].values[0][0] as number; + } + + /** + * Convert sql.js result rows to Recipe objects + */ + private rowsToRecipes(result: { columns: string[]; values: SqlValue[][] }): Recipe[] { + return result.values.map((row) => { + const recipe: Record = {}; + result.columns.forEach((col, idx) => { + recipe[col] = row[idx]; + }); + + return { + id: recipe.id as number, + title: recipe.title as string, + description: recipe.description as string | null, + ingredients: JSON.parse(recipe.ingredients as string) as string[], + instructions: JSON.parse(recipe.instructions as string) as string[], + source_url: recipe.source_url as string | null, + notes: recipe.notes as string | null, + servings: recipe.servings as number | null, + prep_time_minutes: recipe.prep_time_minutes as number | null, + cook_time_minutes: recipe.cook_time_minutes as number | null, + created_at: recipe.created_at as number, + updated_at: recipe.updated_at as number, + last_cooked_at: recipe.last_cooked_at as number | null, + }; + }); + } +} diff --git a/src/backend/routes/recipes.ts b/src/backend/routes/recipes.ts new file mode 100644 index 0000000..17b12e2 --- /dev/null +++ b/src/backend/routes/recipes.ts @@ -0,0 +1,261 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import type { Database } from 'sql.js'; +import { RecipeService } from '../services/RecipeService.js'; + +/** + * Zod validation schemas + */ +const createRecipeSchema = z.object({ + title: z.string().min(1, 'Title is required'), + description: z.string().optional(), + ingredients: z.array(z.string()).min(1, 'At least one ingredient is required'), + instructions: z.array(z.string()).min(1, 'At least one instruction is required'), + source_url: z.string().url().optional().or(z.literal('')), + notes: z.string().optional(), + servings: z.number().int().positive().optional(), + prep_time_minutes: z.number().int().positive().optional(), + cook_time_minutes: z.number().int().positive().optional(), +}); + +const updateRecipeSchema = z.object({ + title: z.string().min(1).optional(), + description: z.string().optional().nullable(), + ingredients: z.array(z.string()).min(1).optional(), + instructions: z.array(z.string()).min(1).optional(), + source_url: z.string().url().optional().nullable().or(z.literal('')), + notes: z.string().optional().nullable(), + servings: z.number().int().positive().optional().nullable(), + prep_time_minutes: z.number().int().positive().optional().nullable(), + cook_time_minutes: z.number().int().positive().optional().nullable(), +}); + +const recipeFiltersSchema = z.object({ + search: z.string().optional(), + offset: z.coerce.number().int().nonnegative().optional(), + limit: z.coerce.number().int().positive().max(100).optional(), +}); + +/** + * Create recipe routes + */ +export function createRecipeRoutes(db: Database): Router { + const router = Router(); + const recipeService = new RecipeService(db); + + /** + * GET /api/recipes + * List recipes with optional filtering + */ + router.get('/', (req, res) => { + try { + const filters = recipeFiltersSchema.parse(req.query); + const result = recipeService.list(filters); + + res.json({ + success: true, + data: result.recipes, + meta: { + total: result.total, + offset: filters.offset || 0, + limit: filters.limit || 50, + }, + error: null, + }); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ + success: false, + data: null, + error: error.errors, + }); + } else { + res.status(500).json({ + success: false, + data: null, + error: 'Internal server error', + }); + } + } + }); + + /** + * GET /api/recipes/:id + * Get a single recipe by ID + */ + router.get('/:id', (req, res) => { + try { + const id = parseInt(req.params.id, 10); + + if (isNaN(id)) { + res.status(400).json({ + success: false, + data: null, + error: 'Invalid recipe ID', + }); + return; + } + + const recipe = recipeService.get(id); + + if (!recipe) { + res.status(404).json({ + success: false, + data: null, + error: 'Recipe not found', + }); + return; + } + + res.json({ + success: true, + data: recipe, + error: null, + }); + } catch (error) { + res.status(500).json({ + success: false, + data: null, + error: 'Internal server error', + }); + } + }); + + /** + * POST /api/recipes + * Create a new recipe + */ + router.post('/', (req, res) => { + try { + const data = createRecipeSchema.parse(req.body); + const recipe = recipeService.create(data); + + res.status(201).json({ + success: true, + data: recipe, + error: null, + }); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ + success: false, + data: null, + error: error.errors, + }); + } else if (error instanceof Error) { + res.status(400).json({ + success: false, + data: null, + error: error.message, + }); + } else { + res.status(500).json({ + success: false, + data: null, + error: 'Internal server error', + }); + } + } + }); + + /** + * PUT /api/recipes/:id + * Update an existing recipe + */ + router.put('/:id', (req, res) => { + try { + const id = parseInt(req.params.id, 10); + + if (isNaN(id)) { + res.status(400).json({ + success: false, + data: null, + error: 'Invalid recipe ID', + }); + return; + } + + const data = updateRecipeSchema.parse(req.body); + const recipe = recipeService.update(id, data); + + if (!recipe) { + res.status(404).json({ + success: false, + data: null, + error: 'Recipe not found', + }); + return; + } + + res.json({ + success: true, + data: recipe, + error: null, + }); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ + success: false, + data: null, + error: error.errors, + }); + } else if (error instanceof Error) { + res.status(400).json({ + success: false, + data: null, + error: error.message, + }); + } else { + res.status(500).json({ + success: false, + data: null, + error: 'Internal server error', + }); + } + } + }); + + /** + * DELETE /api/recipes/:id + * Delete a recipe + */ + router.delete('/:id', (req, res) => { + try { + const id = parseInt(req.params.id, 10); + + if (isNaN(id)) { + res.status(400).json({ + success: false, + data: null, + error: 'Invalid recipe ID', + }); + return; + } + + const deleted = recipeService.delete(id); + + if (!deleted) { + res.status(404).json({ + success: false, + data: null, + error: 'Recipe not found', + }); + return; + } + + res.json({ + success: true, + data: { id }, + error: null, + }); + } catch (error) { + res.status(500).json({ + success: false, + data: null, + error: 'Internal server error', + }); + } + }); + + return router; +} diff --git a/src/backend/services/RecipeService.ts b/src/backend/services/RecipeService.ts new file mode 100644 index 0000000..b714ac4 --- /dev/null +++ b/src/backend/services/RecipeService.ts @@ -0,0 +1,73 @@ +import type { Database } from 'sql.js'; +import { RecipeRepository } from '../repositories/RecipeRepository.js'; +import type { Recipe, CreateRecipeInput, UpdateRecipeInput, RecipeFilters } from '../types/recipe.js'; + +/** + * RecipeService contains business logic for recipe management + */ +export class RecipeService { + private repository: RecipeRepository; + + constructor(db: Database) { + this.repository = new RecipeRepository(db); + } + + /** + * List recipes with optional filtering + */ + list(filters: RecipeFilters = {}): { recipes: Recipe[]; total: number } { + const recipes = this.repository.findAll(filters); + const total = this.repository.count(filters); + return { recipes, total }; + } + + /** + * Get a single recipe by ID + */ + get(id: number): Recipe | null { + return this.repository.findById(id); + } + + /** + * Create a new recipe + */ + create(input: CreateRecipeInput): Recipe { + // Validate business rules + if (!input.title.trim()) { + throw new Error('Recipe title cannot be empty'); + } + if (!input.ingredients.length) { + throw new Error('Recipe must have at least one ingredient'); + } + if (!input.instructions.length) { + throw new Error('Recipe must have at least one instruction'); + } + + return this.repository.create(input); + } + + /** + * Update an existing recipe + */ + update(id: number, input: UpdateRecipeInput): Recipe | null { + // Validate business rules + if (input.title !== undefined && !input.title.trim()) { + throw new Error('Recipe title cannot be empty'); + } + if (input.ingredients !== undefined && !input.ingredients.length) { + throw new Error('Recipe must have at least one ingredient'); + } + if (input.instructions !== undefined && !input.instructions.length) { + throw new Error('Recipe must have at least one instruction'); + } + + return this.repository.update(id, input); + } + + /** + * Delete a recipe + */ + delete(id: number): boolean { + return this.repository.delete(id); + } +} diff --git a/src/backend/tests/recipes.test.ts b/src/backend/tests/recipes.test.ts new file mode 100644 index 0000000..15f8848 --- /dev/null +++ b/src/backend/tests/recipes.test.ts @@ -0,0 +1,291 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import initSqlJs from 'sql.js'; +import { readFileSync } from 'fs'; +import { createRecipeRoutes } from '../routes/recipes.js'; + +describe('Recipe API', () => { + let app: express.Application; + + beforeEach(async () => { + // Create a fresh in-memory database for each test + const SQL = await initSqlJs(); + const db = new SQL.Database(); + + // Load schema + const schemaPath = new URL('../db/schema.sql', import.meta.url).pathname; + const schema = readFileSync(schemaPath, 'utf-8'); + db.exec(schema); + + // Set up Express app with recipe routes + app = express(); + app.use(express.json()); + app.use('/api/recipes', createRecipeRoutes(db)); + }); + + describe('POST /api/recipes', () => { + it('should create a new recipe', async () => { + const recipe = { + title: 'Chocolate Chip Cookies', + description: 'Classic homemade cookies', + ingredients: ['flour', 'sugar', 'chocolate chips'], + instructions: ['Mix ingredients', 'Bake at 350°F'], + servings: 24, + }; + + const response = await request(app) + .post('/api/recipes') + .send(recipe) + .expect(201); + + expect(response.body.success).toBe(true); + expect(response.body.data).toMatchObject({ + id: 1, + title: recipe.title, + description: recipe.description, + ingredients: recipe.ingredients, + instructions: recipe.instructions, + servings: recipe.servings, + }); + expect(response.body.data.created_at).toBeDefined(); + expect(response.body.data.updated_at).toBeDefined(); + }); + + it('should reject recipe without title', async () => { + const recipe = { + ingredients: ['flour'], + instructions: ['Mix'], + }; + + const response = await request(app) + .post('/api/recipes') + .send(recipe) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.error).toBeDefined(); + }); + + it('should reject recipe without ingredients', async () => { + const recipe = { + title: 'Test Recipe', + ingredients: [], + instructions: ['Mix'], + }; + + const response = await request(app) + .post('/api/recipes') + .send(recipe) + .expect(400); + + expect(response.body.success).toBe(false); + }); + + it('should reject recipe without instructions', async () => { + const recipe = { + title: 'Test Recipe', + ingredients: ['flour'], + instructions: [], + }; + + const response = await request(app) + .post('/api/recipes') + .send(recipe) + .expect(400); + + expect(response.body.success).toBe(false); + }); + }); + + describe('GET /api/recipes', () => { + it('should return empty list when no recipes exist', async () => { + const response = await request(app) + .get('/api/recipes') + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toEqual([]); + expect(response.body.meta.total).toBe(0); + }); + + it('should return list of recipes', async () => { + // Create test recipes + await request(app).post('/api/recipes').send({ + title: 'Recipe 1', + ingredients: ['ingredient 1'], + instructions: ['step 1'], + }); + await request(app).post('/api/recipes').send({ + title: 'Recipe 2', + ingredients: ['ingredient 2'], + instructions: ['step 2'], + }); + + const response = await request(app) + .get('/api/recipes') + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.length).toBe(2); + expect(response.body.meta.total).toBe(2); + }); + + it('should support pagination', async () => { + // Create 3 test recipes + for (let i = 1; i <= 3; i++) { + await request(app).post('/api/recipes').send({ + title: `Recipe ${i}`, + ingredients: ['ingredient'], + instructions: ['step'], + }); + } + + const response = await request(app) + .get('/api/recipes?limit=2&offset=1') + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.length).toBe(2); + expect(response.body.meta.total).toBe(3); + expect(response.body.meta.limit).toBe(2); + expect(response.body.meta.offset).toBe(1); + }); + + it('should support search', async () => { + await request(app).post('/api/recipes').send({ + title: 'Chocolate Cake', + ingredients: ['chocolate'], + instructions: ['bake'], + }); + await request(app).post('/api/recipes').send({ + title: 'Vanilla Cookies', + ingredients: ['vanilla'], + instructions: ['bake'], + }); + + const response = await request(app) + .get('/api/recipes?search=chocolate') + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.length).toBe(1); + expect(response.body.data[0].title).toBe('Chocolate Cake'); + }); + }); + + describe('GET /api/recipes/:id', () => { + it('should get a recipe by ID', async () => { + const createResponse = await request(app).post('/api/recipes').send({ + title: 'Test Recipe', + ingredients: ['ingredient'], + instructions: ['step'], + }); + + const id = createResponse.body.data.id; + + const response = await request(app) + .get(`/api/recipes/${id}`) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.id).toBe(id); + expect(response.body.data.title).toBe('Test Recipe'); + }); + + it('should return 404 for non-existent recipe', async () => { + const response = await request(app) + .get('/api/recipes/999') + .expect(404); + + expect(response.body.success).toBe(false); + expect(response.body.error).toBe('Recipe not found'); + }); + + it('should return 400 for invalid ID', async () => { + const response = await request(app) + .get('/api/recipes/invalid') + .expect(400); + + expect(response.body.success).toBe(false); + }); + }); + + describe('PUT /api/recipes/:id', () => { + it('should update a recipe', async () => { + const createResponse = await request(app).post('/api/recipes').send({ + title: 'Original Title', + ingredients: ['ingredient'], + instructions: ['step'], + }); + + const id = createResponse.body.data.id; + + const response = await request(app) + .put(`/api/recipes/${id}`) + .send({ title: 'Updated Title' }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.title).toBe('Updated Title'); + expect(response.body.data.ingredients).toEqual(['ingredient']); + }); + + it('should return 404 for non-existent recipe', async () => { + const response = await request(app) + .put('/api/recipes/999') + .send({ title: 'Updated' }) + .expect(404); + + expect(response.body.success).toBe(false); + }); + + it('should reject empty title', async () => { + const createResponse = await request(app).post('/api/recipes').send({ + title: 'Original Title', + ingredients: ['ingredient'], + instructions: ['step'], + }); + + const id = createResponse.body.data.id; + + const response = await request(app) + .put(`/api/recipes/${id}`) + .send({ title: '' }) + .expect(400); + + expect(response.body.success).toBe(false); + }); + }); + + describe('DELETE /api/recipes/:id', () => { + it('should delete a recipe', async () => { + const createResponse = await request(app).post('/api/recipes').send({ + title: 'To Delete', + ingredients: ['ingredient'], + instructions: ['step'], + }); + + const id = createResponse.body.data.id; + + const response = await request(app) + .delete(`/api/recipes/${id}`) + .expect(200); + + expect(response.body.success).toBe(true); + + // Verify it's deleted + await request(app) + .get(`/api/recipes/${id}`) + .expect(404); + }); + + it('should return 404 for non-existent recipe', async () => { + const response = await request(app) + .delete('/api/recipes/999') + .expect(404); + + expect(response.body.success).toBe(false); + }); + }); +}); diff --git a/src/backend/types/recipe.ts b/src/backend/types/recipe.ts new file mode 100644 index 0000000..0e1730e --- /dev/null +++ b/src/backend/types/recipe.ts @@ -0,0 +1,49 @@ +/** + * Recipe domain types + */ + +export interface Recipe { + id: number; + title: string; + description: string | null; + ingredients: string[]; // Stored as JSON in DB + instructions: string[]; // Stored as JSON in DB + source_url: string | null; + notes: string | null; + servings: number | null; + prep_time_minutes: number | null; + cook_time_minutes: number | null; + created_at: number; // Unix timestamp + updated_at: number; // Unix timestamp + last_cooked_at: number | null; +} + +export interface CreateRecipeInput { + title: string; + description?: string; + ingredients: string[]; + instructions: string[]; + source_url?: string; + notes?: string; + servings?: number; + prep_time_minutes?: number; + cook_time_minutes?: number; +} + +export interface UpdateRecipeInput { + title?: string; + description?: string | null; + ingredients?: string[]; + instructions?: string[]; + source_url?: string | null; + notes?: string | null; + servings?: number | null; + prep_time_minutes?: number | null; + cook_time_minutes?: number | null; +} + +export interface RecipeFilters { + search?: string; // Search in title, description, ingredients + offset?: number; + limit?: number; +}