├── .eslintrc.json ├── .github ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ ├── cypress-tests.yml │ └── node.js.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-push ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── __tests__ ├── integration │ └── openapi.test.js └── unit │ ├── [...nextauth].test.js │ ├── __snapshots__ │ └── snapshot.js.snap │ ├── api │ └── tasks │ │ ├── [taskId].test.js │ │ └── index.test.js │ ├── header.test.jsx │ ├── index.test.jsx │ ├── snapshot.js │ ├── taskitem.test.jsx │ └── tasklist.test.jsx ├── components ├── header.js ├── taskitem.js └── tasklist.js ├── cypress.config.js ├── cypress ├── e2e │ └── securityheaders.cy.js ├── plugins │ └── index.js └── support │ ├── commands.js │ └── e2e.js ├── jest.config.js ├── jest.setup.js ├── lib ├── prisma.js ├── prismaMockSingleton.js └── session.js ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── _app.js ├── api │ ├── auth │ │ └── [...nextauth].js │ └── tasks │ │ ├── [taskId].js │ │ └── index.js └── index.js ├── postgresql ├── Containerfile ├── drop-tables.sql └── init-user-db.sh ├── prisma ├── schema.prisma └── seed.js ├── public ├── favicon.ico ├── openapi.json └── vercel.svg └── styles ├── Header.module.css ├── Home.module.css ├── TaskItem.module.css ├── TaskList.module.css └── globals.css /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": ["next/core-web-vitals", "prettier"], 4 | "plugins": ["testing-library"], 5 | "overrides": [ 6 | // Only uses Testing Library lint rules in test files 7 | { 8 | "files": [ 9 | "**/__tests__/**/*.[jt]s?(x)", 10 | "**/?(*.)+(spec|test).[jt]s?(x)" 11 | ], 12 | "extends": ["plugin:testing-library/react"] 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '27 22 * * 4' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v4 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v2 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v2 71 | -------------------------------------------------------------------------------- /.github/workflows/cypress-tests.yml: -------------------------------------------------------------------------------- 1 | name: Cypress Tests 2 | on: 3 | push: 4 | branches: [ main ] 5 | pull_request: 6 | branches: [ main ] 7 | 8 | jobs: 9 | cypress-tests: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | 15 | - name: Cypress Tests 16 | uses: cypress-io/github-action@v6 17 | env: 18 | CORS_ALLOWED_ORIGIN: "*" 19 | with: 20 | build: npm run build 21 | start: npm run start 22 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | environment: 17 | name: Production 18 | env: 19 | DATABASE_URL: "postgresql://testuser:testpassword@127.0.0.1:15432/testdb" 20 | 21 | strategy: 22 | matrix: 23 | node-version: [18.x, 20.x, 22.4.x] 24 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | - name: Use Node.js ${{ matrix.node-version }} 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: ${{ matrix.node-version }} 32 | cache: 'npm' 33 | - name: Install dependencies 34 | run: npm ci 35 | - name: Test for lint issues 36 | run: npm run lint 37 | - name: Test for prettier formatting issues 38 | run: npm run prettier-check 39 | - name: Build the Docker PostgreSQL image 40 | run: docker build ./postgresql --file ./postgresql/Containerfile --tag postgresdb:latest 41 | - name: Start the PostgreSQL container 42 | run: docker run -d --rm --name postgres -p 15432:5432/tcp postgresdb:latest 43 | - name: Wait until PostgreSQL container is available 44 | run: until pg_isready -h 127.0.0.1 -p 15432; do sleep 1; done 45 | - name: Initialize database with Prisma 46 | run: npx prisma db push 47 | - name: Seed database with Prisma 48 | run: npx prisma db seed 49 | - name: Run tests (unit and integration) 50 | uses: cypress-io/github-action@v6 51 | env: 52 | CORS_ALLOWED_ORIGIN: "*" 53 | NEXTAUTH_URL: "http://127.0.0.1:3000/api/auth" 54 | NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }} 55 | API_BASE_URL: "http://127.0.0.1:3000" 56 | with: 57 | build: npm run build 58 | start: npm run start 59 | command: npm run test:coverage 60 | - uses: actions/upload-artifact@v4 61 | with: 62 | name: coverage-${{ matrix.node-version }} 63 | path: coverage 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | # Cypress 38 | cypress/videos/** 39 | cypress/fixtures/** 40 | cypress/screenshots/** 41 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run lint && npm run prettier-check && npm run test && npm run build 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.17.0 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/.next/** 2 | .github/** 3 | coverage 4 | next.config.js 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": false 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Next.js: debug server-side", 6 | "type": "node-terminal", 7 | "request": "launch", 8 | "command": "npm run dev" 9 | }, 10 | { 11 | "name": "Next.js: debug client-side", 12 | "type": "pwa-chrome", 13 | "request": "launch", 14 | "url": "http://127.0.0.1:3000" 15 | }, 16 | { 17 | "name": "Next.js: debug full stack", 18 | "type": "node-terminal", 19 | "request": "launch", 20 | "command": "npm run dev", 21 | "console": "integratedTerminal", 22 | "serverReadyAction": { 23 | "pattern": "started server on .+, url: (https?://.+)", 24 | "uriFormat": "%s", 25 | "action": "debugWithChrome" 26 | } 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 David Laganière (davidlag0) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # todo-nextjs 2 | 3 | [![codecov](https://codecov.io/gh/davidlag0/todo-nextjs/branch/main/graph/badge.svg?token=YBGR2fclvo)](https://codecov.io/gh/davidlag0/todo-nextjs) 4 | ![Node.js CI](https://github.com/davidlag0/todo-nextjs/actions/workflows/node.js.yml/badge.svg) 5 | ![CodeQL CI](https://github.com/davidlag0/todo-nextjs/actions/workflows/codeql-analysis.yml/badge.svg) 6 | 7 | Simple TODO app using the Next.js React framework. 8 | 9 | This application is meant to be some kind of boilerplate for me to learn about the various tools used in web development. 10 | 11 | ## Installation and Build 12 | 13 | ### Install nvm (https://github.com/nvm-sh/nvm/blob/master/README.md) 14 | 15 | ```console 16 | $ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash 17 | ``` 18 | 19 | ### Install node 20 | 21 | ```console 22 | $ nvm install v16.12.0 23 | ``` 24 | 25 | ### Install npm modules 26 | 27 | ```console 28 | $ npm install --production 29 | ``` 30 | 31 | ### Build 32 | 33 | ```console 34 | $ npm run build 35 | ``` 36 | 37 | ### Run the built application in production mode 38 | 39 | ```console 40 | $ npm start 41 | ``` 42 | 43 | ## Development 44 | 45 | ### Start development server: 46 | 47 | ```console 48 | $ npm run dev 49 | ``` 50 | 51 | The application is accessible at [http://127.0.0.1:3000](http://127.0.0.1:3000). 52 | 53 | ### Linting 54 | 55 | ```console 56 | $ npm run lint 57 | ``` 58 | 59 | ### Run integration (e2e) tests (macOS) 60 | 61 | Start podman VM: 62 | 63 | ```console 64 | $ podman machine start 65 | ``` 66 | 67 | Build container image using the `Containerfile` in folder `postgresql`: 68 | 69 | ```console 70 | $ podman build -t postgresdb:latest . 71 | ``` 72 | 73 | Run test PostgreSQL database container: 74 | 75 | ```console 76 | $ podman run -d --rm --name postgres -p 15432:5432/tcp postgresdb:latest 77 | ``` 78 | 79 | Initialize database with Prisma 80 | 81 | ```console 82 | $ npx prisma db push 83 | ``` 84 | 85 | Troubleshooting the database (from host) 86 | 87 | ```console 88 | $ psql -h 127.0.0.1 -p 15432 -U testuser -d testdb 89 | ``` 90 | 91 | ### Build on different versions of Node.js locally (example) 92 | 93 | ```console 94 | $ nvm ls 95 | $ nvm use v16.14.2 96 | $ npm ci 97 | $ npm run build --if-present 98 | ``` 99 | 100 | ### Deployment 101 | 102 | - Environment variables to configure: 103 | - `DATABASE_URL=postgresql://:@:5432/` 104 | - `NEXTAUTH_URL=https://example.com/api/auth` 105 | - `GITHUB_ID` 106 | - `GITHUB_SECRET` 107 | - `NEXTAUTH_SECRET` that can be generated using `$ openssl rand -base64 32` 108 | - `CORS_ALLOWED_ORIGIN` to set the HTTP header `Access-Control-Allow-Origin` to something else than `*` 109 | -------------------------------------------------------------------------------- /__tests__/integration/openapi.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | import jestOpenAPI from "jest-openapi"; 6 | import fetch from "node-fetch"; 7 | import { encodeJwt } from "../../lib/session"; 8 | 9 | jestOpenAPI(__dirname + "/../../public/openapi.json"); 10 | 11 | const jwtClaims = { 12 | name: "Test User", 13 | email: "testuser@email.com", 14 | }; 15 | 16 | // Reference: https://github.com/openapi-library/OpenAPIValidators/issues/251 17 | describe("Tests to satisfy OpenAPI spec", () => { 18 | let task1ID = "blop"; 19 | 20 | test("GET /api/tasks with empty task list", async () => { 21 | const rawResponse = await fetch(process.env.API_BASE_URL + "/api/tasks", { 22 | headers: { 23 | Cookie: 24 | "next-auth.session-token=" + 25 | (await encodeJwt(jwtClaims, process.env.NEXTAUTH_SECRET)), 26 | }, 27 | }); 28 | 29 | const body = await rawResponse.json(); 30 | const response = { 31 | req: { 32 | path: rawResponse.url, 33 | method: "GET", 34 | }, 35 | status: rawResponse.status, 36 | body, 37 | }; 38 | 39 | expect(response.status).toEqual(404); 40 | expect(response).toSatisfyApiSpec(); 41 | }); 42 | 43 | it("POST /api/tasks", async () => { 44 | const rawResponse = await fetch(process.env.API_BASE_URL + "/api/tasks", { 45 | method: "POST", 46 | headers: { 47 | Cookie: 48 | "next-auth.session-token=" + 49 | (await encodeJwt(jwtClaims, process.env.NEXTAUTH_SECRET)), 50 | "Content-Type": "application/json;charset=utf-8", 51 | }, 52 | body: JSON.stringify({ name: "test task" }), 53 | }); 54 | 55 | const body = await rawResponse.json(); 56 | const response = { 57 | req: { 58 | path: rawResponse.url, 59 | method: "POST", 60 | }, 61 | status: rawResponse.status, 62 | body, 63 | }; 64 | 65 | expect(response.status).toEqual(200); 66 | expect(response).toSatisfyApiSpec(); 67 | }); 68 | 69 | it("POST /api/tasks without a body", async () => { 70 | const rawResponse = await fetch(process.env.API_BASE_URL + "/api/tasks", { 71 | method: "POST", 72 | headers: { 73 | Cookie: 74 | "next-auth.session-token=" + 75 | (await encodeJwt(jwtClaims, process.env.NEXTAUTH_SECRET)), 76 | "Content-Type": "application/json;charset=utf-8", 77 | }, 78 | }); 79 | 80 | const response = { 81 | req: { 82 | path: rawResponse.url, 83 | method: "POST", 84 | }, 85 | status: rawResponse.status, 86 | body: { error: "Missing 'name' value in request body" }, 87 | }; 88 | 89 | expect(response.status).toEqual(400); 90 | expect(response).toSatisfyApiSpec(); 91 | }); 92 | 93 | it("POST /api/tasks with a body but without 'name' data", async () => { 94 | const rawResponse = await fetch(process.env.API_BASE_URL + "/api/tasks", { 95 | method: "POST", 96 | headers: { 97 | Cookie: 98 | "next-auth.session-token=" + 99 | (await encodeJwt(jwtClaims, process.env.NEXTAUTH_SECRET)), 100 | "Content-Type": "application/json;charset=utf-8", 101 | }, 102 | body: JSON.stringify({ someField: "some data" }), 103 | }); 104 | 105 | const response = { 106 | req: { 107 | path: rawResponse.url, 108 | method: "POST", 109 | }, 110 | status: rawResponse.status, 111 | body: { error: "Missing 'name' value in request body" }, 112 | }; 113 | 114 | expect(response.status).toEqual(400); 115 | expect(response).toSatisfyApiSpec(); 116 | }); 117 | 118 | it("GET /api/tasks with tasks", async () => { 119 | const rawResponse = await fetch(process.env.API_BASE_URL + "/api/tasks", { 120 | headers: { 121 | Cookie: 122 | "next-auth.session-token=" + 123 | (await encodeJwt(jwtClaims, process.env.NEXTAUTH_SECRET)), 124 | }, 125 | }); 126 | 127 | const body = await rawResponse.json(); 128 | const response = { 129 | req: { 130 | path: rawResponse.url, 131 | method: "GET", 132 | }, 133 | status: rawResponse.status, 134 | body, 135 | }; 136 | 137 | // Save task ID for following tests 138 | task1ID = response.body[0].id; 139 | 140 | expect(response.status).toEqual(200); 141 | expect(response).toSatisfyApiSpec(); 142 | }); 143 | 144 | it("GET /api/task/[taskId]", async () => { 145 | const rawResponse = await fetch( 146 | process.env.API_BASE_URL + "/api/tasks/" + task1ID, 147 | { 148 | headers: { 149 | Cookie: 150 | "next-auth.session-token=" + 151 | (await encodeJwt(jwtClaims, process.env.NEXTAUTH_SECRET)), 152 | }, 153 | } 154 | ); 155 | 156 | const body = await rawResponse.json(); 157 | const response = { 158 | req: { 159 | path: rawResponse.url, 160 | method: "GET", 161 | }, 162 | status: rawResponse.status, 163 | body, 164 | }; 165 | 166 | expect(response.status).toEqual(200); 167 | expect(response).toSatisfyApiSpec(); 168 | }); 169 | 170 | it("GET /api/task/[taskId] with non-existent task ID", async () => { 171 | const rawResponse = await fetch( 172 | process.env.API_BASE_URL + "/api/tasks/2147483647", 173 | { 174 | headers: { 175 | Cookie: 176 | "next-auth.session-token=" + 177 | (await encodeJwt(jwtClaims, process.env.NEXTAUTH_SECRET)), 178 | }, 179 | } 180 | ); 181 | 182 | const body = await rawResponse.json(); 183 | const response = { 184 | req: { 185 | path: rawResponse.url, 186 | method: "GET", 187 | }, 188 | status: rawResponse.status, 189 | body, 190 | }; 191 | 192 | expect(response.status).toEqual(404); 193 | expect(response).toSatisfyApiSpec(); 194 | }); 195 | 196 | it("DELETE /api/task/[taskId] with task ID", async () => { 197 | const rawResponse = await fetch( 198 | process.env.API_BASE_URL + "/api/tasks/" + task1ID, 199 | { 200 | method: "DELETE", 201 | headers: { 202 | Cookie: 203 | "next-auth.session-token=" + 204 | (await encodeJwt(jwtClaims, process.env.NEXTAUTH_SECRET)), 205 | }, 206 | } 207 | ); 208 | 209 | const body = await rawResponse.json(); 210 | const response = { 211 | req: { 212 | path: rawResponse.url, 213 | method: "DELETE", 214 | }, 215 | status: rawResponse.status, 216 | body, 217 | }; 218 | 219 | expect(response.status).toEqual(200); 220 | 221 | // TODO: modify API spec to allow this to work. 222 | //expect(response).toSatisfyApiSpec(); 223 | }); 224 | 225 | test("GET /api/tasks when logged out", async () => { 226 | const rawResponse = await fetch( 227 | process.env.API_BASE_URL + "/api/tasks", 228 | {} 229 | ); 230 | 231 | const body = await rawResponse.json(); 232 | const response = { 233 | req: { 234 | path: rawResponse.url, 235 | method: "GET", 236 | }, 237 | status: rawResponse.status, 238 | body, 239 | }; 240 | 241 | expect(response.status).toEqual(401); 242 | // TODO: modify API spec to allow this to work. 243 | //expect(response).toSatisfyApiSpec(); 244 | }); 245 | }); 246 | 247 | // TODO: add test for invalid task ID that is not a number. 248 | -------------------------------------------------------------------------------- /__tests__/unit/[...nextauth].test.js: -------------------------------------------------------------------------------- 1 | import { authOptions } from "../../pages/api/auth/[...nextauth]"; 2 | import { prismaMock } from "../../lib/prismaMockSingleton"; 3 | 4 | const allowedUser = { 5 | user: { 6 | email: "allowed_user@domain.com", 7 | }, 8 | }; 9 | 10 | const deniedUser = { 11 | user: { 12 | email: "denied_user@domain.com", 13 | }, 14 | }; 15 | 16 | describe("NextAuth", () => { 17 | test("returns true for a user that is allowed to log in", async () => { 18 | prismaMock.user.findUnique.mockResolvedValue({ 19 | email: "allowed_user@domain.com", 20 | }); 21 | 22 | expect(await authOptions.callbacks.signIn(allowedUser)).toBeTruthy(); 23 | }); 24 | 25 | test("returns false for a user that is not allowed to log in", async () => { 26 | prismaMock.user.findUnique.mockResolvedValue(null); 27 | 28 | expect(await authOptions.callbacks.signIn(deniedUser)).toBeFalsy(); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /__tests__/unit/__snapshots__/snapshot.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders homepage unchanged 1`] = ` 4 |
5 |
6 | 24 |
25 |
28 |
31 |
32 | 38 |
39 |
    42 |
  • 43 |
    46 | 55 | 58 | Task List 59 | 60 | 66 | 69 | 75 | 81 | 87 | 88 |

    89 | Add your first task 90 |

    91 |

    92 | What will you work on today? 93 |

    94 |
    95 |
  • 96 |
