feat(api): implement recipe CRUD API endpoints

- 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.
This commit is contained in:
Paul Huliganga 2026-03-24 00:13:58 -04:00
parent 394aa22df1
commit e2599b81f4
9 changed files with 1466 additions and 16 deletions

455
package-lock.json generated
View File

@ -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",

View File

@ -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"

View File

@ -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<Database> {
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;
}
}

View File

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

View File

@ -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<string, SqlValue> = {};
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,
};
});
}
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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);
});
});
});

View File

@ -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;
}