diff --git a/.github/workflows/tests-e2e.yml b/.github/workflows/tests-e2e.yml new file mode 100644 index 0000000..80851ae --- /dev/null +++ b/.github/workflows/tests-e2e.yml @@ -0,0 +1,35 @@ +name: 'LinksforAll::E2E Test' + +on: [pull_request] + +jobs: + run-e2e-tests: + name: Run E2E Tests + runs-on: ubuntu-latest + + services: + postgres: + image: bitnami/postgresql + ports: + - 5432:5432 + env: + POSTGRESQL_USERNAME: linksforall_user + POSTGRESQL_PASSWORD: linksforall_password + POSTGRESQL_DATABASE: linksforall_db + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22.x + cache: 'npm' + + - name: "Intall dependencies" + run: npm ci + + - name: Run prisma setup and test + run: npx prisma migrate deploy && npx prisma generate && npm run test:e2e + env: + JWT_SECRET: a8f6dnpf6dfapdfyuadyf014117679tghavmvnfk + DATABASE_URL: "postgresql://linksforall_user:linksforall_password@localhost:5432/linksforall_db?schema=public" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 817301f..53c50f7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,9 +1,10 @@ -name: 'LinksforAll::Test' +name: 'LinksforAll::Unit test' on: pull_request: jobs: - test: + unit-test: + name: Run Unit tests runs-on: ubuntu-latest permissions: @@ -16,14 +17,19 @@ jobs: uses: actions/setup-node@v4 with: node-version: '22.x' + - name: 'Install Deps' run: npm install + - name: Set fake DATABASE_URL run: echo "DATABASE_URL=\"file:./dev.db\"" >> .env + - name: Generate Prisma Client run: npx prisma generate + - name: 'Test' - run: npx vitest --coverage.enabled true + run: npx vitest --project UNIT --coverage.enabled true + - name: 'Report Coverage' if: always() uses: davelosert/vitest-coverage-report-action@v2 diff --git a/README.md b/README.md index af1d5dd..104d6a9 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,16 @@ # linksforall-api -**Important**: I'm refactoring this project to follow some good practices, tests and some SOLID principles -REST API for managing users, pages, and links with authentication and role-based authorization. Built with TypeScript, Express, Prisma ORM, and PostgreSQL. +REST API for managing users, pages, and links with authentication and role-based authorization. Built with **Clean Architecture** principles, following **SOLID** design patterns with comprehensive test coverage. +### Key Features +- ✅ **Modular Controllers**: Each operation in its own file with dedicated tests +- ✅ **Clean Architecture**: Separation of concerns (controllers → use cases → repositories) +- ✅ **SOLID Principles**: Dependency injection, single responsibility +- ✅ **Comprehensive Testing**: Unit tests + E2E tests with Vitest +- ✅ **Type Safety**: Full TypeScript with Zod validation +- ✅ **Security**: JWT authentication, bcrypt hashing, role-based access control + ## Tech Stack - **Runtime**: Node.js (>= 22) @@ -12,34 +19,10 @@ REST API for managing users, pages, and links with authentication and role-based - **Database**: PostgreSQL - **ORM**: Prisma - **Authentication**: JWT + bcrypt -- **Testing**: Vitest +- **Validation**: Zod +- **Testing**: Vitest (Unit + E2E) - **Dev Tools**: tsx, ESLint -## Environment Variables - -Create a `.env` file at the project root: - -```env -# Database connection -DATABASE_URL="postgresql://user:password@localhost:5432/database" - -# JWT Secret (use a strong random string in production) -JWT_SECRET="your-secret-key-here" - -# Optional: Server port (defaults to 3001) -PORT=3001 - -# Optional: Node environment -NODE_ENV=development -``` - -**Example for Docker Compose database:** - -```env -DATABASE_URL="postgresql://linksforall:linksforall@localhost:5432/linksforal-development" -JWT_SECRET="your-secret-key-here" -``` - ## Quick Start ### 1. Clone and install dependencies @@ -61,7 +44,21 @@ This starts a PostgreSQL instance on `localhost:5432` with: ### 3. Configure environment variables -Create a `.env` file (see Environment Variables section above) +Create a `.env` file at the project root: + +```env +# Database connection +DATABASE_URL="postgresql://linksforall:linksforall@localhost:5432/linksforal-development" + +# JWT Secret (use a strong random string in production) +JWT_SECRET="your-secret-key-here" + +# Optional: Server port (defaults to 3001) +PORT=3001 + +# Optional: Node environment +NODE_ENV=development +``` ### 4. Generate Prisma client @@ -105,12 +102,76 @@ The server runs on `http://localhost:3001` by default. | `npm run build` | Compile TypeScript to `dist/` directory | | `npm run lint` | Run ESLint on source files | | `npm run lint:fix` | Run ESLint with auto-fix | -| `npm test` | Run tests once | -| `npm run test:watch` | Run tests in watch mode | +| `npm test` | Run unit tests | +| `npm run test:watch` | Run unit tests in watch mode | +| `npm run test:e2e` | Run E2E tests | +| `npm run test:e2e:watch` | Run E2E tests in watch mode | | `npm run test:coverage` | Generate test coverage report | | `npm run test:ui` | Open Vitest UI for interactive testing | | `npm run prisma:seed` | Seed database with fake data | +| `npm run prisma:studio` | Open Prisma Studio (database GUI) | +## Testing + +The project has comprehensive test coverage with two test suites: + +### Unit Tests +Test business logic in isolation using in-memory repositories: + +```bash +# Run once +npm test + +# Watch mode for TDD +npm run test:watch +``` + +### E2E Tests +Test the complete HTTP flow with real database: + +```bash +# Run once +npm run test:e2e + +# Watch mode +npm run test:e2e:watch +``` + +### Coverage Report + +```bash +npm run test:coverage +``` + +### Interactive Testing UI + +```bash +npm run test:ui +``` + +## API Endpoints + +### Authentication +- `POST /auth` - Authenticate user and get JWT token + +### Users +- `POST /users` - Create new user +- `GET /users/:id` - Get user profile (authenticated) +- `PATCH /users/:id` - Update user (authenticated, owner or admin) +- `DELETE /users/:id` - Delete user (authenticated, owner or admin) + +### Pages +- `POST /pages` - Create new page (authenticated) +- `GET /pages/:id` - Get page by ID (authenticated) +- `GET /pages/:id/links` - Get all links from a page (authenticated) +- `PUT /pages/:id` - Update page (authenticated) +- `DELETE /pages/:id` - Delete page (authenticated) + +### Links +- `GET /links/:id` - Get link by ID +- `POST /links` - Create new link +- `PUT /links/:id` - Update link +- `DELETE /links/:id` - Delete link ## Prisma Commands @@ -123,24 +184,17 @@ The server runs on `http://localhost:3001` by default. | `npx prisma generate` | Generate Prisma Client | | `npx prisma db seed` | Run seed script | -## Testing +## Authentication Flow -The project uses Vitest for testing with coverage support: - -```bash -# Run all tests -npm test +1. User registers via `POST /users` +2. User authenticates via `POST /auth` with email/password +3. Server returns JWT token +4. Client includes token in `Authorization: Bearer ` header +5. Protected routes verify token via `authMiddleware` # Watch mode for TDD npm run test:watch -# Generate coverage report -npm run test:coverage - -# Open interactive UI -npm run test:ui -``` - ## License ISC diff --git a/package-lock.json b/package-lock.json index ea19853..924ab9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,35 +1,38 @@ { "name": "linksforall-api", - "version": "0.2.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linksforall-api", - "version": "0.2.0", + "version": "0.5.0", "license": "ISC", "dependencies": { "@prisma/adapter-pg": "^7.0.0", "@prisma/client": "^7.0.0", - "@types/cors": "^2.8.19", "bcrypt": "^5.1.1", "cors": "^2.8.5", "express": "^5.1.0", "jsonwebtoken": "^9.0.2", - "pg": "^8.16.3" + "pg": "^8.16.3", + "zod": "^4.2.1" }, "devDependencies": { "@faker-js/faker": "^10.1.0", "@types/bcrypt": "^5.0.0", + "@types/cors": "^2.8.19", "@types/express": "^4.17.25", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20.19.25", + "@types/supertest": "^6.0.3", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "@vitest/coverage-v8": "^4.0.13", "@vitest/ui": "^4.0.13", "eslint": "^8.57.1", "prisma": "^7.0.0", + "supertest": "^7.1.4", "tsx": "^3.14.0", "typescript": "^5.9.3", "vitest": "^4.0.13" @@ -797,6 +800,19 @@ "node": ">=16" } }, + "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/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -835,6 +851,16 @@ "node": ">= 8" } }, + "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/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -1383,10 +1409,18 @@ "@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/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -1457,6 +1491,13 @@ "@types/node": "*" } }, + "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", @@ -1475,6 +1516,7 @@ "version": "20.19.25", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -1545,6 +1587,30 @@ "@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/@typescript-eslint/eslint-plugin": { "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", @@ -2038,6 +2104,13 @@ "node": ">=8" } }, + "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": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2060,6 +2133,13 @@ "js-tokens": "^9.0.1" } }, + "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/aws-ssl-profiles": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", @@ -2329,6 +2409,29 @@ "color-support": "bin.js" } }, + "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", @@ -2398,6 +2501,13 @@ "node": ">=6.6.0" } }, + "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/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -2475,6 +2585,16 @@ "devOptional": true, "license": "MIT" }, + "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/delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", @@ -2516,6 +2636,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/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -2657,6 +2788,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.18.20", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", @@ -3061,6 +3208,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/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -3190,6 +3344,64 @@ "url": "https://github.com/sponsors/isaacs" } }, + "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/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.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", @@ -3494,6 +3706,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/has-unicode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", @@ -4107,6 +4335,16 @@ "node": ">= 8" } }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -4121,6 +4359,19 @@ "node": ">=8.6" } }, + "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/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -5550,6 +5801,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superagent": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.3.tgz", + "integrity": "sha512-y/hkYGeXAj7wUMjxRbB21g/l6aAEituGXM9Rwl4o20+SX3e8YOSV6BxFXl+dL3Uk0mjSL3kCbNkwURm8/gEDig==", + "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.4", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.4.tgz", + "integrity": "sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "methods": "^1.1.2", + "superagent": "^10.2.3" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -5799,6 +6085,7 @@ "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" }, "node_modules/unpipe": { @@ -6628,6 +6915,15 @@ "dependencies": { "grammex": "^3.1.10" } + }, + "node_modules/zod": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", + "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 4c8556b..6c02dc1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linksforall-api", - "version": "0.4.0", + "version": "0.5.0", "description": "", "main": "index.js", "type": "module", @@ -11,8 +11,10 @@ "build": "tsc", "prisma:seed": "tsx prisma/seed.ts", "prisma:studio": "prisma studio", - "test": "vitest run", - "test:watch": "vitest", + "test": "vitest run --project UNIT", + "test:watch": "vitest --project UNIT", + "test:e2e": "vitest run --project E2E", + "test:e2e:watch": "vitest --project E2E", "test:coverage": "vitest run --coverage", "test:ui": "vitest --ui" }, @@ -22,18 +24,20 @@ "devDependencies": { "@faker-js/faker": "^10.1.0", "@types/bcrypt": "^5.0.0", + "@types/cors": "^2.8.19", "@types/express": "^4.17.25", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20.19.25", + "@types/supertest": "^6.0.3", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "@vitest/coverage-v8": "^4.0.13", "@vitest/ui": "^4.0.13", "eslint": "^8.57.1", "prisma": "^7.0.0", + "supertest": "^7.1.4", "tsx": "^3.14.0", "typescript": "^5.9.3", - "@types/cors": "^2.8.19", "vitest": "^4.0.13" }, "dependencies": { @@ -43,6 +47,7 @@ "cors": "^2.8.5", "express": "^5.1.0", "jsonwebtoken": "^9.0.2", - "pg": "^8.16.3" + "pg": "^8.16.3", + "zod": "^4.2.1" } } diff --git a/prisma/seed.ts b/prisma/seed.ts index 0656dc0..ecd644e 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,20 +1,15 @@ -import { encryptString } from '@/utils/encryptString'; +import { prisma } from '@/lib/prisma'; +import { encryptString } from '@/utils/encrypt-string'; import {fakerPT_BR as faker} from '@faker-js/faker'; -import { PrismaClient } from '@prisma/client'; -const prisma = new PrismaClient(); - -async function createUser(role?: 'ADMIN' | 'USER') { +async function createUser(role: 'ADMIN' | 'USER' = 'USER') { const data = { email: faker.internet.email(), fullname: faker.person.fullName(), - password: (await encryptString('fakepassword')) + password: (await encryptString('123456')), + role }; - if(role) { - data.role = role; - } - const user = await prisma.users.create({ data }); @@ -23,10 +18,13 @@ async function createUser(role?: 'ADMIN' | 'USER') { } async function createPage({userId}: { userId: string}) { + const page = await prisma.pages.create({ data: { + title: faker.lorem.words(5), + description: faker.lorem.text(), slug: faker.lorem.slug(2), - userId + userId: userId.toString() } }); @@ -41,7 +39,7 @@ async function seed() { await createUser() ]); - const pages = await Promise.all([ + await Promise.all([ await createPage({ userId: users[0].id}), await createPage({ userId: users[1].id}), await createPage({ userId: users[2].id}), diff --git a/src/app.ts b/src/app.ts index 887e60e..0a98539 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,6 +1,7 @@ import express, { ErrorRequestHandler } from 'express'; import cors from 'cors'; import router from '@/http/routes'; +import { ZodError } from 'zod'; export const app = express(); @@ -9,6 +10,13 @@ app.use(express.json()); app.use(router); const errorHandler: ErrorRequestHandler = (error, req, res, next) => { + if (error instanceof ZodError) { + return res.status(400).send({ + message: error.message, + issues: error.issues, + }); + } + if (process.env.NODE_ENV !== 'production') { console.error(error); } diff --git a/src/env/index.ts b/src/env/index.ts new file mode 100644 index 0000000..3ed8413 --- /dev/null +++ b/src/env/index.ts @@ -0,0 +1,17 @@ +import 'dotenv/config'; +import * as z from 'zod'; + +const envSchema = z.object({ + DATABASE_URL: z.string(), + JWT_SECRET: z.string(), + NODE_ENV: z.enum(['development', 'production', 'test']) +}); + +const _env = envSchema.safeParse(process.env); + +if (!_env.success) { + console.error('ERROR: Invalid environment variables', _env.error.issues); + throw new Error('Invalid environment variables'); +} + +export const env = _env.data; diff --git a/src/http/controllers/auth-controller.ts b/src/http/controllers/auth-controller.ts deleted file mode 100644 index 6eeae05..0000000 --- a/src/http/controllers/auth-controller.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Request, Response } from 'express'; -import bcrypt from 'bcrypt'; -import jwt from 'jsonwebtoken'; - -import UserModel from '@/use-cases/users-use-case'; - -class AuthController { - async authenticate(req: Request, res: Response) { - const { - password, - email - } = req.body; - - if(!process.env.JWT_SECRET) { - throw new Error('JWT_SECRET must be set'); - } - - const user = await UserModel.findByEmail(email); - - if(email.trim() == '' || password.trim() == '') { - return res.status(400).send({ - error: 'Some fields are missing' - }); - } - - if(!user) { - return res.status(401).send({ - error: 'Wrong e-mail or password' - }); - } - - const passwordIsValid = await bcrypt.compare(password, user.password); - - if(!passwordIsValid) { - return res.status(401).send({ - error: 'Wrong e-mail or password' - }); - } - - const token = jwt.sign({ id: user.id, role: user.role }, process.env.JWT_SECRET ?? '', { - expiresIn: '1d' - }); - - return res.status(200).send({ - token, - }); - - } -} - -export default new AuthController(); diff --git a/src/http/controllers/links-controller.ts b/src/http/controllers/links-controller.ts deleted file mode 100644 index dee00db..0000000 --- a/src/http/controllers/links-controller.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Request, Response } from 'express'; - -import LinksModel from '@/use-cases/links-use-case'; -import PagesModel from '@/use-cases/pages-use-case'; -import { LinksUncheckedCreateInput, LinksUncheckedUpdateInput } from 'prisma/generated/models'; - -class LinkController { - async show(req: Request<{id: string}>, res: Response) { - const { id } = req.params; - - const link = await LinksModel.findById(id); - - if(!link) { - res.status(404).send({ - error: 'Link not found' - }); - } - - return res.status(200).send(link); - } - - async store(req: Request, res: Response) { - const { - title, - url, - description, - order, - type, - pageId - }: LinksUncheckedCreateInput = req.body; - - const page = await PagesModel.findById(pageId); - - if(!pageId) { - return res.status(400).send({ - error: 'Page id is required' - }); - } - - if(!page?.id) { - return res.status(404).send({ - error: 'Page not found' - }); - } - - const newLink = await LinksModel.create({ - title, - url, - description, - order, - type, - pageId - }); - - return res.status(201).send(newLink); - } - - async update(req: Request, res: Response) { - const { id } = req.params; - const { title, description, order, type, url }: LinksUncheckedUpdateInput = req.body; - - await LinksModel.update(id, { - title, description, order, type, url - }); - - return res.status(202).send(); - } - - async delete(req: Request, res: Response) { - const { id } = req.params; - await LinksModel.delete(id); - - return res.status(202).send(); - } -} - -export default new LinkController(); diff --git a/src/http/controllers/links/create.spec.ts b/src/http/controllers/links/create.spec.ts new file mode 100644 index 0000000..dc3b9f3 --- /dev/null +++ b/src/http/controllers/links/create.spec.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import { app } from '@/app'; +import { prisma } from '@/lib/prisma'; + +describe('Create link test', () => { + beforeEach(async () => { + await prisma.links.deleteMany({}); + await prisma.pages.deleteMany({}); + await prisma.users.deleteMany({}); + }); + + it('should be able to create a link', async () => { + const userResponse = await request(app).post('/users').send({ + fullname: 'Test User', + email: 'testuser@linksforall.com', + password: '123456' + }); + + const user = userResponse.body; + + const page = await prisma.pages.create({ + data: { + slug: 'test-page', + title: 'Test Page', + description: 'Test description', + userId: user.id + } + }); + + const response = await request(app) + .post('/links') + .send({ + title: 'New Link', + description: 'Link Description', + url: 'https://example.com', + type: 'link', + pageId: page.id + }); + + expect(response.status).toEqual(201); + expect(response.body).toHaveProperty('id'); + expect(response.body.title).toEqual('New Link'); + }); + + it('should return 404 when page does not exist', async () => { + const response = await request(app) + .post('/links') + .send({ + title: 'New Link', + description: 'Link Description', + url: 'https://example.com', + type: 'link', + pageId: '00000000-0000-0000-0000-000000000000' + }); + + expect(response.status).toEqual(404); + }); +}); diff --git a/src/http/controllers/links/create.ts b/src/http/controllers/links/create.ts new file mode 100644 index 0000000..0c4fe96 --- /dev/null +++ b/src/http/controllers/links/create.ts @@ -0,0 +1,36 @@ +import { ResourceNotFoundError } from '@/use-cases/errors/not-found-error'; +import { createLinkUseCase } from '@/use-cases/factories/create-link-factory'; +import { Request, Response } from 'express'; +import * as z from 'zod'; + +export async function createLinkController(req: Request, res: Response) { + const createLinkSchema = z.object({ + title: z.string(), + description: z.string(), + url: z.string().url(), + type: z.string(), + pageId: z.string() + }); + + const linkUseCase = createLinkUseCase(); + + const { title, description, url, type, pageId } = createLinkSchema.parse(req.body); + + try { + const { link } = await linkUseCase.execute({ + title, + description, + url, + type, + pageId + }); + + return res.status(201).send(link); + + } catch (err) { + if (err instanceof ResourceNotFoundError) { + return res.status(404).send({ message: err.message }); + } + return err; + } +} diff --git a/src/http/controllers/links/delete.spec.ts b/src/http/controllers/links/delete.spec.ts new file mode 100644 index 0000000..6c4ee26 --- /dev/null +++ b/src/http/controllers/links/delete.spec.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import { app } from '@/app'; +import { prisma } from '@/lib/prisma'; + +describe('Delete link test', () => { + beforeEach(async () => { + await prisma.links.deleteMany({}); + await prisma.pages.deleteMany({}); + await prisma.users.deleteMany({}); + }); + + it('should be able to delete a link', async () => { + const userResponse = await request(app).post('/users').send({ + fullname: 'Test User', + email: 'testuser@linksforall.com', + password: '123456' + }); + + const user = userResponse.body; + + const page = await prisma.pages.create({ + data: { + slug: 'test-page', + title: 'Test Page', + description: 'Test description', + userId: user.id + } + }); + + const link = await prisma.links.create({ + data: { + title: 'Link to Delete', + description: 'Test Description', + url: 'https://example.com', + type: 'link', + pageId: page.id + } + }); + + const response = await request(app).delete(`/links/${link.id}`); + + expect(response.status).toEqual(204); + + const deletedLink = await prisma.links.findUnique({ + where: { id: link.id } + }); + + expect(deletedLink).toBeNull(); + }); + + it('should return 404 when trying to delete non-existent link', async () => { + const response = await request(app).delete('/links/00000000-0000-0000-0000-000000000000'); + + expect(response.status).toEqual(404); + }); +}); diff --git a/src/http/controllers/links/delete.ts b/src/http/controllers/links/delete.ts new file mode 100644 index 0000000..a4ac785 --- /dev/null +++ b/src/http/controllers/links/delete.ts @@ -0,0 +1,25 @@ +import { ResourceNotFoundError } from '@/use-cases/errors/not-found-error'; +import { deleteLinkUseCase } from '@/use-cases/factories/delete-link-factory'; +import { Request, Response } from 'express'; +import * as z from 'zod'; + +export async function deleteLinkController(req: Request, res: Response) { + const paramsSchema = z.object({ + id: z.string() + }); + + const { id } = paramsSchema.parse(req.params); + + const linkUseCase = deleteLinkUseCase(); + + try { + await linkUseCase.execute(id); + } catch (err) { + if (err instanceof ResourceNotFoundError) { + return res.status(404).send({ message: err.message }); + } + return err; + } + + return res.status(204).send(); +} diff --git a/src/http/controllers/links/show.spec.ts b/src/http/controllers/links/show.spec.ts new file mode 100644 index 0000000..9b43e81 --- /dev/null +++ b/src/http/controllers/links/show.spec.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import { app } from '@/app'; +import { prisma } from '@/lib/prisma'; + +describe('Get link test', () => { + beforeEach(async () => { + await prisma.links.deleteMany({}); + await prisma.pages.deleteMany({}); + await prisma.users.deleteMany({}); + }); + + it('should be able to get a link by id', async () => { + const userResponse = await request(app).post('/users').send({ + fullname: 'Test User', + email: 'testuser@linksforall.com', + password: '123456' + }); + + const user = userResponse.body; + + const page = await prisma.pages.create({ + data: { + slug: 'test-page', + title: 'Test Page', + description: 'Test description', + userId: user.id + } + }); + + const link = await prisma.links.create({ + data: { + title: 'Test Link', + description: 'Test Description', + url: 'https://example.com', + type: 'link', + pageId: page.id + } + }); + + const response = await request(app).get(`/links/${link.id}`); + + expect(response.status).toEqual(200); + expect(response.body).toHaveProperty('id'); + expect(response.body.title).toEqual('Test Link'); + }); + + it('should return 404 when link does not exist', async () => { + const response = await request(app).get('/links/00000000-0000-0000-0000-000000000000'); + + expect(response.status).toEqual(404); + }); +}); diff --git a/src/http/controllers/links/show.ts b/src/http/controllers/links/show.ts new file mode 100644 index 0000000..649f6c9 --- /dev/null +++ b/src/http/controllers/links/show.ts @@ -0,0 +1,25 @@ +import { ResourceNotFoundError } from '@/use-cases/errors/not-found-error'; +import { getLinkUseCase } from '@/use-cases/factories/get-link-factory'; +import { Request, Response } from 'express'; +import * as z from 'zod'; + +export async function getLinkController(req: Request, res: Response) { + const linkParams = z.object({ + id: z.string() + }); + + const linkUseCase = getLinkUseCase(); + + const { id } = linkParams.parse(req.params); + + try { + const { link } = await linkUseCase.execute(id); + + return res.status(200).send(link); + } catch (err) { + if (err instanceof ResourceNotFoundError) { + return res.status(404).send({ message: err.message }); + } + return err; + } +} diff --git a/src/http/controllers/links/update.spec.ts b/src/http/controllers/links/update.spec.ts new file mode 100644 index 0000000..080708c --- /dev/null +++ b/src/http/controllers/links/update.spec.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import { app } from '@/app'; +import { prisma } from '@/lib/prisma'; + +describe('Update link test', () => { + beforeEach(async () => { + await prisma.links.deleteMany({}); + await prisma.pages.deleteMany({}); + await prisma.users.deleteMany({}); + }); + + it('should be able to update a link', async () => { + const userResponse = await request(app).post('/users').send({ + fullname: 'Test User', + email: 'testuser@linksforall.com', + password: '123456' + }); + + const user = userResponse.body; + + const page = await prisma.pages.create({ + data: { + slug: 'test-page', + title: 'Test Page', + description: 'Test description', + userId: user.id + } + }); + + const link = await prisma.links.create({ + data: { + title: 'Old Title', + description: 'Old Description', + url: 'https://old-url.com', + type: 'link', + pageId: page.id + } + }); + + const response = await request(app) + .put(`/links/${link.id}`) + .send({ + title: 'Updated Title', + description: 'Updated Description', + url: 'https://new-url.com' + }); + + expect(response.status).toEqual(200); + expect(response.body.title).toEqual('Updated Title'); + expect(response.body.url).toEqual('https://new-url.com'); + }); + + it('should return 404 when trying to update non-existent link', async () => { + const response = await request(app) + .put('/links/00000000-0000-0000-0000-000000000000') + .send({ + title: 'Updated Title' + }); + + expect(response.status).toEqual(404); + }); +}); diff --git a/src/http/controllers/links/update.ts b/src/http/controllers/links/update.ts new file mode 100644 index 0000000..c9fec21 --- /dev/null +++ b/src/http/controllers/links/update.ts @@ -0,0 +1,45 @@ +import { ResourceNotFoundError } from '@/use-cases/errors/not-found-error'; +import { updateLinkUseCase } from '@/use-cases/factories/update-link-factory'; +import { Request, Response } from 'express'; +import * as z from 'zod'; + +export async function updateLinkController(req: Request, res: Response) { + const updateLinkSchema = z.object({ + title: z.string().optional(), + description: z.string().optional(), + url: z.string().url().optional(), + type: z.string().optional(), + pageId: z.string().optional() + }); + + const urlParamsSchema = z.object({ + id: z.string() + }); + + const { id } = urlParamsSchema.parse(req.params); + const { title, description, url, type, pageId } = updateLinkSchema.parse(req.body); + + const linkUseCase = updateLinkUseCase(); + + try { + const { link } = await linkUseCase.execute({ + id, + data: { + title, + description, + url, + type, + pageId + } + }); + + return res.status(200).send(link); + + } catch (err) { + if (err instanceof ResourceNotFoundError) { + return res.status(404).send({ message: err.message }); + } + + return err; + } +} diff --git a/src/http/controllers/pages-controller.ts b/src/http/controllers/pages-controller.ts deleted file mode 100644 index f1d3a16..0000000 --- a/src/http/controllers/pages-controller.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { Request, Response } from 'express'; - -import PageModel from '@/use-cases/pages-use-case'; -import UserModel from '@/use-cases/users-use-case'; -import { Pages } from 'prisma/generated/client'; -import { PagesUncheckedCreateInput } from 'prisma/generated/models'; - -class PageController { - async show(req: Request<{id: string}>, res: Response) { - const { id } = req.params; - - const page = await PageModel.findById(id); - - if(!page) { - res.status(404).send({ - error: 'Page not found' - }); - } - - return res.status(200).send(page); - } - - async links(req: Request<{id: string}>, res: Response) { - const { id } = req.params; - - const page = await PageModel.findLinks(id); - - if(!page) { - res.status(404).send({ - error: 'Page not found' - }); - } - - return res.status(200).send(page); - } - - async store(req: Request, res: Response) { - const { - slug, - settings, - userId - }: PagesUncheckedCreateInput = req.body; - - const getUserId = await UserModel.findById(userId); - - if(!userId || userId !== getUserId?.id) { - return res.status(404).send({ - error: 'User not found' - }); - } - - const slugAlreadyExists = await PageModel.findBySlug(slug); - if(slugAlreadyExists) { - return res.status(409).send({ - error: 'The slug already exists.' - }); - } - - const newPage = await PageModel.create({ - userId, - slug, - settings - }); - - return res.status(201).send(newPage); - } - - async update(req: Request, res: Response) { - const { id } = req.params; - const { settings, slug }: Pages = req.body; - - if(!slug || slug.trim() == '') { - return res.status(400).send({ - error: 'Slug is required' - }); - } - - const currentSlug = await PageModel.findBySlug(slug); - const currentPageSlug = await PageModel.findById(id); - - if(currentSlug && currentPageSlug?.slug !== slug) { - return res.status(409).send({ - error: 'The slug already exists.' - }); - } - - await PageModel.update(id, { - settings, - slug: currentSlug?.slug === slug ? currentSlug.slug : slug - }); - - return res.status(202).send(); - } - - async delete(req: Request, res: Response) { - const { id } = req.params; - await PageModel.delete(id); - - return res.status(202).send(); - } -} - -export default new PageController(); diff --git a/src/http/controllers/pages/create.spec.ts b/src/http/controllers/pages/create.spec.ts new file mode 100644 index 0000000..75faaea --- /dev/null +++ b/src/http/controllers/pages/create.spec.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import { app } from '@/app'; +import { prisma } from '@/lib/prisma'; + +describe('Create page test', () => { + beforeEach(async () => { + await prisma.pages.deleteMany({}); + await prisma.users.deleteMany({}); + }); + + it('should be able to create a page', async () => { + const userResponse = await request(app).post('/users').send({ + fullname: 'Test User', + email: 'testuser@linksforall.com', + password: '123456' + }); + + const user = userResponse.body; + + const authResponse = await request(app).post('/auth').send({ + email: 'testuser@linksforall.com', + password: '123456' + }); + + const { token } = authResponse.body; + + const response = await request(app) + .post('/pages') + .set('Authorization', `Bearer ${token}`) + .send({ + slug: 'new-page', + title: 'New Page', + description: 'Test description', + userId: user.id + }); + + expect(response.status).toEqual(201); + expect(response.body).toHaveProperty('id'); + expect(response.body.slug).toEqual('new-page'); + }); + + it('should return 409 when slug already exists', async () => { + const userResponse = await request(app).post('/users').send({ + fullname: 'Test User', + email: 'testuser@linksforall.com', + password: '123456' + }); + + const user = userResponse.body; + + await prisma.pages.create({ + data: { + slug: 'existing-slug', + title: 'Existing Page', + description: 'Existing description', + userId: user.id + } + }); + + const authResponse = await request(app).post('/auth').send({ + email: 'testuser@linksforall.com', + password: '123456' + }); + + const { token } = authResponse.body; + + const response = await request(app) + .post('/pages') + .set('Authorization', `Bearer ${token}`) + .send({ + slug: 'existing-slug', + title: 'Another Page', + description: 'Test description', + userId: user.id + }); + + expect(response.status).toEqual(409); + }); +}); diff --git a/src/http/controllers/pages/create.ts b/src/http/controllers/pages/create.ts new file mode 100644 index 0000000..451055c --- /dev/null +++ b/src/http/controllers/pages/create.ts @@ -0,0 +1,36 @@ +import { PageSlugAlreadyExistsError } from '@/use-cases/errors/page-slug-already-exists-error'; +import { createPageUseCase } from '@/use-cases/factories/create-page-factory'; +import { Request, Response } from 'express'; +import * as z from 'zod'; + +export async function createPageController(req: Request, res: Response) { + const createPageSchema = z.object({ + slug: z.string(), + title: z.string(), + description: z.string(), + settings: z.object({}).optional(), + userId: z.string() + }); + + const pageUseCase = createPageUseCase(); + + const { slug, title, description, settings, userId } = createPageSchema.parse(req.body); + + try { + const { page } = await pageUseCase.execute({ + slug, + title, + description, + settings, + userId + }); + + return res.status(201).send(page); + + } catch (err) { + if (err instanceof PageSlugAlreadyExistsError) { + return res.status(409).send({ message: err.message }); + } + return err; + } +} diff --git a/src/http/controllers/pages/delete.spec.ts b/src/http/controllers/pages/delete.spec.ts new file mode 100644 index 0000000..22a524e --- /dev/null +++ b/src/http/controllers/pages/delete.spec.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import { app } from '@/app'; +import { prisma } from '@/lib/prisma'; + +describe('Delete page test', () => { + beforeEach(async () => { + await prisma.pages.deleteMany({}); + await prisma.users.deleteMany({}); + }); + + it('should be able to delete a page', async () => { + const userResponse = await request(app).post('/users').send({ + fullname: 'Test User', + email: 'testuser@linksforall.com', + password: '123456' + }); + + const user = userResponse.body; + + const page = await prisma.pages.create({ + data: { + slug: 'page-to-delete', + title: 'Page to Delete', + description: 'Test description', + userId: user.id + } + }); + + const authResponse = await request(app).post('/auth').send({ + email: 'testuser@linksforall.com', + password: '123456' + }); + + const { token } = authResponse.body; + + const response = await request(app) + .delete(`/pages/${page.id}`) + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toEqual(204); + + const deletedPage = await prisma.pages.findUnique({ + where: { id: page.id } + }); + + expect(deletedPage).toBeNull(); + }); + + it('should return 404 when trying to delete non-existent page', async () => { + await request(app).post('/users').send({ + fullname: 'Test User', + email: 'testuser@linksforall.com', + password: '123456' + }); + + const authResponse = await request(app).post('/auth').send({ + email: 'testuser@linksforall.com', + password: '123456' + }); + + const { token } = authResponse.body; + + const response = await request(app) + .delete('/pages/00000000-0000-0000-0000-000000000000') + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toEqual(404); + }); +}); diff --git a/src/http/controllers/pages/delete.ts b/src/http/controllers/pages/delete.ts new file mode 100644 index 0000000..e2d459c --- /dev/null +++ b/src/http/controllers/pages/delete.ts @@ -0,0 +1,25 @@ +import { ResourceNotFoundError } from '@/use-cases/errors/not-found-error'; +import { deletePageUseCase } from '@/use-cases/factories/delete-page-factory'; +import { Request, Response } from 'express'; +import * as z from 'zod'; + +export async function deletePageController(req: Request, res: Response) { + const paramsSchema = z.object({ + id: z.string() + }); + + const { id } = paramsSchema.parse(req.params); + + const pageUseCase = deletePageUseCase(); + + try { + await pageUseCase.execute(id); + } catch (err) { + if (err instanceof ResourceNotFoundError) { + return res.status(404).send({ message: err.message }); + } + return err; + } + + return res.status(204).send(); +} diff --git a/src/http/controllers/pages/get-links.spec.ts b/src/http/controllers/pages/get-links.spec.ts new file mode 100644 index 0000000..0a4ab2c --- /dev/null +++ b/src/http/controllers/pages/get-links.spec.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import { app } from '@/app'; +import { prisma } from '@/lib/prisma'; + +describe('Get page links test', () => { + beforeEach(async () => { + await prisma.links.deleteMany({}); + await prisma.pages.deleteMany({}); + await prisma.users.deleteMany({}); + }); + + it('should be able to get links from a page', async () => { + const userResponse = await request(app).post('/users').send({ + fullname: 'Test User', + email: 'testuser@linksforall.com', + password: '123456' + }); + + const user = userResponse.body; + + const page = await prisma.pages.create({ + data: { + slug: 'test-page', + title: 'Test Page', + description: 'Test description', + userId: user.id + } + }); + + await prisma.links.create({ + data: { + title: 'Link 1', + description: 'Description 1', + url: 'https://example.com/1', + type: 'link', + pageId: page.id + } + }); + + await prisma.links.create({ + data: { + title: 'Link 2', + description: 'Description 2', + url: 'https://example.com/2', + type: 'link', + pageId: page.id + } + }); + + const authResponse = await request(app).post('/auth').send({ + email: 'testuser@linksforall.com', + password: '123456' + }); + + const { token } = authResponse.body; + + const response = await request(app) + .get(`/pages/${page.id}/links`) + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toEqual(200); + expect(response.body).toHaveLength(2); + expect(response.body[0]).toHaveProperty('title'); + expect(response.body[0]).toHaveProperty('url'); + }); + + it('should return empty array when page has no links', async () => { + const userResponse = await request(app).post('/users').send({ + fullname: 'Test User', + email: 'testuser@linksforall.com', + password: '123456' + }); + + const user = userResponse.body; + + const page = await prisma.pages.create({ + data: { + slug: 'empty-page', + title: 'Empty Page', + description: 'Page with no links', + userId: user.id + } + }); + + const authResponse = await request(app).post('/auth').send({ + email: 'testuser@linksforall.com', + password: '123456' + }); + + const { token } = authResponse.body; + + const response = await request(app) + .get(`/pages/${page.id}/links`) + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toEqual(200); + expect(response.body).toHaveLength(0); + }); + + it('should return 404 when page does not exist', async () => { + await request(app).post('/users').send({ + fullname: 'Test User', + email: 'testuser@linksforall.com', + password: '123456' + }); + + const authResponse = await request(app).post('/auth').send({ + email: 'testuser@linksforall.com', + password: '123456' + }); + + const { token } = authResponse.body; + + const response = await request(app) + .get('/pages/00000000-0000-0000-0000-000000000000/links') + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toEqual(404); + }); +}); diff --git a/src/http/controllers/pages/get-links.ts b/src/http/controllers/pages/get-links.ts new file mode 100644 index 0000000..8041b2d --- /dev/null +++ b/src/http/controllers/pages/get-links.ts @@ -0,0 +1,25 @@ +import { ResourceNotFoundError } from '@/use-cases/errors/not-found-error'; +import { getPageLinksUseCase } from '@/use-cases/factories/get-page-links-factory'; +import { Request, Response } from 'express'; +import * as z from 'zod'; + +export async function getPageLinksController(req: Request, res: Response) { + const pageParams = z.object({ + id: z.string() + }); + + const pageUseCase = getPageLinksUseCase(); + + const { id } = pageParams.parse(req.params); + + try { + const { links } = await pageUseCase.execute(id); + + return res.status(200).send(links); + } catch (err) { + if (err instanceof ResourceNotFoundError) { + return res.status(404).send({ message: err.message }); + } + return err; + } +} diff --git a/src/http/controllers/pages/get-page.spec.ts b/src/http/controllers/pages/get-page.spec.ts new file mode 100644 index 0000000..6a81ba5 --- /dev/null +++ b/src/http/controllers/pages/get-page.spec.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import { app } from '@/app'; +import { prisma } from '@/lib/prisma'; + +describe('Get page test', () => { + beforeEach(async () => { + await prisma.pages.deleteMany({}); + await prisma.users.deleteMany({}); + }); + + it('should be able to get a page by id', async () => { + const userResponse = await request(app).post('/users').send({ + fullname: 'Test User', + email: 'testuser@linksforall.com', + password: '123456' + }); + + const user = userResponse.body; + + const page = await prisma.pages.create({ + data: { + slug: 'test-page', + title: 'Test Page', + description: 'Test description', + userId: user.id + } + }); + + const authResponse = await request(app).post('/auth').send({ + email: 'testuser@linksforall.com', + password: '123456' + }); + + const { token } = authResponse.body; + + const response = await request(app) + .get(`/pages/${page.id}`) + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toEqual(200); + expect(response.body).toHaveProperty('id'); + expect(response.body.slug).toEqual('test-page'); + }); + + it('should return 404 when page does not exist', async () => { + await request(app).post('/users').send({ + fullname: 'Test User', + email: 'testuser@linksforall.com', + password: '123456' + }); + + const authResponse = await request(app).post('/auth').send({ + email: 'testuser@linksforall.com', + password: '123456' + }); + + const { token } = authResponse.body; + + const response = await request(app) + .get('/pages/00000000-0000-0000-0000-000000000000') + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toEqual(404); + }); +}); diff --git a/src/http/controllers/pages/get-page.ts b/src/http/controllers/pages/get-page.ts new file mode 100644 index 0000000..a5f6034 --- /dev/null +++ b/src/http/controllers/pages/get-page.ts @@ -0,0 +1,25 @@ +import { ResourceNotFoundError } from '@/use-cases/errors/not-found-error'; +import { getPageUseCase } from '@/use-cases/factories/get-page-factory'; +import { Request, Response } from 'express'; +import * as z from 'zod'; + +export async function getPageController(req: Request, res: Response) { + const pageParams = z.object({ + id: z.string() + }); + + const pageUseCase = getPageUseCase(); + + const { id } = pageParams.parse(req.params); + + try { + const { page } = await pageUseCase.execute(id); + + return res.status(200).send(page); + } catch (err) { + if (err instanceof ResourceNotFoundError) { + return res.status(404).send({ message: err.message }); + } + return err; + } +} diff --git a/src/http/controllers/pages/update.spec.ts b/src/http/controllers/pages/update.spec.ts new file mode 100644 index 0000000..325d636 --- /dev/null +++ b/src/http/controllers/pages/update.spec.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import { app } from '@/app'; +import { prisma } from '@/lib/prisma'; + +describe('Update page test', () => { + beforeEach(async () => { + await prisma.pages.deleteMany({}); + await prisma.users.deleteMany({}); + }); + + it('should be able to update a page', async () => { + const userResponse = await request(app).post('/users').send({ + fullname: 'Test User', + email: 'testuser@linksforall.com', + password: '123456' + }); + + const user = userResponse.body; + + const page = await prisma.pages.create({ + data: { + slug: 'old-slug', + title: 'Old Title', + description: 'Old description', + userId: user.id + } + }); + + const authResponse = await request(app).post('/auth').send({ + email: 'testuser@linksforall.com', + password: '123456' + }); + + const { token } = authResponse.body; + + const response = await request(app) + .put(`/pages/${page.id}`) + .set('Authorization', `Bearer ${token}`) + .send({ + slug: 'updated-slug', + title: 'Updated Title', + description: 'Updated description' + }); + + expect(response.status).toEqual(200); + expect(response.body.slug).toEqual('updated-slug'); + }); + + it('should return 409 when trying to update to an existing slug', async () => { + const userResponse = await request(app).post('/users').send({ + fullname: 'Test User', + email: 'testuser@linksforall.com', + password: '123456' + }); + + const user = userResponse.body; + + await prisma.pages.create({ + data: { + slug: 'existing-slug', + title: 'Existing Page', + description: 'Existing description', + userId: user.id + } + }); + + const anotherUserResponse = await request(app).post('/users').send({ + fullname: 'Another User', + email: 'anotheruser@linksforall.com', + password: '123456' + }); + + const anotherUser = anotherUserResponse.body; + + const page = await prisma.pages.create({ + data: { + slug: 'another-slug', + title: 'Another Page', + description: 'Another description', + userId: anotherUser.id + } + }); + + const authResponse = await request(app).post('/auth').send({ + email: 'testuser@linksforall.com', + password: '123456' + }); + + const { token } = authResponse.body; + + const response = await request(app) + .put(`/pages/${page.id}`) + .set('Authorization', `Bearer ${token}`) + .send({ + slug: 'existing-slug' + }); + + expect(response.status).toEqual(409); + }); +}); diff --git a/src/http/controllers/pages/update.ts b/src/http/controllers/pages/update.ts new file mode 100644 index 0000000..62421df --- /dev/null +++ b/src/http/controllers/pages/update.ts @@ -0,0 +1,41 @@ +import { PageSlugAlreadyExistsError } from '@/use-cases/errors/page-slug-already-exists-error'; +import { updatePageUseCase } from '@/use-cases/factories/update-page-factory'; +import { Request, Response } from 'express'; +import * as z from 'zod'; + +export async function updatePageController(req: Request, res: Response) { + const updatePageSchema = z.object({ + slug: z.string().optional(), + title: z.string().optional(), + description: z.string().optional(), + settings: z.object({}).optional() + }); + + const urlParamsSchema = z.object({ + id: z.string() + }); + + const { id } = urlParamsSchema.parse(req.params); + const { slug, title, description, settings } = updatePageSchema.parse(req.body); + + const pageUseCase = updatePageUseCase(); + + try { + const { page } = await pageUseCase.execute({ + id, + slug, + title, + description, + settings + }); + + return res.status(200).send(page); + + } catch (err) { + if (err instanceof PageSlugAlreadyExistsError) { + return res.status(409).send({ message: err.message }); + } + + return err; + } +} diff --git a/src/http/controllers/users-controller.ts b/src/http/controllers/users-controller.ts deleted file mode 100644 index 7cf476a..0000000 --- a/src/http/controllers/users-controller.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { exclude } from '@/utils/exclude-keys'; -import { Request, Response } from 'express'; - -import UserModel, { UserOrderBy } from '@/use-cases/users-use-case'; -import { encryptString } from '@/utils/encrypt-string'; -import { UsersUncheckedUpdateWithoutPageInput } from 'prisma/generated/models'; - -class UserController { - async index(req: Request, res: Response) { - const { orderBy } = req.query; - - const users = await UserModel.findAll(orderBy as UserOrderBy ?? 'desc'); - const usersWithoutPassword = users.map(user => exclude(user, ['password'])); - - return res.send(usersWithoutPassword).status(200); - } - - async show(req: Request<{id: string}>, res: Response) { - const { id } = req.params; - - const user = await UserModel.findById(id); - - if(!user) { - res.status(404).send({ - error: 'User not found' - }); - } - - const userWithoutPassword = user && exclude(user, ['password']); - - return res.send(userWithoutPassword).status(200); - } - - async store(req: Request, res: Response) { - const { - fullname, - password, - email - } = req.body; - - if(email.trim() == '' || password.trim() == '') { - return res.status(400).send({ - error: 'Some fields are missing' - }); - } - - const hashPassword = await encryptString(password); - - const userAlreadyExists = await UserModel.findByEmail(email); - - if(userAlreadyExists) { - return res.status(409).send({ - error: 'The e-mail already exists.' - }); - } - - const newUser = await UserModel.create({ - fullname, - password: hashPassword, - email - }); - - const newUserWithoutPassword = exclude(newUser, ['password']); - return res.send(newUserWithoutPassword).status(201); - } - - async update(req: Request, res: Response) { - const { id } = req.params; - const { - email, - fullname, - password, - profile_photo - }: UsersUncheckedUpdateWithoutPageInput = req.body; - - const userExists = await UserModel.findById(id); - - if(!userExists) { - return res.status(404).send({ - error: 'User not found' - }); - } - - if(!fullname) { - return res.status(400).send({ - error: 'Fullname is required' - }); - } - - const userEmail = await UserModel.findByEmail(email); - if(userEmail && id !== userEmail.id) { - return res.status(400).send({ - error: 'This e-mail is already in use', - }); - } - - const dataToUpdate:UsersUncheckedUpdateWithoutPageInput = { - email, - fullname, - profile_photo - }; - - if (password) { - const hashPassword = await encryptString(password.toString()); - dataToUpdate.password = hashPassword; - } - - await UserModel.update(id, dataToUpdate); - - return res.status(204).send(); - } - - async delete(req: Request, res: Response) { - const { id } = req.params; - await UserModel.delete(id); - - return res.status(202).send(); - } -} - -export default new UserController(); diff --git a/src/http/controllers/users/authenticate.spec.ts b/src/http/controllers/users/authenticate.spec.ts new file mode 100644 index 0000000..478850d --- /dev/null +++ b/src/http/controllers/users/authenticate.spec.ts @@ -0,0 +1,27 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import { app } from '@/app'; +import { prisma } from '@/lib/prisma'; + +describe('Authenticate E2E test', () => { + beforeEach(async () => { + await prisma.users.deleteMany({}); + }); + + it('should be able to authenticate', async () => { + + await request(app).post('/users').send({ + fullname: 'Manager', + email: 'authenticateuser@linksforall.com', + password: '123456' + }).set('Accept', 'application/json'); + + const response = await request(app).post('/auth').send({ + email: 'authenticateuser@linksforall.com', + password: '123456' + }).set('Accept', 'application/json'); + + expect(response.status).toEqual(200); + expect(response.body.token).toBeDefined(); + }); +}); diff --git a/src/http/controllers/users/authenticate.ts b/src/http/controllers/users/authenticate.ts new file mode 100644 index 0000000..98be1a0 --- /dev/null +++ b/src/http/controllers/users/authenticate.ts @@ -0,0 +1,35 @@ +import { InvalidCredentialsError } from '@/use-cases/errors/invalid-credentials-error'; +import { userAuthenticateUseCase } from '@/use-cases/factories/authenticate-user-factory'; +import { Request, Response } from 'express'; +import * as z from 'zod'; +import jwt from 'jsonwebtoken'; +import { env } from '@/env'; + +export async function authenticateUserController(req: Request, res: Response) { + const authenticateBodySchema = z.object({ + email: z.email(), + password: z.string().min(6) + }); + + const authenticateUseCase = userAuthenticateUseCase(); + + const { email, password } = authenticateBodySchema.parse(req.body); + + try { + const { user } = await authenticateUseCase.execute({ email, password }); + + const token = jwt.sign({ + sub: user.id, + role: user.role + }, env.JWT_SECRET); + + return res.status(200).json({ token }); + + } catch (err) { + if (err instanceof InvalidCredentialsError) { + return res.status(400).send({ message: err.message }); + } + + return err; + } +} diff --git a/src/http/controllers/users/create.spec.ts b/src/http/controllers/users/create.spec.ts new file mode 100644 index 0000000..d282099 --- /dev/null +++ b/src/http/controllers/users/create.spec.ts @@ -0,0 +1,20 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import { app } from '@/app'; +import { prisma } from '@/lib/prisma'; + +describe('Create user test', () => { + beforeEach(async () => { + await prisma.users.deleteMany({}); + }); + + it('should be able to create an user', async () => { + const user = await request(app).post('/users').send({ + fullname: 'New user', + email: 'createuser@linksforall.com', + password: '123456' + }).set('Accept', 'application/json'); + + expect(user.status).toEqual(201); + }); +}); diff --git a/src/http/controllers/users/create.ts b/src/http/controllers/users/create.ts new file mode 100644 index 0000000..dc82f07 --- /dev/null +++ b/src/http/controllers/users/create.ts @@ -0,0 +1,35 @@ +import { EmailAlreadyExistsError } from '@/use-cases/errors/email-already-exists-error'; +import { createUserUseCase } from '@/use-cases/factories/create-user-factory'; +import { Request, Response } from 'express'; +import * as z from 'zod'; + +export async function createUserController(req: Request, res: Response) { + const createUserSchema = z.object({ + fullname: z.string(), + email: z.email(), + password: z.string().min(6) + + }); + + const userUseCase = createUserUseCase(); + + const { fullname, email, password } = createUserSchema.parse(req.body); + + try { + const { user } = await userUseCase.execute({ + fullname, + email, + password + }); + + delete user.password; + + return res.status(201).send(user); + + } catch (err) { + if (err instanceof EmailAlreadyExistsError) { + return res.status(409).send({ message: err.message }); + } + return err; + } +} diff --git a/src/http/controllers/users/delete.spec.ts b/src/http/controllers/users/delete.spec.ts new file mode 100644 index 0000000..0410386 --- /dev/null +++ b/src/http/controllers/users/delete.spec.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import { app } from '@/app'; +import { prisma } from '@/lib/prisma'; + +describe('Delete user test', () => { + beforeEach(async () => { + await prisma.users.deleteMany({}); + }); + + it('should be able to delete an user', async () => { + const newUser = await request(app).post('/users').send({ + fullname: 'New user', + email: 'deleteuser@linksforall.com', + password: '123456' + }).set('Accept', 'application/json'); + + const authenticatedUser = await request(app).post('/auth').send({ + email: 'deleteuser@linksforall.com', + password: '123456' + }).set('Accept', 'application/json'); + + const deletedUserRequest = await request(app).delete(`/users/${newUser.body.id}`) + .set({ + 'authorization': `Bearer ${authenticatedUser.body.token}` + }); + + expect(deletedUserRequest.status).toEqual(204); + }); +}); diff --git a/src/http/controllers/users/delete.ts b/src/http/controllers/users/delete.ts new file mode 100644 index 0000000..f96a533 --- /dev/null +++ b/src/http/controllers/users/delete.ts @@ -0,0 +1,29 @@ +import { ResourceNotFoundError } from '@/use-cases/errors/not-found-error'; +import { deleteUserUseCase } from '@/use-cases/factories/delete-user-factory'; +import { Request, Response } from 'express'; +import * as z from 'zod'; + +export async function deleteUserController(req: Request, res: Response) { + const paramsSchema = z.object({ + id: z.uuid(), + }); + + const { id } = paramsSchema.parse(req.params); + + if (id !== req.user?.id && req.user?.role !== 'ADMIN') { + return res.status(403).send(); + } + const userUseCase = deleteUserUseCase(); + + try { + await userUseCase.execute(id); + } catch (err) { + if (err instanceof ResourceNotFoundError) { + return res.status(409).send({ message: err.message }); + } + return err; + } + + return res.status(204).send(); + +} diff --git a/src/http/controllers/users/profile.spec.ts b/src/http/controllers/users/profile.spec.ts new file mode 100644 index 0000000..e9e7149 --- /dev/null +++ b/src/http/controllers/users/profile.spec.ts @@ -0,0 +1,59 @@ +import { app } from '@/app'; +import { prisma } from '@/lib/prisma'; +import request from 'supertest'; +import { beforeEach, describe, expect, it } from 'vitest'; + +describe('Get user profile test', () => { + beforeEach(async () => { + await prisma.users.deleteMany({}); + }); + + it('should be able to get user info', async () => { + const newUser = await request(app).post('/users').send({ + fullname: 'New user', + email: 'userprofile01@linksforall.com', + password: '123456' + }).set('Accept', 'application/json'); + + const authenticatedUser = await request(app).post('/auth').send({ + email: 'userprofile01@linksforall.com', + password: '123456' + }).set('Accept', 'application/json'); + + const user = await request(app).get(`/users/${newUser.body.id}`).set({ + 'authorization': `Bearer ${authenticatedUser.body.token}` + }); + + expect(user.body).toMatchObject({ + fullname: 'New user', + email: 'userprofile01@linksforall.com' + }); + + expect(user.status).toEqual(200); + }); + + it('should not be able to get user info from non logged user', async () => { + await request(app).post('/users').send({ + fullname: 'New user 01', + email: 'newuser01@linksforall.com', + password: '123456' + }).set('Accept', 'application/json'); + + const anotherUser = await request(app).post('/users').send({ + fullname: 'New user 02', + email: 'newuser02@linksforall.com', + password: '123456' + }).set('Accept', 'application/json'); + + const authenticatedUser = await request(app).post('/auth').send({ + email: 'newuser01@linksforall.com', + password: '123456' + }).set('Accept', 'application/json'); + + const user = await request(app).get(`/users/${anotherUser.body.id}`).set({ + 'authorization': `Bearer ${authenticatedUser.body.token}` + }); + + expect(user.status).toEqual(403); + }); +}); diff --git a/src/http/controllers/users/profile.ts b/src/http/controllers/users/profile.ts new file mode 100644 index 0000000..df217f2 --- /dev/null +++ b/src/http/controllers/users/profile.ts @@ -0,0 +1,30 @@ +import { ResourceNotFoundError } from '@/use-cases/errors/not-found-error'; +import { getUserProfileUseCase } from '@/use-cases/factories/get-user-factory'; +import { Request, Response } from 'express'; +import * as z from 'zod'; + +export async function getUserProfileController(req: Request, res: Response) { + const userParams = z.object({ + id: z.coerce.string() + }); + + const userUseCase = getUserProfileUseCase(); + + const { id } = userParams.parse(req.params); + + if (id !== req.user?.id) { + return res.status(403).send(); + } + + try { + const { user } = await userUseCase.execute({id}); + delete user.password; + + return res.status(200).send(user); + } catch (err) { + if (err instanceof ResourceNotFoundError) { + return res.status(404).send({ message: err.message }); + } + return err; + } +} diff --git a/src/http/controllers/users/update.spec.ts b/src/http/controllers/users/update.spec.ts new file mode 100644 index 0000000..7e563d4 --- /dev/null +++ b/src/http/controllers/users/update.spec.ts @@ -0,0 +1,32 @@ +import { app } from '@/app'; +import { prisma } from '@/lib/prisma'; +import request from 'supertest'; +import { beforeEach, describe, expect, it } from 'vitest'; + +describe('Update user test', () => { + beforeEach(async () => { + await prisma.users.deleteMany({}); + }); + + it('should be able to update an user', async () => { + const newUser = await request(app).post('/users').send({ + fullname: 'New user', + email: 'updateuser@linksforall.com', + password: '123456' + }).set('Accept', 'application/json'); + + const authenticatedUser = await request(app).post('/auth').send({ + email: 'updateuser@linksforall.com', + password: '123456' + }).set('Accept', 'application/json'); + + const updateUser = await request(app).patch(`/users/${newUser.body.id}`) + .send({ + email: 'newemail@linksforall.com' + }).set({ + 'authorization': `Bearer ${authenticatedUser.body.token}` + }); + + expect(updateUser.body.email).toEqual('newemail@linksforall.com'); + }); +}); diff --git a/src/http/controllers/users/update.ts b/src/http/controllers/users/update.ts new file mode 100644 index 0000000..c92a939 --- /dev/null +++ b/src/http/controllers/users/update.ts @@ -0,0 +1,49 @@ +import { ResourceNotFoundError } from '@/use-cases/errors/not-found-error'; +import { updateUserUseCase } from '@/use-cases/factories/update-user-factory'; +import { Request, Response } from 'express'; +import * as z from 'zod'; + +export async function updateUserController(req: Request, res: Response) { + const updateUserSchema = z.object({ + fullname: z.string().optional(), + email: z.email().optional(), + password: z.string().min(6).optional(), + profile_photo: z.string().optional() + }); + + const urlParamsSchema = z.object({ + id: z.uuid() + }); + + const { id } = urlParamsSchema.parse(req.params); + const { fullname, email, password, profile_photo } = updateUserSchema.parse(req.body); + + const userUseCase = updateUserUseCase(); + + if (id !== req.user?.id && req.user?.role !== 'ADMIN') { + return res.status(403).send(); + } + + try { + const {user} = await userUseCase.execute({ + id, + data: { + fullname, + email, + password, + profile_photo + } + }); + + delete user.password; + + return res.status(200).send(user); + + } catch (err) { + if (err instanceof ResourceNotFoundError) { + return res.status(404).send({ message: err.message }); + } + + return err; + } +} diff --git a/src/http/routes.ts b/src/http/routes.ts index 1646878..0f13e31 100644 --- a/src/http/routes.ts +++ b/src/http/routes.ts @@ -1,33 +1,42 @@ -import { Router } from 'express'; -import UserController from '@/http/controllers/users-controller'; -import PageController from '@/http/controllers/pages-controller'; -import LinkController from '@/http/controllers/links-controller'; -import AuthController from '@/http/controllers/auth-controller'; import { authMiddleware } from '@/middlewares/auth-middleware'; -import { checkUserRoleMiddleware } from '@/middlewares/check-user-role-middleware'; +import { Router } from 'express'; +import { authenticateUserController } from './controllers/users/authenticate'; +import { createUserController } from './controllers/users/create'; +import { deleteUserController } from './controllers/users/delete'; +import { getUserProfileController } from './controllers/users/profile'; +import { updateUserController } from './controllers/users/update'; +import { createPageController } from './controllers/pages/create'; +import { getPageController } from './controllers/pages/get-page'; +import { updatePageController } from './controllers/pages/update'; +import { deletePageController } from './controllers/pages/delete'; +import { getPageLinksController } from './controllers/pages/get-links'; +import { getLinkController } from './controllers/links/show'; +import { createLinkController } from './controllers/links/create'; +import { updateLinkController } from './controllers/links/update'; +import { deleteLinkController } from './controllers/links/delete'; const router = Router(); // User routes -router.post('/users', UserController.store); -router.get('/users', [authMiddleware, checkUserRoleMiddleware('ADMIN')], UserController.index); -router.get('/users/:id', authMiddleware, UserController.show); -router.put('/users/:id', authMiddleware, UserController.update); -router.delete('/users/:id', authMiddleware, UserController.delete); +router.post('/users', createUserController); +router.patch('/users/:id', authMiddleware, updateUserController); +router.delete('/users/:id', authMiddleware, deleteUserController); +router.get('/users/:id', authMiddleware, getUserProfileController); // Pages routes -router.post('/pages', authMiddleware, PageController.store); -router.get('/pages/:id', authMiddleware, PageController.show); -router.put('/pages/:id', authMiddleware, PageController.update); -router.get('/pages/:id/links', authMiddleware, PageController.links); +router.post('/pages', authMiddleware, createPageController); +router.get('/pages/:id', authMiddleware, getPageController); +router.get('/pages/:id/links', authMiddleware, getPageLinksController); +router.put('/pages/:id', authMiddleware, updatePageController); +router.delete('/pages/:id', authMiddleware, deletePageController); // Links routes -router.get('/links/:id', LinkController.show); -router.post('/links', LinkController.store); -router.put('/links/:id', LinkController.update); -router.delete('/links/:id', LinkController.delete); +router.get('/links/:id', getLinkController); +router.post('/links', createLinkController); +router.put('/links/:id', updateLinkController); +router.delete('/links/:id', deleteLinkController); // Auth -router.post('/auth', AuthController.authenticate); +router.post('/auth', authenticateUserController); export default router; diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index f7e5ff1..b40a34b 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -1,10 +1,14 @@ import 'dotenv/config'; import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaClient } from 'prisma/generated/client'; +import { env } from '@/env'; -const connectionString = `${process.env.DATABASE_URL}`; +const connectionString = env.DATABASE_URL; const adapter = new PrismaPg({ connectionString }); -const prisma = new PrismaClient({ adapter }); +const prisma = new PrismaClient({ + adapter, + log: env.NODE_ENV === 'development' ? ['query'] : [] +}); export { prisma }; diff --git a/src/middlewares/auth-middleware.ts b/src/middlewares/auth-middleware.ts index dc36c90..65c8202 100644 --- a/src/middlewares/auth-middleware.ts +++ b/src/middlewares/auth-middleware.ts @@ -10,7 +10,7 @@ declare module 'express' { } } interface TokenPayloadProps { - id: string; + sub: string; role: 'ADMIN' | 'USER' } @@ -29,11 +29,11 @@ function authMiddleware(req: Request, res: Response, next: NextFunction) { try { const data = jwt.verify(token, process.env.JWT_SECRET); - const { id, role } = data as TokenPayloadProps; - res.setHeader('userId', id); + const { sub, role } = data as TokenPayloadProps; + res.setHeader('userId', sub); req.user = { - id, + id: sub, role }; diff --git a/src/repositories/in-memory/im-pages-respository.ts b/src/repositories/in-memory/im-pages-respository.ts index 070634c..722e9f3 100644 --- a/src/repositories/in-memory/im-pages-respository.ts +++ b/src/repositories/in-memory/im-pages-respository.ts @@ -1,10 +1,11 @@ -import { Pages } from 'prisma/generated/client'; +import { Links, Pages } from 'prisma/generated/client'; import { PagesUncheckedCreateInput } from 'prisma/generated/models'; import { PagesRepository } from '../pages-repository'; import { randomUUID } from 'node:crypto'; export class InMemoryPagesRepository implements PagesRepository { public pages: Pages[] = []; + public links: Links[] = []; async findBySlug(slug: string) { return this.pages.find(page => page.slug === slug) ?? null; @@ -29,6 +30,10 @@ export class InMemoryPagesRepository implements PagesRepository { return this.pages.find(page => page.id === id) ?? null; } + async findPageLinks(pageId: string) { + return this.links.filter(link => link.pageId === pageId); + } + async delete(id: string) { const page = this.pages.find(item => item.id === id); diff --git a/src/repositories/pages-repository.ts b/src/repositories/pages-repository.ts index c94b121..e8dbb1b 100644 --- a/src/repositories/pages-repository.ts +++ b/src/repositories/pages-repository.ts @@ -1,10 +1,11 @@ -import { Pages } from 'prisma/generated/client'; +import { Links, Pages } from 'prisma/generated/client'; import { PagesUncheckedCreateInput, PagesUncheckedUpdateInput } from 'prisma/generated/models'; export interface PagesRepository { create(data: PagesUncheckedCreateInput): Promise findBySlug(slug: string): Promise findById(id: string): Promise + findPageLinks(pageId: string): Promise delete(id: string): Promise update(data: PagesUncheckedUpdateInput): Promise } diff --git a/src/repositories/prisma/prisma-pages-repository.ts b/src/repositories/prisma/prisma-pages-repository.ts index d84c073..6b6e1b2 100644 --- a/src/repositories/prisma/prisma-pages-repository.ts +++ b/src/repositories/prisma/prisma-pages-repository.ts @@ -31,6 +31,19 @@ export class PrismaPagesRepository implements PagesRepository { return page; } + async findPageLinks(pageId: string) { + const links = await prisma.links.findMany({ + where: { + pageId + }, + orderBy: { + createdAt: 'desc' + } + }); + + return links; + } + async delete(id: string) { await prisma.pages.delete({ where: { diff --git a/src/repositories/prisma/prisma-users-repository.ts b/src/repositories/prisma/prisma-users-repository.ts index 8350662..81fd65d 100644 --- a/src/repositories/prisma/prisma-users-repository.ts +++ b/src/repositories/prisma/prisma-users-repository.ts @@ -52,7 +52,7 @@ export class PrismaUsersRepository implements UsersRepository { } async delete(id: string) { - prisma.users.delete({ + await prisma.users.delete({ where: { id } diff --git a/src/use-cases/factories/authenticate-user-factory.ts b/src/use-cases/factories/authenticate-user-factory.ts new file mode 100644 index 0000000..fe85fa9 --- /dev/null +++ b/src/use-cases/factories/authenticate-user-factory.ts @@ -0,0 +1,9 @@ +import { PrismaUsersRepository } from '@/repositories/prisma/prisma-users-repository'; +import { AuthenticateUserUseCase } from '../users/authenticate-user'; + +export function userAuthenticateUseCase() { + const userRepository = new PrismaUsersRepository(); + const authenticateUseCase = new AuthenticateUserUseCase(userRepository); + + return authenticateUseCase; +} diff --git a/src/use-cases/factories/create-link-factory.ts b/src/use-cases/factories/create-link-factory.ts new file mode 100644 index 0000000..5e1c9c2 --- /dev/null +++ b/src/use-cases/factories/create-link-factory.ts @@ -0,0 +1,11 @@ +import { PrismaPagesRepository } from '@/repositories/prisma/prisma-pages-repository'; +import { PrismaLinksRepository } from '@/repositories/prisma/prisma-links-repository'; +import { CreateLinkUseCase } from '../links/create-link'; + +export function createLinkUseCase() { + const linksRepository = new PrismaLinksRepository(); + const pagesRepository = new PrismaPagesRepository(); + const linkUseCase = new CreateLinkUseCase(linksRepository, pagesRepository); + + return linkUseCase; +} diff --git a/src/use-cases/factories/create-page-factory.ts b/src/use-cases/factories/create-page-factory.ts new file mode 100644 index 0000000..8fe79be --- /dev/null +++ b/src/use-cases/factories/create-page-factory.ts @@ -0,0 +1,9 @@ +import { PrismaPagesRepository } from '@/repositories/prisma/prisma-pages-repository'; +import { CreatePageUseCase } from '../pages/create-page'; + +export function createPageUseCase() { + const pageRepository = new PrismaPagesRepository(); + const pageUseCase = new CreatePageUseCase(pageRepository); + + return pageUseCase; +} diff --git a/src/use-cases/factories/create-user-factory.ts b/src/use-cases/factories/create-user-factory.ts new file mode 100644 index 0000000..dfb552c --- /dev/null +++ b/src/use-cases/factories/create-user-factory.ts @@ -0,0 +1,9 @@ +import { PrismaUsersRepository } from '@/repositories/prisma/prisma-users-repository'; +import { CreateUserUseCase } from '../users/create-user'; + +export function createUserUseCase() { + const userRepository = new PrismaUsersRepository(); + const userUseCase = new CreateUserUseCase(userRepository); + + return userUseCase; +} diff --git a/src/use-cases/factories/delete-link-factory.ts b/src/use-cases/factories/delete-link-factory.ts new file mode 100644 index 0000000..8999eba --- /dev/null +++ b/src/use-cases/factories/delete-link-factory.ts @@ -0,0 +1,9 @@ +import { PrismaLinksRepository } from '@/repositories/prisma/prisma-links-repository'; +import { DeleteLinkUseCase } from '../links/delete-link'; + +export function deleteLinkUseCase() { + const linksRepository = new PrismaLinksRepository(); + const linkUseCase = new DeleteLinkUseCase(linksRepository); + + return linkUseCase; +} diff --git a/src/use-cases/factories/delete-page-factory.ts b/src/use-cases/factories/delete-page-factory.ts new file mode 100644 index 0000000..150f093 --- /dev/null +++ b/src/use-cases/factories/delete-page-factory.ts @@ -0,0 +1,9 @@ +import { PrismaPagesRepository } from '@/repositories/prisma/prisma-pages-repository'; +import { DeletePageUseCase } from '../pages/delete-page'; + +export function deletePageUseCase() { + const pageRepository = new PrismaPagesRepository(); + const pageUseCase = new DeletePageUseCase(pageRepository); + + return pageUseCase; +} diff --git a/src/use-cases/factories/delete-page-repository.ts b/src/use-cases/factories/delete-page-repository.ts new file mode 100644 index 0000000..150f093 --- /dev/null +++ b/src/use-cases/factories/delete-page-repository.ts @@ -0,0 +1,9 @@ +import { PrismaPagesRepository } from '@/repositories/prisma/prisma-pages-repository'; +import { DeletePageUseCase } from '../pages/delete-page'; + +export function deletePageUseCase() { + const pageRepository = new PrismaPagesRepository(); + const pageUseCase = new DeletePageUseCase(pageRepository); + + return pageUseCase; +} diff --git a/src/use-cases/factories/delete-user-factory.ts b/src/use-cases/factories/delete-user-factory.ts new file mode 100644 index 0000000..cc0301e --- /dev/null +++ b/src/use-cases/factories/delete-user-factory.ts @@ -0,0 +1,9 @@ +import { PrismaUsersRepository } from '@/repositories/prisma/prisma-users-repository'; +import { DeleteUserUseCase } from '../users/delete-user'; + +export function deleteUserUseCase() { + const userRepository = new PrismaUsersRepository(); + const userUseCase = new DeleteUserUseCase(userRepository); + + return userUseCase; +} diff --git a/src/use-cases/factories/get-link-factory.ts b/src/use-cases/factories/get-link-factory.ts new file mode 100644 index 0000000..3c6ba3e --- /dev/null +++ b/src/use-cases/factories/get-link-factory.ts @@ -0,0 +1,9 @@ +import { PrismaLinksRepository } from '@/repositories/prisma/prisma-links-repository'; +import { GetLinkUseCase } from '../links/get-link'; + +export function getLinkUseCase() { + const linkRepository = new PrismaLinksRepository(); + const linkUseCase = new GetLinkUseCase(linkRepository); + + return linkUseCase; +} diff --git a/src/use-cases/factories/get-page-factory.ts b/src/use-cases/factories/get-page-factory.ts new file mode 100644 index 0000000..f8509a2 --- /dev/null +++ b/src/use-cases/factories/get-page-factory.ts @@ -0,0 +1,9 @@ +import { PrismaPagesRepository } from '@/repositories/prisma/prisma-pages-repository'; +import { GetPageUseCase } from '../pages/get-page'; + +export function getPageUseCase() { + const pageRepository = new PrismaPagesRepository(); + const pageUseCase = new GetPageUseCase(pageRepository); + + return pageUseCase; +} diff --git a/src/use-cases/factories/get-page-links-factory.ts b/src/use-cases/factories/get-page-links-factory.ts new file mode 100644 index 0000000..50ab868 --- /dev/null +++ b/src/use-cases/factories/get-page-links-factory.ts @@ -0,0 +1,9 @@ +import { PrismaPagesRepository } from '@/repositories/prisma/prisma-pages-repository'; +import { GetPageLinksUseCase } from '../pages/get-page-links'; + +export function getPageLinksUseCase() { + const pageRepository = new PrismaPagesRepository(); + const pageUseCase = new GetPageLinksUseCase(pageRepository); + + return pageUseCase; +} diff --git a/src/use-cases/factories/get-user-factory.ts b/src/use-cases/factories/get-user-factory.ts new file mode 100644 index 0000000..3228ca3 --- /dev/null +++ b/src/use-cases/factories/get-user-factory.ts @@ -0,0 +1,9 @@ +import { PrismaUsersRepository } from '@/repositories/prisma/prisma-users-repository'; +import { GetUserProfileUseCase } from '../users/get-user-profile'; + +export function getUserProfileUseCase() { + const userRepository = new PrismaUsersRepository(); + const userUseCase = new GetUserProfileUseCase(userRepository); + + return userUseCase; +} diff --git a/src/use-cases/factories/update-link-factory.ts b/src/use-cases/factories/update-link-factory.ts new file mode 100644 index 0000000..8caabab --- /dev/null +++ b/src/use-cases/factories/update-link-factory.ts @@ -0,0 +1,9 @@ +import { PrismaLinksRepository } from '@/repositories/prisma/prisma-links-repository'; +import { UpdateLinkUseCase } from '../links/update-link'; + +export function updateLinkUseCase() { + const linksRepository = new PrismaLinksRepository(); + const linkUseCase = new UpdateLinkUseCase(linksRepository); + + return linkUseCase; +} diff --git a/src/use-cases/factories/update-page-factory.ts b/src/use-cases/factories/update-page-factory.ts new file mode 100644 index 0000000..2bbc978 --- /dev/null +++ b/src/use-cases/factories/update-page-factory.ts @@ -0,0 +1,9 @@ +import { PrismaPagesRepository } from '@/repositories/prisma/prisma-pages-repository'; +import { UpdatePageUseCase } from '../pages/update-page'; + +export function updatePageUseCase() { + const pageRepository = new PrismaPagesRepository(); + const pageUseCase = new UpdatePageUseCase(pageRepository); + + return pageUseCase; +} diff --git a/src/use-cases/factories/update-user-factory.ts b/src/use-cases/factories/update-user-factory.ts new file mode 100644 index 0000000..4d38074 --- /dev/null +++ b/src/use-cases/factories/update-user-factory.ts @@ -0,0 +1,9 @@ +import { PrismaUsersRepository } from '@/repositories/prisma/prisma-users-repository'; +import { UpdateUserUseCase } from '../users/update-user'; + +export function updateUserUseCase() { + const userRepository = new PrismaUsersRepository(); + const userUseCase = new UpdateUserUseCase(userRepository); + + return userUseCase; +} diff --git a/src/use-cases/links/delete-link.spec.ts b/src/use-cases/links/delete-link.spec.ts index ec9cb96..605ec26 100644 --- a/src/use-cases/links/delete-link.spec.ts +++ b/src/use-cases/links/delete-link.spec.ts @@ -2,15 +2,21 @@ import { InMemoryLinksRepository } from '@/repositories/in-memory/im-links-repos import { InMemoryPagesRepository } from '@/repositories/in-memory/im-pages-respository'; import { faker } from '@faker-js/faker/locale/pt_BR'; import { randomUUID } from 'node:crypto'; -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { DeleteLinkUseCase } from './delete-link'; +import { ResourceNotFoundError } from '../errors/not-found-error'; describe('Delete links use case', () => { + let inMemoryLinksRepository: InMemoryLinksRepository; + let deleteLinkUseCase: DeleteLinkUseCase; + + beforeEach(() => { + inMemoryLinksRepository = new InMemoryLinksRepository(); + deleteLinkUseCase = new DeleteLinkUseCase(inMemoryLinksRepository); + }); it('should be able to delete a link', async () => { - const inMemoryLinksRepository = new InMemoryLinksRepository(); const inMemoryPagesRepository = new InMemoryPagesRepository(); - const deleteLinkUseCase = new DeleteLinkUseCase(inMemoryLinksRepository); await inMemoryPagesRepository.create({ id: 'page-001', @@ -30,4 +36,10 @@ describe('Delete links use case', () => { await expect(deleteLinkUseCase.execute(link.id)).resolves.toBeUndefined(); }); + + it('should throw error when trying to delete non-existent link', async () => { + await expect(() => + deleteLinkUseCase.execute('non-existent-id') + ).rejects.toBeInstanceOf(ResourceNotFoundError); + }); }); diff --git a/src/use-cases/links/delete-link.ts b/src/use-cases/links/delete-link.ts index 58afb07..61bb88a 100644 --- a/src/use-cases/links/delete-link.ts +++ b/src/use-cases/links/delete-link.ts @@ -1,9 +1,16 @@ import { LinksRepository } from '@/repositories/links-repository'; +import { ResourceNotFoundError } from '../errors/not-found-error'; export class DeleteLinkUseCase { constructor(private linksRepository: LinksRepository) {} async execute(id: string) { + const link = await this.linksRepository.findById(id); + + if (!link) { + throw new ResourceNotFoundError('Link not found'); + } + await this.linksRepository.delete(id); } } diff --git a/src/use-cases/links/get-link.spec.ts b/src/use-cases/links/get-link.spec.ts new file mode 100644 index 0000000..7961952 --- /dev/null +++ b/src/use-cases/links/get-link.spec.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { InMemoryLinksRepository } from '@/repositories/in-memory/im-links-repository'; +import { GetLinkUseCase } from './get-link'; +import { ResourceNotFoundError } from '../errors/not-found-error'; + +describe('Get Link Use Case', () => { + let linksRepository: InMemoryLinksRepository; + let sut: GetLinkUseCase; + + beforeEach(() => { + linksRepository = new InMemoryLinksRepository(); + sut = new GetLinkUseCase(linksRepository); + }); + + it('should be able to get a link by id', async () => { + const createdLink = await linksRepository.create({ + title: 'Test Link', + description: 'Test Description', + url: 'https://example.com', + type: 'link', + pageId: 'page-01' + }); + + const { link } = await sut.execute(createdLink.id); + + expect(link.id).toEqual(createdLink.id); + expect(link.title).toEqual('Test Link'); + }); + + it('should throw error when link does not exist', async () => { + await expect(() => + sut.execute('non-existent-link-id') + ).rejects.toBeInstanceOf(ResourceNotFoundError); + }); +}); diff --git a/src/use-cases/links/get-link.ts b/src/use-cases/links/get-link.ts new file mode 100644 index 0000000..89ae975 --- /dev/null +++ b/src/use-cases/links/get-link.ts @@ -0,0 +1,16 @@ +import { LinksRepository } from '@/repositories/links-repository'; +import { ResourceNotFoundError } from '../errors/not-found-error'; + +export class GetLinkUseCase { + constructor(private linksRepository: LinksRepository) { } + + async execute(id: string) { + const link = await this.linksRepository.findById(id); + + if (!link) { + throw new ResourceNotFoundError('Link not found'); + } + + return { link }; + } +} diff --git a/src/use-cases/links/update-link.ts b/src/use-cases/links/update-link.ts index 196379d..d129cc4 100644 --- a/src/use-cases/links/update-link.ts +++ b/src/use-cases/links/update-link.ts @@ -4,11 +4,11 @@ import { ResourceNotFoundError } from '../errors/not-found-error'; interface UpdateLinkUseCaseRequest { id: string, data: { - title: string - description: string - url: string - pageId: string - type: string + title?: string + description?: string + url?: string + pageId?: string + type?: string } } @@ -16,13 +16,22 @@ export class UpdateLinkUseCase { constructor(private linksRepository: LinksRepository) { } async execute({ id, data }: UpdateLinkUseCaseRequest) { - const doesLinkExists = await this.linksRepository.findById(id); + const currentLink = await this.linksRepository.findById(id); - if (!doesLinkExists) { + if (!currentLink) { throw new ResourceNotFoundError('Link not found'); } - const link = await this.linksRepository.update(id, data); + // Merge current data with updates + const updatedData = { + title: data.title ?? currentLink.title, + description: data.description ?? currentLink.description, + url: data.url ?? currentLink.url, + pageId: data.pageId ?? currentLink.pageId, + type: data.type ?? currentLink.type + }; + + const link = await this.linksRepository.update(id, updatedData); return { link }; } diff --git a/src/use-cases/pages/delete-page.spec.ts b/src/use-cases/pages/delete-page.spec.ts index 84b185b..7a160b6 100644 --- a/src/use-cases/pages/delete-page.spec.ts +++ b/src/use-cases/pages/delete-page.spec.ts @@ -26,6 +26,6 @@ describe('Delete page use case', () => { const inMemoryPageRepository = new InMemoryPagesRepository(); const deletePageUseCase = new DeletePageUseCase(inMemoryPageRepository); - expect(deletePageUseCase.execute('non-created-page-id')).rejects.toBeInstanceOf(ResourceNotFoundError); + await expect(deletePageUseCase.execute('non-created-page-id')).rejects.toBeInstanceOf(ResourceNotFoundError); }); }); diff --git a/src/use-cases/pages/get-page-links.spec.ts b/src/use-cases/pages/get-page-links.spec.ts new file mode 100644 index 0000000..dc9a92c --- /dev/null +++ b/src/use-cases/pages/get-page-links.spec.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { InMemoryPagesRepository } from '@/repositories/in-memory/im-pages-respository'; +import { GetPageLinksUseCase } from './get-page-links'; +import { ResourceNotFoundError } from '../errors/not-found-error'; + +describe('Get Page Links Use Case', () => { + let pagesRepository: InMemoryPagesRepository; + let sut: GetPageLinksUseCase; + + beforeEach(() => { + pagesRepository = new InMemoryPagesRepository(); + sut = new GetPageLinksUseCase(pagesRepository); + }); + + it('should be able to get links from a page', async () => { + const page = await pagesRepository.create({ + slug: 'test-page', + title: 'Test Page', + description: 'Test description', + userId: 'user-01' + }); + + // Add some links to the in-memory repository + pagesRepository.links.push({ + id: 'link-01', + title: 'Link 1', + description: 'Description 1', + url: 'https://example.com/1', + type: 'link', + pageId: page.id, + createdAt: new Date() + }); + + pagesRepository.links.push({ + id: 'link-02', + title: 'Link 2', + description: 'Description 2', + url: 'https://example.com/2', + type: 'link', + pageId: page.id, + createdAt: new Date() + }); + + const { links } = await sut.execute(page.id); + + expect(links).toHaveLength(2); + expect(links[0].title).toEqual('Link 1'); + expect(links[1].title).toEqual('Link 2'); + }); + + it('should return empty array when page has no links', async () => { + const page = await pagesRepository.create({ + slug: 'empty-page', + title: 'Empty Page', + description: 'Page with no links', + userId: 'user-01' + }); + + const { links } = await sut.execute(page.id); + + expect(links).toHaveLength(0); + }); + + it('should throw error when page does not exist', async () => { + await expect(() => + sut.execute('non-existent-page-id') + ).rejects.toBeInstanceOf(ResourceNotFoundError); + }); +}); diff --git a/src/use-cases/pages/get-page-links.ts b/src/use-cases/pages/get-page-links.ts new file mode 100644 index 0000000..b55d0df --- /dev/null +++ b/src/use-cases/pages/get-page-links.ts @@ -0,0 +1,18 @@ +import { PagesRepository } from '@/repositories/pages-repository'; +import { ResourceNotFoundError } from '../errors/not-found-error'; + +export class GetPageLinksUseCase { + constructor(private pagesRepository: PagesRepository) { } + + async execute(pageId: string) { + const page = await this.pagesRepository.findById(pageId); + + if (!page) { + throw new ResourceNotFoundError('Page not found'); + } + + const links = await this.pagesRepository.findPageLinks(pageId); + + return { links }; + } +} diff --git a/src/use-cases/pages/get-page.spec.ts b/src/use-cases/pages/get-page.spec.ts index ce4a640..e1159ce 100644 --- a/src/use-cases/pages/get-page.spec.ts +++ b/src/use-cases/pages/get-page.spec.ts @@ -29,6 +29,6 @@ describe('Get page use case', () => { const inMemoryPageRepository = new InMemoryPagesRepository(); const getPageUseCase = new GetPageUseCase(inMemoryPageRepository); - expect(getPageUseCase.execute('non-created-page-id')).rejects.toBeInstanceOf(ResourceNotFoundError); + await expect(getPageUseCase.execute('non-created-page-id')).rejects.toBeInstanceOf(ResourceNotFoundError); }); }); diff --git a/src/use-cases/pages/update-page.spec.ts b/src/use-cases/pages/update-page.spec.ts index 037468e..4625f19 100644 --- a/src/use-cases/pages/update-page.spec.ts +++ b/src/use-cases/pages/update-page.spec.ts @@ -39,7 +39,7 @@ describe('Update pages use case', () => { userId: 'user-001', }); - expect(updatePageUseCase.execute({ + await expect(updatePageUseCase.execute({ id: newPage.id, title: 'Bio page', description: 'Page for my social media', diff --git a/src/use-cases/users/authenticate-user.ts b/src/use-cases/users/authenticate-user.ts index 9f13448..7dc9590 100644 --- a/src/use-cases/users/authenticate-user.ts +++ b/src/use-cases/users/authenticate-user.ts @@ -1,7 +1,7 @@ import { UsersRepository } from '@/repositories/users-repository'; import { InvalidCredentialsError } from '../errors/invalid-credentials-error'; -import {compare } from 'bcrypt'; +import { compare } from 'bcrypt'; interface AuthenticateUserUseCaseRequest { email: string diff --git a/vite.config.js b/vite.config.js index 49b5ffc..4db7b79 100644 --- a/vite.config.js +++ b/vite.config.js @@ -17,6 +17,24 @@ export default defineConfig({ '**/prisma/generated/**', '**/node_modules/**', ], - } + }, + projects: [ + { + extends: true, + test: { + name: 'UNIT', + dir: 'src/use-cases', + }, + }, + { + extends: true, + test: { + name: 'E2E', + dir: 'src/http', + fileParallelism: false, + pool: 'forks' + }, + }, + ] } });