97 |
98 |
99 |
100 |
101 |
102 | `; 103 | -------------------------------------------------------------------------------- /__tests__/unit/api/tasks/[taskId].test.js: -------------------------------------------------------------------------------- 1 | import { createMocks } from "node-mocks-http"; 2 | import handle from "../../../../pages/api/tasks/[taskId]"; 3 | import { getServerSession } from "next-auth/next"; 4 | import { prismaMock } from "../../../../lib/prismaMockSingleton"; 5 | 6 | jest.mock("next-auth/jwt"); 7 | jest.mock("next-auth/next", () => { 8 | const originalModule = jest.requireActual("next-auth/next"); 9 | 10 | return { 11 | __esModule: true, 12 | ...originalModule, 13 | getServerSession: jest.fn(), 14 | }; 15 | }); 16 | 17 | const testSession = { 18 | user: { 19 | name: "Test Author", 20 | email: "testauthor@test.com", 21 | image: "testimage", 22 | }, 23 | expires: "1", 24 | }; 25 | 26 | const testTaskId = 1; 27 | const testTask = { 28 | id: testTaskId, 29 | name: "Test Task", 30 | checked: false, 31 | }; 32 | const testTaskUrlId = 1; 33 | 34 | describe("/api/tasks/[taskId]", () => { 35 | test("returns error message when logged out", async () => { 36 | const { req, res } = createMocks({ 37 | method: "GET", 38 | query: { 39 | id: testTaskUrlId, 40 | }, 41 | }); 42 | 43 | getServerSession.mockReturnValue(null); 44 | 45 | await handle(req, res); 46 | 47 | expect(res._getStatusCode()).toBe(401); 48 | expect(res._getData()).toEqual( 49 | expect.objectContaining({ 50 | error: "Valid Credentials Required", 51 | }) 52 | ); 53 | }); 54 | 55 | test("returns error message when logged in and using an unsupported HTTP method", async () => { 56 | const { req, res } = createMocks({ 57 | method: "UNSUPPORTED", 58 | query: { 59 | id: testTaskUrlId, 60 | }, 61 | }); 62 | 63 | getServerSession.mockReturnValue({}); 64 | 65 | await handle(req, res); 66 | 67 | expect(res._getStatusCode()).toBe(405); 68 | expect(res._getData()).toEqual( 69 | expect.objectContaining({ 70 | error: "Method Not Allowed", 71 | }) 72 | ); 73 | }); 74 | 75 | test("returns error message when logged in and GET'ing task when there is no task returned", async () => { 76 | const { req, res } = createMocks({ 77 | method: "GET", 78 | query: { 79 | id: testTaskUrlId, 80 | }, 81 | }); 82 | 83 | getServerSession.mockReturnValue(testSession); 84 | 85 | prismaMock.task.findUnique.mockResolvedValue(null); 86 | 87 | await handle(req, res); 88 | 89 | expect(res._getStatusCode()).toBe(404); 90 | expect(res._getData()).toEqual( 91 | expect.objectContaining({ 92 | error: "Task Not Found", 93 | }) 94 | ); 95 | }); 96 | 97 | test("returns task details when logged in and GET'ing a task", async () => { 98 | const { req, res } = createMocks({ 99 | method: "GET", 100 | query: { 101 | id: testTaskUrlId, 102 | }, 103 | }); 104 | 105 | getServerSession.mockReturnValue(testSession); 106 | 107 | prismaMock.task.findUnique.mockResolvedValue(testTask); 108 | 109 | await handle(req, res); 110 | 111 | expect(res._getStatusCode()).toBe(200); 112 | expect(JSON.parse(res._getData())).toEqual( 113 | expect.objectContaining(testTask) 114 | ); 115 | expect(req.query.id).toBe(testTask.id); 116 | }); 117 | 118 | test("returns task details when logged in and DELETE'ing a task", async () => { 119 | const { req, res } = createMocks({ 120 | method: "DELETE", 121 | query: { 122 | id: testTaskUrlId, 123 | }, 124 | }); 125 | 126 | getServerSession.mockReturnValue(testSession); 127 | 128 | prismaMock.task.delete.mockResolvedValue(testTask); 129 | 130 | await handle(req, res); 131 | 132 | expect(res._getStatusCode()).toBe(200); 133 | expect(JSON.parse(res._getData())).toEqual( 134 | expect.objectContaining(testTask) 135 | ); 136 | expect(req.query.id).toBe(testTask.id); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /__tests__/unit/api/tasks/index.test.js: -------------------------------------------------------------------------------- 1 | import { createMocks } from "node-mocks-http"; 2 | import handle from "../../../../pages/api/tasks/index"; 3 | import { getServerSession } from "next-auth/next"; 4 | import { prismaMock } from "../../../../lib/prismaMockSingleton"; 5 | 6 | jest.mock("next-auth/jwt"); 7 | jest.mock("next-auth/next", () => { 8 | const originalModule = jest.requireActual("next-auth/next"); 9 | 10 | return { 11 | __esModule: true, 12 | ...originalModule, 13 | getServerSession: jest.fn(), 14 | }; 15 | }); 16 | 17 | const testSession = { 18 | user: { 19 | name: "Test Author", 20 | email: "testauthor@test.com", 21 | image: "testimage", 22 | }, 23 | expires: "1", 24 | }; 25 | 26 | const testTask = { 27 | name: "Test Task", 28 | checked: false, 29 | }; 30 | 31 | const testTask2 = { 32 | name: "Test Task 2", 33 | checked: false, 34 | }; 35 | 36 | describe("/api/tasks/", () => { 37 | test("returns error message when logged out", async () => { 38 | const { req, res } = createMocks({ 39 | method: "GET", 40 | }); 41 | 42 | getServerSession.mockReturnValue(null); 43 | 44 | await handle(req, res); 45 | 46 | expect(res._getStatusCode()).toBe(401); 47 | expect(res._getData()).toEqual( 48 | expect.objectContaining({ 49 | error: "Valid Credentials Required", 50 | }) 51 | ); 52 | }); 53 | 54 | test("returns error message when logged in and using an unsupported HTTP method", async () => { 55 | const { req, res } = createMocks({ 56 | method: "UNSUPPORTED", 57 | }); 58 | 59 | getServerSession.mockReturnValue(testSession); 60 | 61 | await handle(req, res); 62 | 63 | expect(res._getStatusCode()).toBe(405); 64 | expect(res._getData()).toEqual( 65 | expect.objectContaining({ 66 | error: "Method Not Allowed", 67 | }) 68 | ); 69 | }); 70 | 71 | test("returns task details when logged in and POST'ing a new task name", async () => { 72 | const { req, res } = createMocks({ 73 | method: "POST", 74 | body: { name: "Test Task" }, 75 | }); 76 | 77 | getServerSession.mockReturnValue(testSession); 78 | 79 | prismaMock.task.create.mockResolvedValue(testTask); 80 | 81 | await handle(req, res); 82 | 83 | expect(res._getStatusCode()).toBe(200); 84 | expect(JSON.parse(res._getData())).toEqual( 85 | expect.objectContaining(testTask) 86 | ); 87 | }); 88 | 89 | test("returns an error message when logged in and POST'ing a new task without a body (without task name)", async () => { 90 | const { req, res } = createMocks({ 91 | method: "POST", 92 | }); 93 | 94 | getServerSession.mockReturnValue(testSession); 95 | 96 | await handle(req, res); 97 | 98 | expect(res._getStatusCode()).toBe(400); 99 | 100 | expect(res._getData()).toEqual( 101 | expect.objectContaining({ 102 | error: "Missing 'name' value in request body", 103 | }) 104 | ); 105 | }); 106 | 107 | test("returns an error message when logged in and POST'ing a new task with a body but without task name", async () => { 108 | const { req, res } = createMocks({ 109 | method: "POST", 110 | body: { "other data": "some data" }, 111 | }); 112 | 113 | getServerSession.mockReturnValue(testSession); 114 | 115 | await handle(req, res); 116 | 117 | expect(res._getStatusCode()).toBe(400); 118 | 119 | expect(res._getData()).toEqual( 120 | expect.objectContaining({ 121 | error: "Missing 'name' value in request body", 122 | }) 123 | ); 124 | }); 125 | 126 | test("returns error message when logged in and GET'ing tasks when there is no task in the list", async () => { 127 | const { req, res } = createMocks({ 128 | method: "GET", 129 | }); 130 | 131 | getServerSession.mockReturnValue(testSession); 132 | 133 | prismaMock.task.findMany.mockResolvedValue([]); 134 | 135 | await handle(req, res); 136 | 137 | expect(res._getStatusCode()).toBe(404); 138 | expect(res._getData()).toEqual( 139 | expect.objectContaining({ 140 | error: "No Task Found", 141 | }) 142 | ); 143 | }); 144 | 145 | test("returns task details when logged in and GET'ing tasks", async () => { 146 | const { req, res } = createMocks({ 147 | method: "GET", 148 | }); 149 | 150 | getServerSession.mockReturnValue(testSession); 151 | 152 | prismaMock.task.findMany.mockResolvedValue([testTask]); 153 | 154 | await handle(req, res); 155 | 156 | expect(res._getStatusCode()).toBe(200); 157 | expect(JSON.parse(res._getData())[0]).toEqual( 158 | expect.objectContaining(testTask) 159 | ); 160 | }); 161 | 162 | test("returns task details when logged in and GET'ing tasks when there is more than one task in the list", async () => { 163 | const { req, res } = createMocks({ 164 | method: "GET", 165 | }); 166 | 167 | getServerSession.mockReturnValue(testSession); 168 | 169 | prismaMock.task.findMany.mockResolvedValue([testTask, testTask2]); 170 | 171 | await handle(req, res); 172 | 173 | expect(res._getStatusCode()).toBe(200); 174 | expect(JSON.parse(res._getData())[0]).toEqual( 175 | expect.objectContaining(testTask) 176 | ); 177 | expect(JSON.parse(res._getData())[1]).toEqual( 178 | expect.objectContaining(testTask2) 179 | ); 180 | }); 181 | }); 182 | -------------------------------------------------------------------------------- /__tests__/unit/header.test.jsx: -------------------------------------------------------------------------------- 1 | import { render, screen, fireEvent } from "@testing-library/react"; 2 | import Header from "../../components/header"; 3 | import { SessionProvider, signIn, signOut } from "next-auth/react"; 4 | 5 | jest.mock("next-auth/react", () => ({ 6 | ...jest.requireActual("next-auth/react"), 7 | signIn: jest.fn(), 8 | signOut: jest.fn(), 9 | })); 10 | 11 | describe("Header (unauthenticated)", () => { 12 | beforeEach(() => { 13 | fetch.doMock(); 14 | }); 15 | 16 | test("renders a login button in nav", async () => { 17 | render( 18 | 19 |
20 | 21 | ); 22 | 23 | const loginButton = screen.getByRole("button", { 24 | name: "Sign in with GitHub", 25 | }); 26 | 27 | expect(loginButton).toBeInTheDocument(); 28 | }); 29 | 30 | test("clicks login button and it triggers login process", () => { 31 | render( 32 | 33 |
34 | 35 | ); 36 | 37 | fireEvent.click(screen.getByText(/Sign in with GitHub/i)); 38 | expect(signIn).toHaveBeenCalledTimes(1); 39 | }); 40 | }); 41 | 42 | describe("Header (authenticated)", () => { 43 | beforeEach(() => { 44 | fetch.resetMocks(); 45 | }); 46 | 47 | test("renders a logout button in nav", async () => { 48 | const mockSession = { 49 | expires: "1", 50 | user: { email: "test@test.com", name: "Test Name", image: "test_image" }, 51 | }; 52 | 53 | render( 54 | 55 |
56 | 57 | ); 58 | 59 | const logoutButton = screen.getByRole("button", { 60 | name: "Sign out", 61 | }); 62 | 63 | const loggedInText = screen.getByText("Signed in as test@test.com"); 64 | 65 | expect(logoutButton).toBeInTheDocument(); 66 | expect(loggedInText).toBeInTheDocument(); 67 | }); 68 | 69 | test("clicks logout button and it triggers logout process", () => { 70 | const mockSession = { 71 | expires: "1", 72 | user: { email: "test@test.com", name: "Test Name", image: "test_image" }, 73 | }; 74 | 75 | render( 76 | 77 |
78 | 79 | ); 80 | 81 | fireEvent.click(screen.getByText(/Sign out/i)); 82 | expect(signOut).toHaveBeenCalledTimes(1); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /__tests__/unit/index.test.jsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import Home from "../../pages/index"; 3 | import { SessionProvider } from "next-auth/react"; 4 | 5 | jest.mock("next-auth/react", () => ({ 6 | ...jest.requireActual("next-auth/react"), 7 | signIn: jest.fn(), 8 | signOut: jest.fn(), 9 | })); 10 | 11 | describe("Home", () => { 12 | beforeEach(() => { 13 | fetch.doMock(); 14 | }); 15 | 16 | test("renders a nav and a main section with an empty task list", () => { 17 | render( 18 | 19 | 20 | 21 | ); 22 | 23 | const navigation = screen.getByRole("navigation", { 24 | name: "", 25 | }); 26 | 27 | const main = screen.getByRole("main", { 28 | name: "", 29 | }); 30 | 31 | const newTaskTextbox = screen.getByRole("textbox", { 32 | name: "Enter a new task", 33 | }); 34 | 35 | const newTaskHeading = screen.getByRole("heading", { 36 | name: "Add your first task", 37 | }); 38 | 39 | const emptyTaskListImg = screen.getByRole("img", { 40 | name: "Task List", 41 | }); 42 | 43 | expect(navigation).toBeInTheDocument(); 44 | expect(main).toBeInTheDocument(); 45 | expect(newTaskTextbox).toBeInTheDocument(); 46 | expect(newTaskHeading).toBeInTheDocument(); 47 | expect(emptyTaskListImg).toBeInTheDocument(); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /__tests__/unit/snapshot.js: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import Home from "../../pages/index"; 3 | import { SessionProvider } from "next-auth/react"; 4 | 5 | it("renders homepage unchanged", () => { 6 | const { container } = render( 7 | 8 | 9 | 10 | ); 11 | expect(container).toMatchSnapshot(); 12 | }); 13 | -------------------------------------------------------------------------------- /__tests__/unit/taskitem.test.jsx: -------------------------------------------------------------------------------- 1 | import { render, screen, fireEvent } from "@testing-library/react"; 2 | import TaskItem from "../../components/taskitem"; 3 | 4 | test("verifies initial listitem", () => { 5 | render(); 6 | 7 | const taskCheckbox = screen.getByRole("checkbox"); 8 | const taskText = screen.getByTitle("Task Item"); 9 | const taskCheckmarkSvg = screen.getByTitle("Task Checkmark"); 10 | 11 | expect(taskCheckbox).not.toBeChecked(); 12 | expect(taskText.classList[0]).toBe("listTaskText"); 13 | expect(taskCheckmarkSvg.classList[0]).toBe("listTaskCheckmark"); 14 | }); 15 | 16 | test("clicks checkbox and verify changes", () => { 17 | render(); 18 | 19 | const taskCheckbox = screen.getByRole("checkbox"); 20 | fireEvent.click(taskCheckbox); 21 | 22 | const taskText = screen.getByTitle("Task Item"); 23 | const taskCheckmarkSvg = screen.getByTitle("Task Checkmark"); 24 | 25 | expect(taskCheckbox).toBeChecked(); 26 | expect(taskText.classList[0]).toBe("listTaskTextCrossed"); 27 | expect(taskCheckmarkSvg.classList[0]).toBe("listTaskCheckmarkChecked"); 28 | }); 29 | -------------------------------------------------------------------------------- /__tests__/unit/tasklist.test.jsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor } from "@testing-library/react"; 2 | import userEvent from "@testing-library/user-event"; 3 | import TaskList from "../../components/tasklist"; 4 | import { SessionProvider } from "next-auth/react"; 5 | 6 | describe(" (unauthenticated)", () => { 7 | beforeEach(() => { 8 | fetch.doMock(); 9 | }); 10 | 11 | test("verifies initial and empty TaskList", () => { 12 | render( 13 | 14 | 15 | 16 | ); 17 | 18 | const addTaskTextbox = screen.getByRole("textbox", { 19 | name: "Enter a new task", 20 | }); 21 | const taskList = screen.getByRole("list"); 22 | const emptyTaskListSvg = screen.getByRole("img", { name: "Task List" }); 23 | const addTaskHeading = screen.getByRole("heading", { 24 | name: "Add your first task", 25 | }); 26 | 27 | expect(addTaskTextbox).toBeInTheDocument(); 28 | expect(taskList).toBeInTheDocument(); 29 | expect(emptyTaskListSvg).toBeInTheDocument(); 30 | expect(addTaskHeading).toBeInTheDocument(); 31 | }); 32 | 33 | test("add task", async () => { 34 | render( 35 | 36 | 37 | 38 | ); 39 | 40 | const addTaskTextbox = screen.getByRole("textbox", { 41 | name: "Enter a new task", 42 | }); 43 | await userEvent.type(addTaskTextbox, "test task!{enter}"); 44 | 45 | const newTaskElement = screen.getByText("test task!"); 46 | 47 | expect(newTaskElement).toBeInTheDocument(); 48 | }); 49 | 50 | test("delete task", async () => { 51 | render( 52 | 53 | 54 | 55 | ); 56 | 57 | const addTaskTextbox = screen.getByRole("textbox", { 58 | name: "Enter a new task", 59 | }); 60 | await userEvent.type(addTaskTextbox, "task to be deleted!{enter}"); 61 | 62 | // Verify new task has been added. 63 | const newTaskElement = screen.getByText("task to be deleted!"); 64 | expect(newTaskElement).toBeInTheDocument(); 65 | 66 | // Then delete the task. 67 | await userEvent.click(screen.getByRole("button")); 68 | 69 | const emptyTaskListSvg = screen.getByRole("img", { name: "Task List" }); 70 | const addTaskHeading = screen.getByRole("heading", { 71 | name: "Add your first task", 72 | }); 73 | expect(emptyTaskListSvg).toBeInTheDocument(); 74 | expect(addTaskHeading).toBeInTheDocument(); 75 | }); 76 | }); 77 | 78 | describe(" (authenticated)", () => { 79 | beforeEach(() => { 80 | fetch.resetMocks(); 81 | }); 82 | 83 | test("verifies initial and empty TaskList", async () => { 84 | const mockSession = { 85 | expires: "1", 86 | user: { email: "test@test.com", name: "Test Name", image: "test_image" }, 87 | }; 88 | 89 | fetch.mockResponse(JSON.stringify({ error: "No Task Found" }), { 90 | status: 404, 91 | }); 92 | 93 | render( 94 | 95 | 96 | 97 | ); 98 | 99 | const addTaskTextbox = screen.getByRole("textbox", { 100 | name: "Enter a new task", 101 | }); 102 | const taskList = screen.getByRole("list"); 103 | const emptyTaskListSvg = screen.getByRole("img", { name: "Task List" }); 104 | const addTaskHeading = screen.getByRole("heading", { 105 | name: "Add your first task", 106 | }); 107 | 108 | expect(addTaskTextbox).toBeInTheDocument(); 109 | expect(taskList).toBeInTheDocument(); 110 | expect(emptyTaskListSvg).toBeInTheDocument(); 111 | expect(addTaskHeading).toBeInTheDocument(); 112 | }); 113 | 114 | test("verifies initial TaskList with 1 item", async () => { 115 | const mockSession = { 116 | expires: "1", 117 | user: { email: "test@test.com", name: "Test Name", image: "test_image" }, 118 | }; 119 | 120 | fetch.mockResponse( 121 | JSON.stringify([ 122 | { 123 | id: 58, 124 | createdAt: "2022-04-11T01:20:08.370Z", 125 | updatedAt: "2022-04-11T01:20:08.371Z", 126 | name: "test task (authenticated)", 127 | checked: false, 128 | authorId: 1, 129 | }, 130 | ]), 131 | { 132 | status: 200, 133 | } 134 | ); 135 | 136 | render( 137 | 138 | 139 | 140 | ); 141 | 142 | const emptyTaskListSvg = screen.getByRole("img", { name: "Task List" }); 143 | const addTaskHeading = screen.getByRole("heading", { 144 | name: "Add your first task", 145 | }); 146 | 147 | await waitFor(() => { 148 | expect(emptyTaskListSvg).not.toBeInTheDocument(); 149 | }); 150 | await waitFor(() => { 151 | expect(addTaskHeading).not.toBeInTheDocument(); 152 | }); 153 | }); 154 | 155 | test("add task from empty list", async () => { 156 | const mockSession = { 157 | expires: "1", 158 | user: { email: "test@test.com", name: "Test Name", image: "test_image" }, 159 | }; 160 | 161 | fetch 162 | .doMockOnce(JSON.stringify({ error: "No Task Found" }), { 163 | status: 404, 164 | }) 165 | .doMockOnce( 166 | JSON.stringify([ 167 | { 168 | id: 58, 169 | createdAt: "2022-04-11T01:20:08.370Z", 170 | updatedAt: "2022-04-11T01:20:08.371Z", 171 | name: "test task (authenticated add)", 172 | checked: false, 173 | authorId: 1, 174 | }, 175 | ]), 176 | { 177 | status: 200, 178 | } 179 | ); 180 | 181 | render( 182 | 183 | 184 | 185 | ); 186 | 187 | const emptyTaskListSvg = screen.getByRole("img", { name: "Task List" }); 188 | const addTaskHeading = screen.getByRole("heading", { 189 | name: "Add your first task", 190 | }); 191 | 192 | await waitFor(() => { 193 | expect(emptyTaskListSvg).toBeInTheDocument(); 194 | }); 195 | await waitFor(() => { 196 | expect(addTaskHeading).toBeInTheDocument(); 197 | }); 198 | 199 | const addTaskTextbox = screen.getByRole("textbox", { 200 | name: "Enter a new task", 201 | }); 202 | 203 | await userEvent.type( 204 | addTaskTextbox, 205 | "test task (authenticated add)!{enter}" 206 | ); 207 | 208 | const newTaskElement = screen.getByText("test task (authenticated add)!"); 209 | 210 | await waitFor(() => { 211 | expect(newTaskElement).toBeInTheDocument(); 212 | }); 213 | }); 214 | 215 | test("delete task that is already in the list", async () => { 216 | const mockSession = { 217 | expires: "1", 218 | user: { email: "test@test.com", name: "Test Name", image: "test_image" }, 219 | }; 220 | 221 | fetch.doMockOnce( 222 | JSON.stringify([ 223 | { 224 | id: 58, 225 | createdAt: "2022-04-11T01:20:08.370Z", 226 | updatedAt: "2022-04-11T01:20:08.371Z", 227 | name: "test task (authenticated delete already there)", 228 | checked: false, 229 | authorId: 1, 230 | }, 231 | ]), 232 | { 233 | status: 200, 234 | } 235 | ); 236 | 237 | render( 238 | 239 | 240 | 241 | ); 242 | 243 | // Wait for fetch to complete to show the task to delete. 244 | await waitFor(() => { 245 | expect(screen.getByRole("button")).toBeInTheDocument(); 246 | }); 247 | 248 | // Delete the task. 249 | await userEvent.click(screen.getByRole("button")); 250 | 251 | const emptyTaskListSvg = screen.getByRole("img", { name: "Task List" }); 252 | const addTaskHeading = screen.getByRole("heading", { 253 | name: "Add your first task", 254 | }); 255 | 256 | // Confirm it was indeed deleted. 257 | await waitFor(() => { 258 | expect(emptyTaskListSvg).toBeInTheDocument(); 259 | }); 260 | await waitFor(() => { 261 | expect(addTaskHeading).toBeInTheDocument(); 262 | }); 263 | }); 264 | 265 | test("error when fetching the list of tasks", async () => { 266 | jest.spyOn(global.console, "error").mockImplementation(() => jest.fn()); 267 | 268 | const mockSession = { 269 | expires: "1", 270 | user: { email: "test@test.com", name: "Test Name", image: "test_image" }, 271 | }; 272 | 273 | fetch.mockReject(new Error("fake error message")); 274 | 275 | render( 276 | 277 | 278 | 279 | ); 280 | 281 | const emptyTaskListSvg = screen.getByRole("img", { name: "Task List" }); 282 | const addTaskHeading = screen.getByRole("heading", { 283 | name: "Add your first task", 284 | }); 285 | 286 | await waitFor(() => { 287 | expect(emptyTaskListSvg).toBeInTheDocument(); 288 | }); 289 | await waitFor(() => { 290 | expect(addTaskHeading).toBeInTheDocument(); 291 | }); 292 | }); 293 | 294 | test("error when adding a task from an empty list", async () => { 295 | jest.spyOn(global.console, "error").mockImplementation(() => jest.fn()); 296 | 297 | const mockSession = { 298 | expires: "1", 299 | user: { email: "test@test.com", name: "Test Name", image: "test_image" }, 300 | }; 301 | 302 | fetch 303 | .doMockOnce(JSON.stringify({ error: "No Task Found" }), { 304 | status: 404, 305 | }) 306 | .mockReject(new Error("fake error message")); 307 | 308 | render( 309 | 310 | 311 | 312 | ); 313 | 314 | const emptyTaskListSvg = screen.getByRole("img", { name: "Task List" }); 315 | const addTaskHeading = screen.getByRole("heading", { 316 | name: "Add your first task", 317 | }); 318 | 319 | await waitFor(() => { 320 | expect(emptyTaskListSvg).toBeInTheDocument(); 321 | }); 322 | await waitFor(() => { 323 | expect(addTaskHeading).toBeInTheDocument(); 324 | }); 325 | 326 | const addTaskTextbox = screen.getByRole("textbox", { 327 | name: "Enter a new task", 328 | }); 329 | 330 | await userEvent.type( 331 | addTaskTextbox, 332 | "test task (authenticated add with error)!{enter}" 333 | ); 334 | 335 | const newTaskElement = screen.getByText( 336 | "test task (authenticated add with error)!" 337 | ); 338 | 339 | // TODO: We should probably not show the task being in the list if there was an error! 340 | await waitFor(() => { 341 | expect(newTaskElement).toBeInTheDocument(); 342 | }); 343 | }); 344 | }); 345 | -------------------------------------------------------------------------------- /components/header.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styles from "../styles/Header.module.css"; 3 | import { useSession, signIn, signOut } from "next-auth/react"; 4 | 5 | export default function Header() { 6 | const { data: session, status } = useSession(); 7 | 8 | return ( 9 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /components/taskitem.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styles from "../styles/TaskItem.module.css"; 3 | 4 | export default function TaskItem({ text, checked, id, handleDelete }) { 5 | const [done, setDone] = React.useState(checked); 6 | 7 | const handleSetDone = (event) => { 8 | setDone(!done); 9 | }; 10 | 11 | return ( 12 |
13 |
  • 14 | 20 | 36 | 40 | {text} 41 | 42 | 52 |
  • 53 |
    54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /components/tasklist.js: -------------------------------------------------------------------------------- 1 | import TaskItem from "./taskitem"; 2 | import React from "react"; 3 | import { useSession } from "next-auth/react"; 4 | import styles from "../styles/TaskList.module.css"; 5 | 6 | export default function TaskList() { 7 | const [tasks, updateTasks] = React.useState([]); 8 | const { status } = useSession(); 9 | 10 | async function getTasks() { 11 | try { 12 | const res = await fetch("api/tasks"); 13 | 14 | if (res.ok) { 15 | const data = await res.json(); 16 | 17 | updateTasks( 18 | data.map((task) => { 19 | return { 20 | text: task.name, 21 | checked: task.checked, 22 | id: task.id, 23 | }; 24 | }) 25 | ); 26 | } 27 | } catch (e) { 28 | console.error("Error when fetching list of tasks:", e); 29 | } 30 | } 31 | 32 | React.useEffect(() => { 33 | if (status === "authenticated") { 34 | getTasks(); 35 | } 36 | }, [status]); 37 | 38 | const addTask = (text) => { 39 | const newTask = { 40 | text, 41 | checked: false, 42 | id: Date.now(), 43 | }; 44 | 45 | updateTasks((prevTasks) => { 46 | return [...prevTasks, newTask]; 47 | }); 48 | }; 49 | 50 | const handleDeleteTask = async (event) => { 51 | const IDToDelete = event.target.dataset.deleteid; 52 | 53 | updateTasks((prevTasks) => { 54 | return prevTasks.filter((task) => task.id !== Number(IDToDelete)); 55 | }); 56 | 57 | if (status === "authenticated") { 58 | await fetch("/api/tasks/" + IDToDelete, { 59 | method: "DELETE", 60 | headers: { "Content-Type": "application/json" }, 61 | }); 62 | } 63 | }; 64 | 65 | const handleAddTask = async (event) => { 66 | // To avoid the default form behavior of sending the content to the web server. 67 | event.preventDefault(); 68 | 69 | try { 70 | const input = document.querySelector("form > input"); 71 | 72 | const text = input.value.trim(); 73 | if (text !== "") { 74 | addTask(text); 75 | input.value = ""; 76 | 77 | if (status === "authenticated") { 78 | await fetch("/api/tasks", { 79 | method: "POST", 80 | headers: { "Content-Type": "application/json" }, 81 | body: JSON.stringify({ name: text }), 82 | }); 83 | } 84 | } 85 | } catch (error) { 86 | console.error("Error when adding a task:", error); 87 | } 88 | }; 89 | 90 | return ( 91 |
    92 |
    93 | 99 |
    100 | 101 |
      102 | {tasks.length === 0 ? ( 103 |
    • 104 |
      105 | 114 | Task List 115 | 116 | 117 | 118 | 119 | 120 | 121 |

      Add your first task

      122 |

      What will you work on today?

      123 |
      124 |
    • 125 | ) : ( 126 | tasks.map((task) => { 127 | return ( 128 | 134 | ); 135 | }) 136 | )} 137 |
    138 |
    139 | ); 140 | } 141 | -------------------------------------------------------------------------------- /cypress.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require("cypress"); 2 | 3 | module.exports = defineConfig({ 4 | e2e: { 5 | // We've imported your old cypress plugins here. 6 | // You may want to clean this up later by importing these. 7 | setupNodeEvents(on, config) { 8 | return require("./cypress/plugins/index.js")(on, config); 9 | }, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /cypress/e2e/securityheaders.cy.js: -------------------------------------------------------------------------------- 1 | describe("Navigation", () => { 2 | it("should navigate to the main page and receive all proper security headers", () => { 3 | // Start from the index page 4 | cy.request("http://127.0.0.1:3000/").as("mainPage"); 5 | 6 | cy.get("@mainPage").should((response) => { 7 | expect(response.headers, "response headers").to.include({ 8 | "x-frame-options": "SAMEORIGIN", 9 | "x-content-type-options": "nosniff", 10 | "referrer-policy": "no-referrer-when-downgrade", 11 | "permissions-policy": 12 | "accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), camera=(), cross-origin-isolated=(), display-capture=(), document-domain=(), encrypted-media=(), execution-while-not-rendered=(), execution-while-out-of-viewport=(), fullscreen=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), navigation-override=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=()", 13 | // "content-security-policy": 14 | // "default-src 'none'; connect-src 'self' https://vitals.vercel-insights.com/v1/vitals; img-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self'", 15 | "x-xss-protection": "1; mode=block", 16 | }); 17 | }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | // eslint-disable-next-line no-unused-vars 19 | module.exports = (on, config) => { 20 | // `on` is used to hook into various events Cypress emits 21 | // `config` is the resolved Cypress config 22 | }; 23 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add('login', (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import "./commands"; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const nextJest = require("next/jest"); 2 | 3 | const createJestConfig = nextJest({ 4 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment 5 | dir: "./", 6 | }); 7 | 8 | // Add any custom config to be passed to Jest 9 | const customJestConfig = { 10 | setupFilesAfterEnv: [ 11 | "/jest.setup.js", 12 | "/lib/prismaMockSingleton.js", 13 | ], 14 | // if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work 15 | moduleDirectories: ["node_modules", "/"], 16 | transform: { 17 | // Use babel-jest to transpile tests with the next/babel preset 18 | // https://jestjs.io/docs/configuration#transform-objectstring-pathtotransformer--pathtotransformer-object 19 | "^.+\\.(js|jsx|ts|tsx)$": ["babel-jest", { presets: ["next/babel"] }], 20 | }, 21 | testEnvironment: "jest-environment-jsdom", 22 | moduleNameMapper: { 23 | "^jose$": require.resolve("jose"), 24 | "^@panva/hkdf$": require.resolve("@panva/hkdf"), 25 | "^uuid$": require.resolve("uuid"), 26 | "^preact-render-to-string$": require.resolve("preact-render-to-string"), 27 | "^preact$": require.resolve("preact"), 28 | }, 29 | testPathIgnorePatterns: ["/cypress/"], 30 | }; 31 | 32 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async 33 | module.exports = createJestConfig(customJestConfig); 34 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | // Optional: configure or set up a testing framework before each test. 2 | // If you delete this file, remove `setupFilesAfterEnv` from `jest.config.js` 3 | 4 | // Used for __tests__/testing-library.js 5 | // Learn more: https://github.com/testing-library/jest-dom 6 | import "@testing-library/jest-dom"; 7 | 8 | // Required to fix an error message. Reference used for the fix: 9 | // https://github.com/inrupt/solid-client-authn-js/issues/1676 10 | import { TextEncoder, TextDecoder } from "util"; 11 | global.TextEncoder = TextEncoder; 12 | global.TextDecoder = TextDecoder; 13 | 14 | if (typeof global.fetch === "undefined") { 15 | const fetchMock = require("jest-fetch-mock"); 16 | fetchMock.enableMocks(); 17 | fetchMock.dontMock(); 18 | } 19 | -------------------------------------------------------------------------------- /lib/prisma.js: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | let prisma; 4 | 5 | if (process.env.NODE_ENV === "production") { 6 | prisma = new PrismaClient(); 7 | } else { 8 | if (!global.prisma) { 9 | global.prisma = new PrismaClient(); 10 | } 11 | prisma = global.prisma; 12 | } 13 | 14 | export default prisma; 15 | -------------------------------------------------------------------------------- /lib/prismaMockSingleton.js: -------------------------------------------------------------------------------- 1 | import { mockDeep, mockReset } from "jest-mock-extended"; 2 | import prisma from "./prisma"; 3 | 4 | jest.mock("./prisma", () => ({ 5 | __esModule: true, 6 | default: mockDeep(), 7 | })); 8 | 9 | beforeEach(() => { 10 | mockReset(prismaMock); 11 | }); 12 | 13 | export const prismaMock = prisma; 14 | -------------------------------------------------------------------------------- /lib/session.js: -------------------------------------------------------------------------------- 1 | // Reference: https://github.com/yeungalan0/site-monorepo/blob/main/my_site/cypress/support/commands.ts 2 | import hkdf from "@panva/hkdf"; 3 | import { EncryptJWT } from "jose"; 4 | import { v4 as uuid } from "uuid"; 5 | 6 | // Function logic derived from https://github.com/nextauthjs/next-auth/blob/5c1826a8d1f8d8c2d26959d12375704b0a693bfc/packages/next-auth/src/jwt/index.ts#L113-L121 7 | async function getDerivedEncryptionKey(secret) { 8 | return hkdf("sha256", secret, "", "NextAuth.js Generated Encryption Key", 32); 9 | } 10 | 11 | // Function logic derived from https://github.com/nextauthjs/next-auth/blob/5c1826a8d1f8d8c2d26959d12375704b0a693bfc/packages/next-auth/src/jwt/index.ts#L16-L25 12 | export async function encodeJwt(claims, secret) { 13 | const maxAge = 30 * 24 * 60 * 60; // 30 days 14 | const encryptionSecret = await getDerivedEncryptionKey(secret); 15 | 16 | return new EncryptJWT(claims) 17 | .setProtectedHeader({ 18 | alg: "dir", 19 | enc: "A256GCM", 20 | }) 21 | .setIssuedAt() 22 | .setExpirationTime(Math.round(Date.now() / 1000 + maxAge)) 23 | .setJti(uuid()) 24 | .encrypt(encryptionSecret); 25 | } 26 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const securityHeaders = [ 2 | { 3 | key: 'X-Frame-Options', 4 | value: 'SAMEORIGIN' 5 | }, 6 | { 7 | key: 'X-Content-Type-Options', 8 | value: 'nosniff' 9 | }, 10 | { 11 | key: 'Referrer-Policy', 12 | value: 'no-referrer-when-downgrade' 13 | }, 14 | { 15 | key: 'Permissions-Policy', 16 | value: 'accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), camera=(), cross-origin-isolated=(), display-capture=(), document-domain=(), encrypted-media=(), execution-while-not-rendered=(), execution-while-out-of-viewport=(), fullscreen=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), navigation-override=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=()' 17 | }, 18 | /* 19 | { 20 | key: 'Content-Security-Policy', 21 | value: 22 | "default-src 'none'; connect-src 'self' https://vitals.vercel-insights.com/v1/vitals; img-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self'", 23 | }, 24 | */ 25 | { key: "Access-Control-Allow-Origin", value: process.env.CORS_ALLOWED_ORIGIN }, 26 | { 27 | key: 'X-XSS-Protection', 28 | value: '1; mode=block' 29 | } 30 | ] 31 | 32 | module.exports = { 33 | swcMinify: true, 34 | reactStrictMode: true, 35 | async headers() { 36 | return [ 37 | { 38 | // Apply the security headers to all routes in the application. 39 | source: '/:path*', 40 | headers: securityHeaders, 41 | } 42 | ] 43 | }, 44 | webpack: (config) => { 45 | config.module.rules.push({ 46 | test: /\.ya?ml$/, 47 | type: 'json', 48 | use: 'yaml-loader' 49 | }) 50 | 51 | return config 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "todo-nextjs", 3 | "private": true, 4 | "scripts": { 5 | "dev": "next dev", 6 | "build": "next build", 7 | "start": "next start", 8 | "lint": "next lint", 9 | "prepare": "husky install", 10 | "prettier-check": "prettier --check .", 11 | "postinstall": "prisma generate", 12 | "test": "jest --coverage --testPathPattern='unit'", 13 | "test:coverage": "jest --coverage", 14 | "test:watch": "jest --watch --testPathPattern='unit'", 15 | "test:e2e": "jest --testPathPattern='integration' --runInBand", 16 | "test:dependencies": "npm run build && npm run lint && npm run prettier-check && npm run test && npm run e2e:headless", 17 | "cypress": "cypress open", 18 | "cypress:headless": "cypress run", 19 | "e2e": "start-server-and-test start http://127.0.0.1:3000 cypress", 20 | "e2e:headless": "start-server-and-test start http://127.0.0.1:3000 'npm run database:reset && npm run cypress:headless && npm run test:e2e'", 21 | "database:reset": "cat ./postgresql/drop-tables.sql | podman exec -i postgres psql -U testuser -d testdb && npx prisma db push && npx prisma db seed" 22 | }, 23 | "dependencies": { 24 | "@prisma/client": "^6.9.0", 25 | "mobx": "^6.13.7", 26 | "net": "^1.0.2", 27 | "next": "^15.3.3", 28 | "next-auth": "^4.24.11", 29 | "react": "^19.0.0", 30 | "react-dom": "^19.1.0", 31 | "react-is": "19.1.0", 32 | "styled-components": "^6.1.18", 33 | "tls": "^0.0.1" 34 | }, 35 | "devDependencies": { 36 | "@commitlint/cli": "^19.8.1", 37 | "@commitlint/config-conventional": "^19.8.1", 38 | "@testing-library/jest-dom": "^6.6.3", 39 | "@testing-library/react": "^16.3.0", 40 | "@testing-library/user-event": "^14.6.1", 41 | "cypress": "^14.4.1", 42 | "eslint": "^9.28.0", 43 | "eslint-config-next": "^15.3.3", 44 | "eslint-config-prettier": "^10.1.5", 45 | "eslint-plugin-testing-library": "^7.4.0", 46 | "husky": "^9.1.7", 47 | "jest": "^29.7.0", 48 | "jest-environment-jsdom": "^29.7.0", 49 | "jest-fetch-mock": "^3.0.3", 50 | "jest-mock-extended": "^3.0.7", 51 | "jest-openapi": "^0.14.2", 52 | "node-mocks-http": "^1.17.2", 53 | "prettier": "^3.5.3", 54 | "prisma": "^6.9.0", 55 | "start-server-and-test": "^2.0.12" 56 | }, 57 | "commitlint": { 58 | "extends": [ 59 | "@commitlint/config-conventional" 60 | ] 61 | }, 62 | "prisma": { 63 | "seed": "node prisma/seed.js" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import "../styles/globals.css"; 2 | import { SessionProvider } from "next-auth/react"; 3 | 4 | export default function App({ 5 | Component, 6 | pageProps: { session, ...pageProps }, 7 | }) { 8 | return ( 9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /pages/api/auth/[...nextauth].js: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth"; 2 | import GithubProvider from "next-auth/providers/github"; 3 | import prisma from "../../../lib/prisma"; 4 | 5 | export const authOptions = { 6 | providers: [ 7 | GithubProvider({ 8 | clientId: process.env.GITHUB_ID, 9 | clientSecret: process.env.GITHUB_SECRET, 10 | }), 11 | ], 12 | secret: process.env.NEXTAUTH_SECRET, 13 | callbacks: { 14 | async signIn({ user, _account, _profile, _email, _credentials }) { 15 | const isAllowedToSignIn = await prisma.user.findUnique({ 16 | where: { 17 | email: user.email, 18 | }, 19 | }); 20 | 21 | return isAllowedToSignIn !== null; 22 | }, 23 | }, 24 | }; 25 | 26 | export default NextAuth(authOptions); 27 | -------------------------------------------------------------------------------- /pages/api/tasks/[taskId].js: -------------------------------------------------------------------------------- 1 | import { authOptions } from "../auth/[...nextauth]"; 2 | import prisma from "../../../lib/prisma"; 3 | import { getServerSession } from "next-auth/next"; 4 | 5 | export default async function handle(req, res) { 6 | const session = await getServerSession(req, res, authOptions); 7 | 8 | if (session) { 9 | if (req.method === "GET") { 10 | const task = await prisma.task.findUnique({ 11 | where: { 12 | id: Number(req.query.taskId), 13 | }, 14 | }); 15 | 16 | if (task !== null) { 17 | res.json(task); 18 | } else { 19 | res.status(404).send({ error: "Task Not Found" }); 20 | } 21 | } else if (req.method === "DELETE") { 22 | const task = await prisma.task.delete({ 23 | where: { id: Number(req.query.taskId) }, 24 | }); 25 | res.json(task); 26 | } else { 27 | res.status(405).send({ error: "Method Not Allowed" }); 28 | } 29 | } else { 30 | res.status(401).send({ error: "Valid Credentials Required" }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /pages/api/tasks/index.js: -------------------------------------------------------------------------------- 1 | import { authOptions } from "../auth/[...nextauth]"; 2 | import prisma from "../../../lib/prisma"; 3 | import { getServerSession } from "next-auth/next"; 4 | 5 | const secret = process.env.NEXTAUTH_SECRET; 6 | 7 | export default async function handle(req, res) { 8 | const session = await getServerSession(req, res, authOptions); 9 | 10 | if (session) { 11 | if (req.method === "GET") { 12 | const tasks = await prisma.task.findMany(); 13 | 14 | if (tasks.length !== 0) { 15 | res.json(tasks); 16 | } else { 17 | res.status(404).send({ error: "No Task Found" }); 18 | } 19 | } else if (req.method === "POST") { 20 | const { name } = req.body; 21 | 22 | if (!name) { 23 | return res 24 | .status(400) 25 | .send({ error: "Missing 'name' value in request body" }); 26 | } 27 | 28 | const result = await prisma.task.create({ 29 | data: { 30 | name: name, 31 | author: { connect: { email: session?.user?.email } }, 32 | }, 33 | }); 34 | 35 | res.json(result); 36 | } else { 37 | res.status(405).send({ error: "Method Not Allowed" }); 38 | } 39 | } else { 40 | res.status(401).send({ error: "Valid Credentials Required" }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import styles from "../styles/Home.module.css"; 3 | import Header from "../components/header"; 4 | import TaskList from "../components/tasklist"; 5 | 6 | export default function Home() { 7 | return ( 8 |
    9 | 10 | 14 | 18 | Next.js TODO App 19 | 25 | 26 | 27 |
    28 | 29 |
    30 |
    31 | 32 |
    33 |
    34 |
    35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /postgresql/Containerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/library/postgres:14.2 2 | ENV POSTGRES_USER testuser 3 | ENV POSTGRES_DB testdb 4 | ENV POSTGRES_PASSWORD testpassword 5 | EXPOSE 5432 6 | COPY init-user-db.sh /docker-entrypoint-initdb.d/ -------------------------------------------------------------------------------- /postgresql/drop-tables.sql: -------------------------------------------------------------------------------- 1 | DO $$ DECLARE 2 | r RECORD; 3 | BEGIN 4 | FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = current_schema()) LOOP 5 | EXECUTE 'DROP TABLE IF EXISTS ' || quote_ident(r.tablename) || ' CASCADE'; 6 | END LOOP; 7 | END $$; 8 | -------------------------------------------------------------------------------- /postgresql/init-user-db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL 5 | GRANT ALL PRIVILEGES ON DATABASE testdb TO testuser; 6 | GRANT pg_read_all_data TO testuser; 7 | GRANT pg_write_all_data TO testuser; 8 | EOSQL 9 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | binaryTargets = ["native", "debian-openssl-1.1.x"] 4 | } 5 | 6 | datasource db { 7 | provider = "postgresql" 8 | url = env("DATABASE_URL") 9 | shadowDatabaseUrl = env("SHADOW_DATABASE_URL") 10 | } 11 | 12 | model User { 13 | id Int @id @default(autoincrement()) 14 | createdAt DateTime @default(now()) @map(name: "created_at") 15 | updatedAt DateTime @updatedAt @map(name: "updated_at") 16 | email String @unique 17 | name String? 18 | role Role @default(USER) 19 | tasks Task[] 20 | @@map(name: "users") 21 | } 22 | 23 | model Task { 24 | id Int @id @default(autoincrement()) 25 | createdAt DateTime @default(now()) @map(name: "created_at") 26 | updatedAt DateTime @updatedAt @map(name: "updated_at") 27 | name String @db.VarChar(255) 28 | checked Boolean @default(false) 29 | author User @relation(fields: [authorId], references: [id]) 30 | authorId Int 31 | } 32 | 33 | enum Role { 34 | USER 35 | ADMIN 36 | } 37 | -------------------------------------------------------------------------------- /prisma/seed.js: -------------------------------------------------------------------------------- 1 | const { PrismaClient } = require("@prisma/client"); 2 | const prisma = new PrismaClient(); 3 | 4 | async function main() { 5 | const test_admin_user = await prisma.user.create({ 6 | data: { 7 | email: "testuser@email.com", 8 | name: "Test User", 9 | role: "ADMIN", 10 | }, 11 | }); 12 | 13 | console.log({ test_admin_user }); 14 | } 15 | main() 16 | .then(async () => { 17 | await prisma.$disconnect(); 18 | }) 19 | .catch(async (e) => { 20 | console.error(e); 21 | await prisma.$disconnect(); 22 | process.exit(1); 23 | }); 24 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidlag0/todo-nextjs/5ef77e1ed4e62f4d2472e14c094b245f94cfaf1b/public/favicon.ico -------------------------------------------------------------------------------- /public/openapi.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.2", 3 | "info": { 4 | "title": "Tasks", 5 | "description": "Manage a common task list. Add tasks, mark tasks as done and remove task from the list. Data is available in JSON format.", 6 | "version": "0.0.1", 7 | "termsOfService": "https://todo-nextjs.davidlaganiere.com/terms", 8 | "contact": { 9 | "name": "Tasks API", 10 | "url": "https://todo-nextjs.davidlaganiere.com/api", 11 | "email": "do-not-contact@does-not-exist.com" 12 | }, 13 | "license": { 14 | "name": "CC Attribution-ShareAlike 4.0 (CC BY-SA 4.0)", 15 | "url": "https://todo-nextjs.davidlaganiere.com/price" 16 | } 17 | }, 18 | "paths": { 19 | "/tasks": { 20 | "get": { 21 | "tags": ["Tasks"], 22 | "summary": "Get current shared list of tasks.", 23 | "description": "Access the shared list of tasks to collaborate with other users.", 24 | "operationId": "getTasks", 25 | "parameters": [ 26 | { 27 | "$ref": "#/components/parameters/name" 28 | }, 29 | { 30 | "$ref": "#/components/parameters/done" 31 | }, 32 | { 33 | "$ref": "#/components/parameters/todo" 34 | } 35 | ], 36 | "responses": { 37 | "200": { 38 | "description": "Successful response", 39 | "content": { 40 | "application/json": { 41 | "schema": { 42 | "$ref": "#/components/schemas/200" 43 | } 44 | } 45 | } 46 | }, 47 | "404": { 48 | "description": "Not found response", 49 | "content": { 50 | "application/json": { 51 | "schema": { 52 | "$ref": "#/components/schemas/404" 53 | } 54 | } 55 | } 56 | } 57 | } 58 | }, 59 | "post": { 60 | "tags": ["Tasks"], 61 | "summary": "Add a new task.", 62 | "description": "Add a new task by submitting a task name.", 63 | "operationId": "addTask", 64 | "requestBody": { 65 | "content": { 66 | "application/json": { 67 | "schema": { 68 | "type": "object" 69 | }, 70 | "example": { 71 | "name": "Wash clothes" 72 | } 73 | } 74 | } 75 | }, 76 | "responses": { 77 | "200": { 78 | "description": "Successful response", 79 | "content": { 80 | "application/json": { 81 | "schema": { 82 | "$ref": "#/components/schemas/Task" 83 | } 84 | } 85 | } 86 | }, 87 | "400": { 88 | "description": "Bad request", 89 | "content": { 90 | "application/json": { 91 | "schema": { 92 | "$ref": "#/components/schemas/Error" 93 | } 94 | } 95 | } 96 | } 97 | } 98 | } 99 | }, 100 | "/tasks/{id}": { 101 | "get": { 102 | "tags": ["Tasks"], 103 | "summary": "Get a specific task.", 104 | "description": "Get a task identified by a specific ID.", 105 | "operationId": "getTaskById", 106 | "parameters": [ 107 | { 108 | "name": "id", 109 | "in": "path", 110 | "description": "Task ID", 111 | "required": true, 112 | "schema": { 113 | "type": "integer", 114 | "format": "int64" 115 | } 116 | } 117 | ], 118 | "responses": { 119 | "200": { 120 | "description": "Successful response", 121 | "content": { 122 | "application/json": { 123 | "schema": { 124 | "$ref": "#/components/schemas/Task" 125 | } 126 | } 127 | } 128 | }, 129 | "404": { 130 | "description": "Not found response", 131 | "content": { 132 | "application/json": { 133 | "schema": { 134 | "$ref": "#/components/schemas/404" 135 | } 136 | } 137 | } 138 | } 139 | } 140 | } 141 | } 142 | }, 143 | "components": { 144 | "parameters": { 145 | "name": { 146 | "name": "name", 147 | "in": "query", 148 | "description": "**Task Name**. *Example: laundry*. The API responds with a list of results that match a searching word.", 149 | "schema": { 150 | "type": "string" 151 | } 152 | }, 153 | "done": { 154 | "name": "done", 155 | "in": "query", 156 | "description": "**Done Only**. Only show tasks that are marked as done.", 157 | "schema": { 158 | "type": "boolean" 159 | } 160 | }, 161 | "todo": { 162 | "name": "todo", 163 | "in": "query", 164 | "description": "**To Do Only**. Only show tasks that are still to be done.", 165 | "schema": { 166 | "type": "boolean" 167 | } 168 | } 169 | }, 170 | "schemas": { 171 | "200": { 172 | "title": "Successful response", 173 | "type": "array", 174 | "items": { 175 | "$ref": "#/components/schemas/Task" 176 | } 177 | }, 178 | "404": { 179 | "title": "Task not found", 180 | "type": "object", 181 | "properties": { 182 | "error": { 183 | "type": "string", 184 | "description": "Error message", 185 | "example": "No Task Found" 186 | } 187 | } 188 | }, 189 | "Task": { 190 | "title": "task", 191 | "type": "object", 192 | "properties": { 193 | "id": { 194 | "type": "integer", 195 | "format": "int64", 196 | "description": "Unique ID", 197 | "example": 5 198 | }, 199 | "createdAt": { 200 | "type": "string", 201 | "format": "date-time", 202 | "description": "Creation date and time", 203 | "example": "2022-01-30T08:30:00.123Z" 204 | }, 205 | "updatedAt": { 206 | "type": "string", 207 | "format": "date-time", 208 | "description": "Last update date and time", 209 | "example": "2022-01-30T08:30:00.123Z" 210 | }, 211 | "name": { 212 | "type": "string", 213 | "description": "Title", 214 | "example": "Wash clothes" 215 | }, 216 | "checked": { 217 | "type": "boolean", 218 | "description": "True if task is marked as done, false otherwise", 219 | "example": true 220 | } 221 | } 222 | }, 223 | "Error": { 224 | "type": "object", 225 | "properties": { 226 | "error": { 227 | "type": "string", 228 | "description": "Error message" 229 | } 230 | } 231 | } 232 | }, 233 | "securitySchemes": { 234 | "app_id": { 235 | "type": "oauth2", 236 | "description": "GitHub token to authorize requests.", 237 | "flows": { 238 | "implicit": { 239 | "authorizationUrl": "https://example.com/api/oauth/dialog", 240 | "scopes": { 241 | "write:pets": "modify pets in your account", 242 | "read:pets": "read your pets" 243 | } 244 | } 245 | } 246 | } 247 | } 248 | }, 249 | "servers": [ 250 | { 251 | "url": "https://todo-nextjs.davidlaganiere.com/api", 252 | "description": "Production server (uses live data)" 253 | }, 254 | { 255 | "url": "http://127.0.0.1:3000/api", 256 | "description": "Local development" 257 | } 258 | ], 259 | "security": [ 260 | { 261 | "app_id": [] 262 | } 263 | ] 264 | } 265 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /styles/Header.module.css: -------------------------------------------------------------------------------- 1 | .ul { 2 | list-style-type: none; 3 | margin: 0; 4 | padding: 0; 5 | overflow: hidden; 6 | background-color: #333; 7 | } 8 | 9 | .li { 10 | float: left; 11 | display: block; 12 | text-align: center; 13 | padding: 14px 16px; 14 | text-decoration: none; 15 | } 16 | 17 | .liRight { 18 | composes: li; 19 | float: right; 20 | } 21 | 22 | .appTitle { 23 | composes: li; 24 | color: var(--background-color); 25 | background-color: var(--primary-color); 26 | margin: 0.3em 0.3em 0.3em 0.3em; 27 | padding: 0.1em 0.5em 0.1em 0.5em; 28 | width: auto; 29 | display: inline-block; 30 | border-radius: var(--border-radius); 31 | font-weight: bold; 32 | font-size: 1.5em; 33 | } 34 | 35 | .loginMessage { 36 | color: #ffffff; 37 | } 38 | 39 | .button { 40 | margin: 0 1em 0 1em; 41 | } 42 | -------------------------------------------------------------------------------- /styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 100%; 3 | max-width: 500px; 4 | min-width: 300px; 5 | margin: 2em auto; 6 | } 7 | -------------------------------------------------------------------------------- /styles/TaskItem.module.css: -------------------------------------------------------------------------------- 1 | .global { 2 | box-sizing: inherit; 3 | } 4 | 5 | .inputCheckbox { 6 | display: none; 7 | } 8 | 9 | .svg { 10 | width: 24px; 11 | height: 24px; 12 | stroke: var(--primary-color); 13 | stroke-width: 2; 14 | stroke-linecap: square; 15 | stroke-linejoin: miter; 16 | fill: none; 17 | color: var(--primary-color); 18 | } 19 | 20 | .listTaskItem { 21 | width: 100%; 22 | display: flex; 23 | align-items: center; 24 | justify-content: space-between; 25 | border-radius: var(--border-radius); 26 | padding: 8px 10px 8px 10px; 27 | background-color: var(--background-color); 28 | color: #000000; 29 | font-size: 16px; 30 | font-weight: normal; 31 | margin: 3px 0 3px 0; 32 | } 33 | 34 | .listTaskLabel { 35 | border: none; 36 | background-color: transparent; 37 | outline: none; 38 | cursor: pointer; 39 | } 40 | 41 | .listTaskCheckmark { 42 | transition: stroke-dashoffset 0.1s linear; 43 | } 44 | 45 | .listTaskCheckmarkChecked { 46 | composes: listTaskCheckmark; 47 | stroke-dashoffset: 0; 48 | } 49 | 50 | .listTaskText { 51 | width: 100%; 52 | padding-left: 7px; 53 | overflow: hidden; 54 | text-overflow: ellipsis; 55 | } 56 | 57 | .listTaskTextCrossed { 58 | composes: listTaskText; 59 | text-decoration: line-through; 60 | } 61 | 62 | .listTaskButton { 63 | border: none; 64 | background-color: transparent; 65 | outline: none; 66 | cursor: pointer; 67 | padding: 0; 68 | } 69 | 70 | .listTaskButtonSVG { 71 | composes: listTaskButton svg; 72 | pointer-events: none; 73 | } 74 | -------------------------------------------------------------------------------- /styles/TaskList.module.css: -------------------------------------------------------------------------------- 1 | .global { 2 | box-sizing: inherit; 3 | } 4 | 5 | .taskFormInput { 6 | display: inline-block; 7 | color: var(--background-color); 8 | background-color: var(--primary-color); 9 | width: 100%; 10 | border-radius: var(--border-radius); 11 | padding: 15px 10px 15px 10px; 12 | border: none; 13 | outline: none; 14 | font-size: 16px; 15 | font-weight: normal; 16 | } 17 | 18 | .ul { 19 | padding: 0; 20 | list-style-type: none; 21 | } 22 | 23 | .emptyState { 24 | background-color: var(--background-color); 25 | border-radius: var(--border-radius); 26 | margin-top: 10px; 27 | padding-bottom: 1px; 28 | display: flex; 29 | flex-direction: column; 30 | align-items: center; 31 | align-content: center; 32 | color: var(--primary-color); 33 | } 34 | 35 | .emptyStateSVG { 36 | width: 120px; 37 | height: 120px; 38 | stroke: var(--primary-color); 39 | stroke-width: 0.4; 40 | stroke-linecap: square; 41 | stroke-linejoin: miter; 42 | fill: none; 43 | } 44 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary-color: #155b94; 3 | --background-color: #ffffff; 4 | --border-radius: 3px; 5 | } 6 | 7 | html { 8 | box-sizing: border-box; 9 | height: 100%; 10 | } 11 | 12 | * { 13 | box-sizing: inherit; 14 | } 15 | 16 | body { 17 | font-family: "Helvetica Neue", sans-serif; 18 | background-color: #e7f5fd; 19 | margin: 0px; 20 | } 21 | --------------------------------------------------------------------------------