diff --git a/README.md b/README.md index 662dd94..1a27953 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,9 @@ npm run build # Run tests npm test +# Run tests with coverage +npm test -- --coverage + # Start dev server (with hot reload) npm run dev @@ -40,6 +43,49 @@ npm start | `npm test` | Run Jest tests | | `npm run lint` | Run ESLint | +## Integration tests + +The backend includes a focused integration test suite covering representative API behavior for: + +- `GET /health` +- `GET /api/v1/contracts` +- `GET /api/v1/contracts/:id` +- `POST /api/v1/contracts` + +The tests verify: + +- success paths +- malformed JSON handling +- validation failures +- duplicate creation conflicts +- not found and unsupported route behavior +- internal error sanitization +- server bootstrap/start-stop behavior + +### Test architecture + +To keep tests deterministic and reviewer-friendly: + +- `src/app.ts` creates the Express app without listening +- `src/index.ts` only starts the HTTP server +- `ContractService` uses per-app in-memory state for tests +- no external services or production systems are contacted + +### Security and threat assumptions validated + +- malformed input is rejected early +- invalid identifiers do not proceed to resource lookup +- duplicate resource creation is blocked +- self-dealing contract creation is denied +- internal errors are sanitized and do not leak stack traces +- integration tests do not depend on live external systems + +### Documentation + +Detailed backend test notes live in: + +- `docs/backend/integration-testing.md` + ## Contributing 1. Fork the repo and create a branch from `main`. diff --git a/docs/backend/integration-testing.md b/docs/backend/integration-testing.md new file mode 100644 index 0000000..3f74dba --- /dev/null +++ b/docs/backend/integration-testing.md @@ -0,0 +1,67 @@ +# Backend Integration Testing + +## Overview + +The backend now includes a focused integration test suite for the API surface. The suite verifies representative success and failure behavior without relying on external services or mutable shared state. + +## Test architecture + +The production server bootstrap was split into two small pieces: + +- `src/app.ts` creates an importable Express app +- `src/index.ts` starts the HTTP server + +This keeps production behavior intact while allowing tests to instantiate the app directly without binding to a port. + +## Isolation strategy + +- Tests use an in-memory `ContractService` +- Each app instance gets its own service state +- No external database or network dependency is required +- No production services are called + +## Covered API flows + +- `GET /health` +- `GET /api/v1/contracts` +- `GET /api/v1/contracts/:id` +- `POST /api/v1/contracts` + +## Covered failure paths + +- malformed JSON +- missing required request fields +- invalid path identifiers +- duplicate resource creation +- unauthorized role/ownership style misuse via self-dealing protection +- unknown contract lookup +- unknown route +- unsupported method under current router behavior +- unexpected internal error sanitization + +## Security assumptions validated + +- malformed input is rejected +- invalid identifiers are rejected before lookup +- internal errors do not leak implementation details +- duplicate creation conflicts are handled consistently +- self-dealing contract creation is denied +- tests do not contact external systems + +## How to run + +```bash +npm test +npm run build +``` + +To inspect coverage: + +```bash +npm test -- --coverage +``` + +## Limitations + +- The backend currently has a small API surface and no persistent database integration yet, so tests focus on the present app behavior and in-memory service layer. +- Auth middleware does not exist in the current repo, so authentication-specific integration cases are not added beyond the present business-rule enforcement. diff --git a/package-lock.json b/package-lock.json index 6392ca6..a384dd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,9 @@ "@types/express": "^4.17.21", "@types/jest": "^29.5.12", "@types/node": "^22.9.0", + "@types/supertest": "^7.2.0", "jest": "^29.7.0", + "supertest": "^7.2.2", "ts-jest": "^29.2.5", "ts-node-dev": "^2.0.0", "typescript": "^5.6.3" @@ -963,6 +965,29 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "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/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -1084,6 +1109,13 @@ "@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/express": { "version": "4.17.25", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", @@ -1165,6 +1197,13 @@ "pretty-format": "^29.0.0" } }, + "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", @@ -1178,7 +1217,6 @@ "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1251,6 +1289,30 @@ "dev": true, "license": "MIT" }, + "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": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -1386,6 +1448,20 @@ "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/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/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -1603,7 +1679,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1855,6 +1930,29 @@ "dev": true, "license": "MIT" }, + "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/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1905,6 +2003,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-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -1983,6 +2088,16 @@ "node": ">=0.10.0" } }, + "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", @@ -2012,6 +2127,17 @@ "node": ">=8" } }, + "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", @@ -2138,6 +2264,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/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2290,6 +2432,13 @@ "dev": true, "license": "MIT" }, + "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/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -2345,6 +2494,41 @@ "node": ">=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": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.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", @@ -2572,6 +2756,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", @@ -2914,7 +3114,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -4623,6 +4822,90 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "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": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4788,7 +5071,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -4937,7 +5219,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 4522e40..6614da1 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,9 @@ "@types/express": "^4.17.21", "@types/jest": "^29.5.12", "@types/node": "^22.9.0", + "@types/supertest": "^7.2.0", "jest": "^29.7.0", + "supertest": "^7.2.2", "ts-jest": "^29.2.5", "ts-node-dev": "^2.0.0", "typescript": "^5.6.3" diff --git a/src/app.integration.test.ts b/src/app.integration.test.ts new file mode 100644 index 0000000..0329f96 --- /dev/null +++ b/src/app.integration.test.ts @@ -0,0 +1,201 @@ +import request from 'supertest'; + +import { createApp } from './app'; + +describe('TalentTrust API integration', () => { + it('boots the app in test mode without side effects', async () => { + const app = createApp(); + const response = await request(app).get('/health'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ status: 'ok', service: 'talenttrust-backend' }); + }); + + it('returns seeded contracts from the list endpoint', async () => { + const app = createApp({ + seedContracts: [ + { + id: 'ctr-123', + title: 'Website build', + clientId: 'client-1', + freelancerId: 'freelancer-1', + budget: 5000, + currency: 'USDC', + createdAt: '2026-01-01T00:00:00.000Z', + }, + ], + }); + + const response = await request(app).get('/api/v1/contracts'); + + expect(response.status).toBe(200); + expect(response.body.contracts).toHaveLength(1); + expect(response.body.contracts[0].id).toBe('ctr-123'); + }); + + it('returns contract detail for an existing contract', async () => { + const app = createApp({ + seedContracts: [ + { + id: 'ctr-abc', + title: 'Audit trail setup', + clientId: 'client-a', + freelancerId: 'freelancer-b', + budget: 2500, + currency: 'USDC', + createdAt: '2026-01-01T00:00:00.000Z', + }, + ], + }); + + const response = await request(app).get('/api/v1/contracts/ctr-abc'); + + expect(response.status).toBe(200); + expect(response.body.contract.id).toBe('ctr-abc'); + }); + + it('creates a contract successfully', async () => { + const app = createApp(); + + const response = await request(app).post('/api/v1/contracts').send({ + title: 'Design sprint', + clientId: 'client-7', + freelancerId: 'freelancer-9', + budget: 1200, + currency: 'USDC', + }); + + expect(response.status).toBe(201); + expect(response.body.contract.title).toBe('Design sprint'); + expect(response.body.contract.id).toBeDefined(); + }); + + it('rejects malformed JSON bodies', async () => { + const app = createApp(); + + const response = await request(app) + .post('/api/v1/contracts') + .set('Content-Type', 'application/json') + .send('{"title":'); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'Malformed JSON request body.' }); + }); + + it('rejects missing required fields', async () => { + const app = createApp(); + + const response = await request(app).post('/api/v1/contracts').send({ + title: 'Missing fields', + clientId: 'client-1', + }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + error: 'title, clientId, freelancerId, budget, and currency are required.', + }); + }); + + it('rejects invalid budget values', async () => { + const app = createApp(); + + const response = await request(app).post('/api/v1/contracts').send({ + title: 'Bad budget', + clientId: 'client-1', + freelancerId: 'freelancer-1', + budget: 0, + currency: 'USDC', + }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'budget must be a positive number.' }); + }); + + it('prevents cross-role misuse when client and freelancer are the same', async () => { + const app = createApp(); + + const response = await request(app).post('/api/v1/contracts').send({ + title: 'Self dealing', + clientId: 'same-user', + freelancerId: 'same-user', + budget: 500, + currency: 'USDC', + }); + + expect(response.status).toBe(403); + expect(response.body).toEqual({ + error: 'Client and freelancer must be different accounts.', + }); + }); + + it('rejects duplicate contract creation for the same participants and title', async () => { + const app = createApp({ + seedContracts: [ + { + id: 'ctr-existing', + title: 'Roadmap sprint', + clientId: 'client-dup', + freelancerId: 'freelancer-dup', + budget: 800, + currency: 'USDC', + createdAt: '2026-01-01T00:00:00.000Z', + }, + ], + }); + + const response = await request(app).post('/api/v1/contracts').send({ + title: 'Roadmap sprint', + clientId: 'client-dup', + freelancerId: 'freelancer-dup', + budget: 800, + currency: 'USDC', + }); + + expect(response.status).toBe(409); + expect(response.body).toEqual({ + error: 'A contract with the same participants and title already exists.', + }); + }); + + it('returns 404 for unknown contracts', async () => { + const app = createApp(); + + const response = await request(app).get('/api/v1/contracts/ctr-missing'); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ error: 'Contract not found.' }); + }); + + it('returns 400 for invalid contract identifiers', async () => { + const app = createApp(); + + const response = await request(app).get('/api/v1/contracts/%24'); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'Contract id is invalid.' }); + }); + + it('returns 404 for unknown routes', async () => { + const app = createApp(); + const response = await request(app).get('/api/v1/missing'); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ error: 'Route not found.' }); + }); + + it('returns 404 for unsupported methods under current router behavior', async () => { + const app = createApp(); + const response = await request(app).delete('/api/v1/contracts'); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ error: 'Route not found.' }); + }); + + it('sanitizes unexpected internal errors', async () => { + const app = createApp({ enableTestRoutes: true }); + const response = await request(app).get('/__test__/error'); + + expect(response.status).toBe(500); + expect(response.body).toEqual({ error: 'Internal server error.' }); + }); +}); diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..c047d18 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,36 @@ +import express, { type Express } from 'express'; + +import { errorMiddleware, notFoundMiddleware } from './middleware/error-middleware'; +import { createContractRouter } from './routes/contract-routes'; +import { createHealthRouter } from './routes/health-routes'; +import { ContractService } from './services/contract-service'; +import type { ContractRecord } from './types/contract'; + +/** + * @notice Application factory used by both production startup and integration tests. + * @param options Optional app dependencies and test-only switches. + */ +export function createApp(options?: { + seedContracts?: ContractRecord[]; + enableTestRoutes?: boolean; + contractService?: ContractService; +}): Express { + const app = express(); + const contractService = + options?.contractService ?? new ContractService(options?.seedContracts ?? []); + + app.use(express.json()); + app.use(createHealthRouter()); + app.use(createContractRouter(contractService)); + + if (options?.enableTestRoutes) { + app.get('/__test__/error', () => { + throw new Error('Unexpected test failure'); + }); + } + + app.use(notFoundMiddleware); + app.use(errorMiddleware); + + return app; +} diff --git a/src/contract-service.test.ts b/src/contract-service.test.ts new file mode 100644 index 0000000..5689437 --- /dev/null +++ b/src/contract-service.test.ts @@ -0,0 +1,100 @@ +import { ApiError } from './errors/ApiError'; +import { ContractService } from './services/contract-service'; + +describe('ContractService', () => { + it('lists contracts without mutating the internal store', () => { + const service = new ContractService([ + { + id: 'ctr-1', + title: 'One', + clientId: 'client-1', + freelancerId: 'free-1', + budget: 10, + currency: 'USDC', + createdAt: '2026-01-01T00:00:00.000Z', + }, + ]); + + const listed = service.listContracts(); + listed.push({ + id: 'ctr-2', + title: 'Two', + clientId: 'client-2', + freelancerId: 'free-2', + budget: 20, + currency: 'USDC', + createdAt: '2026-01-02T00:00:00.000Z', + }); + + expect(service.listContracts()).toHaveLength(1); + }); + + it('gets contracts by id', () => { + const service = new ContractService([ + { + id: 'ctr-lookup', + title: 'Lookup', + clientId: 'client-1', + freelancerId: 'free-1', + budget: 10, + currency: 'USDC', + createdAt: '2026-01-01T00:00:00.000Z', + }, + ]); + + expect(service.getContractById('ctr-lookup')?.title).toBe('Lookup'); + expect(service.getContractById('missing')).toBeUndefined(); + }); + + it('creates contracts and stores them', () => { + const service = new ContractService(); + const created = service.createContract({ + title: 'Create', + clientId: 'client-1', + freelancerId: 'free-1', + budget: 99, + currency: 'USDC', + }); + + expect(created.id).toBeDefined(); + expect(service.listContracts()).toHaveLength(1); + }); + + it('throws ApiError for self-dealing participants', () => { + const service = new ContractService(); + + expect(() => + service.createContract({ + title: 'Invalid', + clientId: 'same', + freelancerId: 'same', + budget: 99, + currency: 'USDC', + }), + ).toThrow(ApiError); + }); + + it('throws ApiError for duplicates', () => { + const service = new ContractService([ + { + id: 'ctr-existing', + title: 'Duplicate', + clientId: 'client-1', + freelancerId: 'free-1', + budget: 99, + currency: 'USDC', + createdAt: '2026-01-01T00:00:00.000Z', + }, + ]); + + expect(() => + service.createContract({ + title: 'Duplicate', + clientId: 'client-1', + freelancerId: 'free-1', + budget: 99, + currency: 'USDC', + }), + ).toThrow(ApiError); + }); +}); diff --git a/src/error-middleware.test.ts b/src/error-middleware.test.ts new file mode 100644 index 0000000..00b6f3c --- /dev/null +++ b/src/error-middleware.test.ts @@ -0,0 +1,41 @@ +import { ApiError } from './errors/ApiError'; +import { + errorMiddleware, + isBodyParserError, + notFoundMiddleware, +} from './middleware/error-middleware'; + +describe('error middleware', () => { + it('identifies body parser errors', () => { + expect(isBodyParserError({ type: 'entity.parse.failed', status: 400 })).toBe(true); + expect(isBodyParserError({ type: 'other', status: 400 })).toBe(false); + }); + + it('passes ApiError details through the error middleware', () => { + const status = jest.fn().mockReturnThis(); + const json = jest.fn(); + + errorMiddleware(new ApiError(403, 'Forbidden'), {} as never, { status, json } as never, jest.fn()); + + expect(status).toHaveBeenCalledWith(403); + expect(json).toHaveBeenCalledWith({ error: 'Forbidden' }); + }); + + it('sanitizes unknown errors in the error middleware', () => { + const status = jest.fn().mockReturnThis(); + const json = jest.fn(); + + errorMiddleware(new Error('secret details'), {} as never, { status, json } as never, jest.fn()); + + expect(status).toHaveBeenCalledWith(500); + expect(json).toHaveBeenCalledWith({ error: 'Internal server error.' }); + }); + + it('creates a not found error via the terminal middleware', () => { + const next = jest.fn(); + + notFoundMiddleware({} as never, {} as never, next); + + expect(next).toHaveBeenCalledWith(expect.any(ApiError)); + }); +}); diff --git a/src/errors/ApiError.ts b/src/errors/ApiError.ts new file mode 100644 index 0000000..9d49e85 --- /dev/null +++ b/src/errors/ApiError.ts @@ -0,0 +1,16 @@ +/** + * @notice Typed operational error used by the API middleware stack. + */ +export class ApiError extends Error { + /** + * @param status HTTP status code to return. + * @param message Safe user-facing message. + */ + constructor( + public readonly status: number, + message: string, + ) { + super(message); + this.name = 'ApiError'; + } +} diff --git a/src/health.test.ts b/src/health.test.ts deleted file mode 100644 index a9fa0e8..0000000 --- a/src/health.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -describe('health', () => { - it('should pass', () => { - expect(true).toBe(true); - }); -}); diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..8013829 --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,21 @@ +import { type AddressInfo } from 'net'; + +import request from 'supertest'; + +import { startServer } from './index'; + +describe('server bootstrap', () => { + it('starts and stops cleanly', async () => { + const server = startServer(0); + + await new Promise((resolve) => server.once('listening', () => resolve())); + + const address = server.address() as AddressInfo; + const response = await request(`http://127.0.0.1:${address.port}`).get('/health'); + + expect(response.status).toBe(200); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + }); +}); diff --git a/src/index.ts b/src/index.ts index dd2fd8f..07e2877 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,18 +1,22 @@ -import express, { Request, Response } from 'express'; +import { createServer, type Server } from 'http'; -const app = express(); -const PORT = process.env.PORT || 3001; +import { createApp } from './app'; -app.use(express.json()); +/** + * @notice Start the HTTP server for the TalentTrust backend. + * @param port Port to bind to. Defaults to the PORT environment variable or 3001. + */ +export function startServer(port: number | string = process.env.PORT || 3001): Server { + const app = createApp(); + const server = createServer(app); -app.get('/health', (_req: Request, res: Response) => { - res.json({ status: 'ok', service: 'talenttrust-backend' }); -}); + server.listen(port, () => { + console.log(`TalentTrust API listening on http://localhost:${port}`); + }); -app.get('/api/v1/contracts', (_req: Request, res: Response) => { - res.json({ contracts: [] }); -}); + return server; +} -app.listen(PORT, () => { - console.log(`TalentTrust API listening on http://localhost:${PORT}`); -}); +if (require.main === module) { + startServer(); +} diff --git a/src/middleware/error-middleware.ts b/src/middleware/error-middleware.ts new file mode 100644 index 0000000..733a663 --- /dev/null +++ b/src/middleware/error-middleware.ts @@ -0,0 +1,47 @@ +import type { NextFunction, Request, Response } from 'express'; + +import { ApiError } from '../errors/ApiError'; + +/** + * @notice Translate unknown routes into a consistent 404 response. + */ +export function notFoundMiddleware(_req: Request, _res: Response, next: NextFunction): void { + next(new ApiError(404, 'Route not found.')); +} + +/** + * @notice Final error handler that keeps internal details out of API responses. + */ +export function errorMiddleware( + error: unknown, + _req: Request, + res: Response, + _next: NextFunction, +): void { + if (isBodyParserError(error)) { + res.status(400).json({ error: 'Malformed JSON request body.' }); + return; + } + + if (error instanceof ApiError) { + res.status(error.status).json({ error: error.message }); + return; + } + + res.status(500).json({ error: 'Internal server error.' }); +} + +/** + * @notice Detect malformed JSON parsing failures from Express. + * @param error Thrown framework error value. + */ +export function isBodyParserError(error: unknown): boolean { + return Boolean( + error && + typeof error === 'object' && + 'type' in error && + 'status' in error && + error.type === 'entity.parse.failed' && + error.status === 400, + ); +} diff --git a/src/route-validation.test.ts b/src/route-validation.test.ts new file mode 100644 index 0000000..678415e --- /dev/null +++ b/src/route-validation.test.ts @@ -0,0 +1,35 @@ +import { ApiError } from './errors/ApiError'; +import { + validateCreateContractBody, + validateIdentifier, +} from './routes/contract-routes'; + +describe('contract route validation', () => { + it('accepts a valid contract payload', () => { + expect( + validateCreateContractBody({ + title: 'Valid', + clientId: 'client-1', + freelancerId: 'free-1', + budget: 42, + currency: 'USDC', + }), + ).toEqual({ + title: 'Valid', + clientId: 'client-1', + freelancerId: 'free-1', + budget: 42, + currency: 'USDC', + }); + }); + + it('rejects non-object payloads', () => { + expect(() => validateCreateContractBody(null)).toThrow(ApiError); + expect(() => validateCreateContractBody([])).toThrow(ApiError); + }); + + it('rejects invalid identifiers', () => { + expect(() => validateIdentifier('$')).toThrow(ApiError); + expect(() => validateIdentifier('ok-id')).not.toThrow(); + }); +}); diff --git a/src/routes/contract-routes.ts b/src/routes/contract-routes.ts new file mode 100644 index 0000000..365e965 --- /dev/null +++ b/src/routes/contract-routes.ts @@ -0,0 +1,84 @@ +import { Router } from 'express'; + +import { ApiError } from '../errors/ApiError'; +import { ContractService } from '../services/contract-service'; +import type { CreateContractInput } from '../types/contract'; + +/** + * @notice Build the contract routes with an injected service for test isolation. + * @param contractService Service used to read and create contract records. + */ +export function createContractRouter(contractService: ContractService): Router { + const router = Router(); + + router.get('/api/v1/contracts', (_req, res) => { + res.json({ contracts: contractService.listContracts() }); + }); + + router.get('/api/v1/contracts/:id', (req, res) => { + validateIdentifier(req.params.id); + const contract = contractService.getContractById(req.params.id); + + if (!contract) { + throw new ApiError(404, 'Contract not found.'); + } + + res.json({ contract }); + }); + + router.post('/api/v1/contracts', (req, res) => { + const payload = validateCreateContractBody(req.body); + const created = contractService.createContract(payload); + res.status(201).json({ contract: created }); + }); + + return router; +} + +/** + * @notice Validate a contract identifier before lookup. + * @param id Contract identifier from the request path. + */ +export function validateIdentifier(id: string): void { + if (!/^[A-Za-z0-9-]{3,64}$/.test(id)) { + throw new ApiError(400, 'Contract id is invalid.'); + } +} + +/** + * @notice Validate the JSON payload for contract creation. + * @param body Raw request body. + * @returns A normalized create-contract payload. + */ +export function validateCreateContractBody(body: unknown): CreateContractInput { + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw new ApiError(400, 'Contract payload must be a JSON object.'); + } + + const { + title, + clientId, + freelancerId, + budget, + currency, + } = body as Partial; + + if (!title || !clientId || !freelancerId || typeof budget !== 'number' || !currency) { + throw new ApiError( + 400, + 'title, clientId, freelancerId, budget, and currency are required.', + ); + } + + if (!Number.isFinite(budget) || budget <= 0) { + throw new ApiError(400, 'budget must be a positive number.'); + } + + return { + title, + clientId, + freelancerId, + budget, + currency, + }; +} diff --git a/src/routes/health-routes.ts b/src/routes/health-routes.ts new file mode 100644 index 0000000..314c34d --- /dev/null +++ b/src/routes/health-routes.ts @@ -0,0 +1,14 @@ +import { Router } from 'express'; + +/** + * @notice Build the health and status routes. + */ +export function createHealthRouter(): Router { + const router = Router(); + + router.get('/health', (_req, res) => { + res.json({ status: 'ok', service: 'talenttrust-backend' }); + }); + + return router; +} diff --git a/src/services/contract-service.ts b/src/services/contract-service.ts new file mode 100644 index 0000000..2605a7c --- /dev/null +++ b/src/services/contract-service.ts @@ -0,0 +1,71 @@ +import { randomUUID } from 'crypto'; + +import { ApiError } from '../errors/ApiError'; +import type { ContractRecord, CreateContractInput } from '../types/contract'; + +/** + * @notice Small in-memory contract service used by the API layer and tests. + * @dev The service intentionally keeps state isolated per app instance so + * integration tests remain deterministic and do not require a database. + */ +export class ContractService { + private readonly contracts: ContractRecord[]; + + /** + * @param seedContracts Optional initial contract records for deterministic tests. + */ + constructor(seedContracts: ContractRecord[] = []) { + this.contracts = [...seedContracts]; + } + + /** + * @notice Return all known contract records. + */ + listContracts(): ContractRecord[] { + return [...this.contracts]; + } + + /** + * @notice Look up a contract by id. + * @param id Contract identifier. + * @returns The matching contract when present. + */ + getContractById(id: string): ContractRecord | undefined { + return this.contracts.find((contract) => contract.id === id); + } + + /** + * @notice Create a new contract after validating uniqueness and business rules. + * @param input Proposed contract payload. + * @returns The created contract record. + */ + createContract(input: CreateContractInput): ContractRecord { + if (input.clientId === input.freelancerId) { + throw new ApiError(403, 'Client and freelancer must be different accounts.'); + } + + const duplicate = this.contracts.find( + (contract) => + contract.title.toLowerCase() === input.title.toLowerCase() && + contract.clientId === input.clientId && + contract.freelancerId === input.freelancerId, + ); + + if (duplicate) { + throw new ApiError(409, 'A contract with the same participants and title already exists.'); + } + + const created: ContractRecord = { + id: randomUUID(), + title: input.title, + clientId: input.clientId, + freelancerId: input.freelancerId, + budget: input.budget, + currency: input.currency, + createdAt: new Date().toISOString(), + }; + + this.contracts.push(created); + return created; + } +} diff --git a/src/types/contract.ts b/src/types/contract.ts new file mode 100644 index 0000000..61f4f87 --- /dev/null +++ b/src/types/contract.ts @@ -0,0 +1,23 @@ +/** + * @notice Contract metadata stored and returned by the TalentTrust API. + */ +export interface ContractRecord { + id: string; + title: string; + clientId: string; + freelancerId: string; + budget: number; + currency: string; + createdAt: string; +} + +/** + * @notice Input payload required to create a contract record. + */ +export interface CreateContractInput { + title: string; + clientId: string; + freelancerId: string; + budget: number; + currency: string; +}