diff --git a/.eslintrc.js b/.eslintrc.js index 5aef503a..61ba3f32 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -4,6 +4,7 @@ module.exports = { es2021: true, node: true, browser: true, + "cypress/globals": true }, extends: ["eslint:recommended", "plugin:react/recommended", "prettier"], parserOptions: { @@ -16,7 +17,7 @@ module.exports = { ecmaVersion: "latest", sourceType: "module", }, - plugins: ["react", "react-hooks", "prettier"], + plugins: ["react", "react-hooks", "prettier", "cypress"], rules: { indent: [ "error", diff --git a/.github/workflows/review-pull-request.yml b/.github/workflows/review-pull-request.yml new file mode 100644 index 00000000..660c1b2a --- /dev/null +++ b/.github/workflows/review-pull-request.yml @@ -0,0 +1,51 @@ +name: Review Pull Request + +on: + + push: + branches: + - main + - development + pull_request: + branches: + - main + - development + +permissions: + checks: write + contents: write + +jobs: + review-pull-request: + name: Run tests to review pull requests + timeout-minutes: 10 + runs-on: ubuntu-latest + environment: DEV + env: + DB_STRING: ${{ vars.DB_STRING }} + DISCORD_CLIENT_ID: ${{ secrets.DISCORD_CLIENT_ID }} + DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }} + OAUTH_REDIRECT_URL: ${{ vars.OAUTH_REDIRECT_URL }} + MOCK_USER: ${{ vars.MOCK_USER }} + NODE_ENV: ${{ vars.NODE_ENV }} + + steps: + - name: Check out Git repository + uses: actions/checkout@v2 + + - name: Set up Node.js + uses: actions/setup-node@v1 + with: + node-version: 16.15.0 + + - name: Install Node.js dependencies + run: npm ci + + - name: Run linters + uses: wearerequired/lint-action@v2 + with: + eslint: true + + - name: Run Front end test + run: npm run test-frontend + diff --git a/.gitignore b/.gitignore index 9285087b..c9bff93e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,9 @@ node_modules .vscode build .DS_Store -.mongo +.mongo* formLogic.txt +coverage +.nyc_output +cypress/screenshots +cypress/videos \ No newline at end of file diff --git a/client/package.json b/client/package.json index d7c086e4..7593b1bf 100644 --- a/client/package.json +++ b/client/package.json @@ -2,6 +2,7 @@ "proxy": "http://localhost:2121", "scripts": { "start": "react-scripts start", + "start:coverage": "react-scripts -r @cypress/instrument-cra start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", @@ -31,5 +32,6 @@ "tailwindcss": "^3.2.2", "axios": "^1.1.3", "nanoid": "^4.0.0" - } + }, + "cypressWebpackConfigPath": "../node_modules/react-scripts/config/webpack.config.js" } diff --git a/client/public/index.html b/client/public/index.html index 4605b518..b437a057 100644 --- a/client/public/index.html +++ b/client/public/index.html @@ -13,7 +13,7 @@ PDT nonrecurring +// Sunday, March 12, 2023, 2:00:00 AM clocks are turned forward 1 hour to March 12, 2023, 3:00:00 AM +// 2 AM - 3 AM doesn't exist +const PSTtoPDTnonrecurring = { + timeZone: "America/Los_Angeles", + dates: ["2023-03-12"], + times() { + const [d] = this.dates; + // prettier-ignore + return [ + [[ "01:30", "01:45", Date.parse(`${d}T01:30:00.000-08:00`), Date.parse(`${d}T01:45:00.000-08:00`) ]], + [[ "01:30", "02:00", Date.parse(`${d}T01:30:00.000-08:00`), Date.parse(`${d}T03:00:00.000-07:00`) ]], + [[ "01:30", "02:30", Date.parse(`${d}T01:30:00.000-08:00`), Date.parse(`${d}T03:30:00.000-07:00`) ]], + [[ "01:30", "03:00", Date.parse(`${d}T01:30:00.000-08:00`), Date.parse(`${d}T04:00:00.000-07:00`) ]], + [[ "01:30", "03:30", Date.parse(`${d}T01:30:00.000-08:00`), Date.parse(`${d}T04:30:00.000-07:00`) ]], + [[ "02:00", "02:30", Date.parse(`${d}T03:00:00.000-07:00`), Date.parse(`${d}T03:30:00.000-07:00`) ]], + [[ "02:00", "03:00", Date.parse(`${d}T03:00:00.000-07:00`), Date.parse(`${d}T04:00:00.000-07:00`) ]], + [[ "02:00", "03:30", Date.parse(`${d}T03:00:00.000-07:00`), Date.parse(`${d}T04:30:00.000-07:00`) ]], + [[ "02:30", "02:45", Date.parse(`${d}T03:30:00.000-07:00`), Date.parse(`${d}T03:45:00.000-07:00`) ]], + [[ "02:30", "03:00", Date.parse(`${d}T03:30:00.000-07:00`), Date.parse(`${d}T04:00:00.000-07:00`) ]], + [[ "02:30", "03:30", Date.parse(`${d}T03:30:00.000-07:00`), Date.parse(`${d}T04:30:00.000-07:00`) ]], + [[ "03:00", "03:30", Date.parse(`${d}T03:00:00.000-07:00`), Date.parse(`${d}T03:30:00.000-07:00`) ]], + ] + }, +}; + +const PSTtoPDTrecurring = { + timeZone: "America/Los_Angeles", + recurring: { rate: "weekly", days: ["7"] }, + groupId: "1234", + dates: ["2023-03-05", "2023-03-12", "2023-03-19"], + times() { + const [d1, d2, d3] = this.dates; + // prettier-ignore + return [ + [ + [ "01:30", "01:45", Date.parse(`${d1}T01:30:00.000-08:00`), Date.parse(`${d1}T01:45:00.000-08:00`) ], + [ "01:30", "01:45", Date.parse(`${d2}T01:30:00.000-08:00`), Date.parse(`${d2}T01:45:00.000-08:00`) ], + [ "01:30", "01:45", Date.parse(`${d3}T01:30:00.000-07:00`), Date.parse(`${d3}T01:45:00.000-07:00`) ], + ], + [ + [ "01:30", "02:00", Date.parse(`${d1}T01:30:00.000-08:00`), Date.parse(`${d1}T02:00:00.000-08:00`) ], + [ "01:30", "02:00", Date.parse(`${d2}T01:30:00.000-08:00`), Date.parse(`${d2}T03:00:00.000-07:00`) ], + [ "01:30", "02:00", Date.parse(`${d3}T01:30:00.000-07:00`), Date.parse(`${d3}T02:00:00.000-07:00`) ], + ], + [ + [ "01:30", "02:30", Date.parse(`${d1}T01:30:00.000-08:00`), Date.parse(`${d1}T02:30:00.000-08:00`) ], + [ "01:30", "02:30", Date.parse(`${d2}T01:30:00.000-08:00`), Date.parse(`${d2}T03:30:00.000-07:00`) ], + [ "01:30", "02:30", Date.parse(`${d3}T01:30:00.000-07:00`), Date.parse(`${d3}T02:30:00.000-07:00`) ], + ], + [ + [ "01:30", "03:00", Date.parse(`${d1}T01:30:00.000-08:00`), Date.parse(`${d1}T03:00:00.000-08:00`) ], + [ "01:30", "03:00", Date.parse(`${d2}T01:30:00.000-08:00`), Date.parse(`${d2}T04:00:00.000-07:00`) ], + [ "01:30", "03:00", Date.parse(`${d3}T01:30:00.000-07:00`), Date.parse(`${d3}T03:00:00.000-07:00`) ], + ], + [ + [ "01:30", "03:30", Date.parse(`${d1}T01:30:00.000-08:00`), Date.parse(`${d1}T03:30:00.000-08:00`) ], + [ "01:30", "03:30", Date.parse(`${d2}T01:30:00.000-08:00`), Date.parse(`${d2}T04:30:00.000-07:00`) ], + [ "01:30", "03:30", Date.parse(`${d3}T01:30:00.000-07:00`), Date.parse(`${d3}T03:30:00.000-07:00`) ], + ], + [ + [ "02:00", "02:30", Date.parse(`${d1}T02:00:00.000-08:00`), Date.parse(`${d1}T02:30:00.000-08:00`) ], + [ "02:00", "02:30", Date.parse(`${d2}T03:00:00.000-07:00`), Date.parse(`${d2}T03:30:00.000-07:00`) ], + [ "02:00", "02:30", Date.parse(`${d3}T02:00:00.000-07:00`), Date.parse(`${d3}T02:30:00.000-07:00`) ], + ], + [ + [ "02:00", "03:00", Date.parse(`${d1}T02:00:00.000-08:00`), Date.parse(`${d1}T03:00:00.000-08:00`) ], + [ "02:00", "03:00", Date.parse(`${d2}T03:00:00.000-07:00`), Date.parse(`${d2}T04:00:00.000-07:00`) ], + [ "02:00", "03:00", Date.parse(`${d3}T02:00:00.000-07:00`), Date.parse(`${d3}T03:00:00.000-07:00`) ], + ], + [ + [ "02:00", "03:30", Date.parse(`${d1}T02:00:00.000-08:00`), Date.parse(`${d1}T03:30:00.000-08:00`) ], + [ "02:00", "03:30", Date.parse(`${d2}T03:00:00.000-07:00`), Date.parse(`${d2}T04:30:00.000-07:00`) ], + [ "02:00", "03:30", Date.parse(`${d3}T02:00:00.000-07:00`), Date.parse(`${d3}T03:30:00.000-07:00`) ], + ], + [ + [ "02:30", "02:45", Date.parse(`${d1}T02:30:00.000-08:00`), Date.parse(`${d1}T02:45:00.000-08:00`) ], + [ "02:30", "02:45", Date.parse(`${d2}T03:30:00.000-07:00`), Date.parse(`${d2}T03:45:00.000-07:00`) ], + [ "02:30", "02:45", Date.parse(`${d3}T02:30:00.000-07:00`), Date.parse(`${d3}T02:45:00.000-07:00`) ], + ], + [ + [ "02:30", "03:00", Date.parse(`${d1}T02:30:00.000-08:00`), Date.parse(`${d1}T03:00:00.000-08:00`) ], + [ "02:30", "03:00", Date.parse(`${d2}T03:30:00.000-07:00`), Date.parse(`${d2}T04:00:00.000-07:00`) ], + [ "02:30", "03:00", Date.parse(`${d3}T02:30:00.000-07:00`), Date.parse(`${d3}T03:00:00.000-07:00`) ], + ], + [ + [ "02:30", "03:30", Date.parse(`${d1}T02:30:00.000-08:00`), Date.parse(`${d1}T03:30:00.000-08:00`) ], + [ "02:30", "03:30", Date.parse(`${d2}T03:30:00.000-07:00`), Date.parse(`${d2}T04:30:00.000-07:00`) ], + [ "02:30", "03:30", Date.parse(`${d3}T02:30:00.000-07:00`), Date.parse(`${d3}T03:30:00.000-07:00`) ], + ], + [ + [ "03:00", "03:30", Date.parse(`${d1}T03:00:00.000-08:00`), Date.parse(`${d1}T03:30:00.000-08:00`) ], + [ "03:00", "03:30", Date.parse(`${d2}T03:00:00.000-07:00`), Date.parse(`${d2}T03:30:00.000-07:00`) ], + [ "03:00", "03:30", Date.parse(`${d3}T03:00:00.000-07:00`), Date.parse(`${d3}T03:30:00.000-07:00`) ], + ], + ] + }, +}; + +// Sunday, November 5, 2023, 2:00:00 AM clocks are turned backward 1 hour to Sunday, November 5, 2023, 1:00:00 AM +// 1 AM - 2 AM occurs twice +// All event durations are saved according to the time on the clock. +// It might be not possible to create an event during the second occurrence of 1 - 2 AM +const PDTtoPSTnonrecurring = { + timeZone: "America/Los_Angeles", + dates: ["2023-11-05"], + times() { + const [d] = this.dates; + // prettier-ignore + return [ + [[ "00:30", "00:45", Date.parse(`${d}T00:30:00.000-07:00`), Date.parse(`${d}T00:45:00.000-07:00`) ]], + [[ "00:30", "01:00", Date.parse(`${d}T00:30:00.000-07:00`), Date.parse(`${d}T01:00:00.000-07:00`) ]], + [[ "00:30", "01:30", Date.parse(`${d}T00:30:00.000-07:00`), Date.parse(`${d}T01:30:00.000-07:00`) ]], + [[ "00:30", "02:00", Date.parse(`${d}T00:30:00.000-07:00`), Date.parse(`${d}T01:00:00.000-08:00`) ]], + [[ "00:30", "02:30", Date.parse(`${d}T00:30:00.000-07:00`), Date.parse(`${d}T01:30:00.000-08:00`) ]], + [[ "01:00", "01:30", Date.parse(`${d}T01:00:00.000-07:00`), Date.parse(`${d}T01:30:00.000-07:00`) ]], + [[ "01:00", "02:00", Date.parse(`${d}T01:00:00.000-07:00`), Date.parse(`${d}T01:00:00.000-08:00`) ]], + [[ "01:00", "02:30", Date.parse(`${d}T01:00:00.000-07:00`), Date.parse(`${d}T01:30:00.000-08:00`) ]], + [[ "01:30", "01:45", Date.parse(`${d}T01:30:00.000-07:00`), Date.parse(`${d}T01:45:00.000-07:00`) ]], + [[ "01:30", "02:00", Date.parse(`${d}T01:30:00.000-07:00`), Date.parse(`${d}T01:00:00.000-08:00`) ]], + [[ "01:30", "02:30", Date.parse(`${d}T01:30:00.000-07:00`), Date.parse(`${d}T01:30:00.000-08:00`) ]], + [[ "02:00", "02:30", Date.parse(`${d}T02:00:00.000-08:00`), Date.parse(`${d}T02:30:00.000-08:00`) ]], + ] + }, +}; + +// WEST -> WET recurring event +const PDTtoPSTrecurring = { + timeZone: "America/Los_Angeles", + recurring: { rate: "weekly", days: ["7"] }, + groupId: "1234", + dates: ["2023-10-29", "2023-11-05", "2023-11-12"], + times() { + const [d1, d2, d3] = this.dates; + // prettier-ignore + return [ + [ + [ "00:30", "00:45", Date.parse(`${d1}T00:30:00.000-07:00`), Date.parse(`${d1}T00:45:00.000-07:00`) ], + [ "00:30", "00:45", Date.parse(`${d2}T00:30:00.000-07:00`), Date.parse(`${d2}T00:45:00.000-07:00`) ], + [ "00:30", "00:45", Date.parse(`${d3}T00:30:00.000-08:00`), Date.parse(`${d3}T00:45:00.000-08:00`) ], + ], + [ + [ "00:30", "01:00", Date.parse(`${d1}T00:30:00.000-07:00`), Date.parse(`${d1}T01:00:00.000-07:00`) ], + [ "00:30", "01:00", Date.parse(`${d2}T00:30:00.000-07:00`), Date.parse(`${d2}T01:00:00.000-07:00`) ], + [ "00:30", "01:00", Date.parse(`${d3}T00:30:00.000-08:00`), Date.parse(`${d3}T01:00:00.000-08:00`) ], + ], + [ + [ "00:30", "01:30", Date.parse(`${d1}T00:30:00.000-07:00`), Date.parse(`${d1}T01:30:00.000-07:00`) ], + [ "00:30", "01:30", Date.parse(`${d2}T00:30:00.000-07:00`), Date.parse(`${d2}T01:30:00.000-07:00`) ], + [ "00:30", "01:30", Date.parse(`${d3}T00:30:00.000-08:00`), Date.parse(`${d3}T01:30:00.000-08:00`) ], + ], + [ + [ "00:30", "02:00", Date.parse(`${d1}T00:30:00.000-07:00`), Date.parse(`${d1}T02:00:00.000-07:00`) ], + [ "00:30", "02:00", Date.parse(`${d2}T00:30:00.000-07:00`), Date.parse(`${d2}T01:00:00.000-08:00`) ], + [ "00:30", "02:00", Date.parse(`${d3}T00:30:00.000-08:00`), Date.parse(`${d3}T02:00:00.000-08:00`) ], + ], + [ + [ "00:30", "02:30", Date.parse(`${d1}T00:30:00.000-07:00`), Date.parse(`${d1}T02:30:00.000-07:00`) ], + [ "00:30", "02:30", Date.parse(`${d2}T00:30:00.000-07:00`), Date.parse(`${d2}T01:30:00.000-08:00`) ], + [ "00:30", "02:30", Date.parse(`${d3}T00:30:00.000-08:00`), Date.parse(`${d3}T02:30:00.000-08:00`) ], + ], + [ + [ "01:00", "01:30", Date.parse(`${d1}T01:00:00.000-07:00`), Date.parse(`${d1}T01:30:00.000-07:00`) ], + [ "01:00", "01:30", Date.parse(`${d2}T01:00:00.000-07:00`), Date.parse(`${d2}T01:30:00.000-07:00`) ], + [ "01:00", "01:30", Date.parse(`${d3}T01:00:00.000-08:00`), Date.parse(`${d3}T01:30:00.000-08:00`) ], + ], + [ + [ "01:00", "02:00", Date.parse(`${d1}T01:00:00.000-07:00`), Date.parse(`${d1}T02:00:00.000-07:00`) ], + [ "01:00", "02:00", Date.parse(`${d2}T01:00:00.000-07:00`), Date.parse(`${d2}T01:00:00.000-08:00`) ], + [ "01:00", "02:00", Date.parse(`${d3}T01:00:00.000-08:00`), Date.parse(`${d3}T02:00:00.000-08:00`) ], + ], + [ + [ "01:00", "02:30", Date.parse(`${d1}T01:00:00.000-07:00`), Date.parse(`${d1}T02:30:00.000-07:00`) ], + [ "01:00", "02:30", Date.parse(`${d2}T01:00:00.000-07:00`), Date.parse(`${d2}T01:30:00.000-08:00`) ], + [ "01:00", "02:30", Date.parse(`${d3}T01:00:00.000-08:00`), Date.parse(`${d3}T02:30:00.000-08:00`) ], + ], + [ + [ "01:30", "01:45", Date.parse(`${d1}T01:30:00.000-07:00`), Date.parse(`${d1}T01:45:00.000-07:00`) ], + [ "01:30", "01:45", Date.parse(`${d2}T01:30:00.000-07:00`), Date.parse(`${d2}T01:45:00.000-07:00`) ], + [ "01:30", "01:45", Date.parse(`${d3}T01:30:00.000-08:00`), Date.parse(`${d3}T01:45:00.000-08:00`) ], + ], + [ + [ "01:30", "02:00", Date.parse(`${d1}T01:30:00.000-07:00`), Date.parse(`${d1}T02:00:00.000-07:00`) ], + [ "01:30", "02:00", Date.parse(`${d2}T01:30:00.000-07:00`), Date.parse(`${d2}T01:00:00.000-08:00`) ], + [ "01:30", "02:00", Date.parse(`${d3}T01:30:00.000-08:00`), Date.parse(`${d3}T02:00:00.000-08:00`) ], + ], + [ + [ "01:30", "02:30", Date.parse(`${d1}T01:30:00.000-07:00`), Date.parse(`${d1}T02:30:00.000-07:00`) ], + [ "01:30", "02:30", Date.parse(`${d2}T01:30:00.000-07:00`), Date.parse(`${d2}T01:30:00.000-08:00`) ], + [ "01:30", "02:30", Date.parse(`${d3}T01:30:00.000-08:00`), Date.parse(`${d3}T02:30:00.000-08:00`) ], + ], + [ + [ "02:00", "02:30", Date.parse(`${d1}T02:00:00.000-07:00`), Date.parse(`${d1}T02:30:00.000-07:00`) ], + [ "02:00", "02:30", Date.parse(`${d2}T02:00:00.000-08:00`), Date.parse(`${d2}T02:30:00.000-08:00`) ], + [ "02:00", "02:30", Date.parse(`${d3}T02:00:00.000-08:00`), Date.parse(`${d3}T02:30:00.000-08:00`) ], + ] + ]; + }, +}; + +module.exports = { + generateTestCases, + WETnonrecurring, + WESTnonrecurring, + WETtoWESTnonrecurring, + WETtoWESTrecurring, + WESTtoWETnonrecurring, + WESTtoWETrecurring, + PSTnonrecurring, + PDTnonrecurring, + PSTtoPDTnonrecurring, + PSTtoPDTrecurring, + PDTtoPSTnonrecurring, + PDTtoPSTrecurring, +}; diff --git a/server/test/unit/httpError.test.js b/server/test/unit/httpError.test.js new file mode 100644 index 00000000..21aeda80 --- /dev/null +++ b/server/test/unit/httpError.test.js @@ -0,0 +1,17 @@ +const httpError = require("../../utilities/httpError"); + +describe("httpError", () => { + it('should return a custom error with message: "test"', () => { + const res = httpError(400, "test"); + expect(res).toBeInstanceOf(Error); + expect(res.status).toBe(400); + expect(res.message).toBe("test"); + }); + + it("should return a custom error with default message", () => { + const res = httpError(401); + expect(res).toBeInstanceOf(Error); + expect(res.status).toBe(401); + expect(res.message).toBe("Unauthorized"); + }); +}); diff --git a/server/test/unit/validateBody.test.js b/server/test/unit/validateBody.test.js new file mode 100644 index 00000000..58f60413 --- /dev/null +++ b/server/test/unit/validateBody.test.js @@ -0,0 +1,155 @@ +"use strict"; + +const validateBody = require("../../middleware/validateBody"); +const { + createEventSchema, + MAX_RECURRENCE_PERIOD, + EVENT_MAX_DATE, +} = require("../../models/Event"); +const sample = require("./validateBodyMockData"); + +const mockRequest = body => ({ body }); +const mockNext = () => jest.fn(); + +describe("validateBody", () => { + describe("req.body object has no errors", () => { + it("should call next for valid nonrecurring event", () => { + const req = mockRequest(sample.validFormDataNonRecurr); + const next = mockNext(); + validateBody(createEventSchema)(req, null, next); + expect(next).toBeCalled(); + }); + + it("should call next for valid recurring event", () => { + const req = mockRequest(sample.validFormDataRecurr); + const next = mockNext(); + validateBody(createEventSchema)(req, null, next); + expect(next).toBeCalled(); + }); + }); + + describe("req.body object shape", () => { + it("should error when a field is missing", () => { + const req = mockRequest(sample.missingTitle); + const next = mockNext(); + expect(() => validateBody(createEventSchema)(req, null, next)).toThrow( + '"title" is required' + ); + expect(next).not.toBeCalled(); + }); + + it("should error when there's an alien field", () => { + const req = mockRequest(sample.alienProp); + const next = mockNext(); + expect(() => validateBody(createEventSchema)(req, null, next)).toThrow( + /is not allowed/ + ); + expect(next).not.toBeCalled(); + }); + }); + + describe("string fields", () => { + it("should error when title is empty", () => { + const req = mockRequest(sample.emptyTitle); + const next = mockNext(); + expect(() => validateBody(createEventSchema)(req, null, next)).toThrow( + '"title" is not allowed to be empty' + ); + expect(next).not.toBeCalled(); + }); + + it("should error when title is too long", () => { + const req = mockRequest(sample.titleToLong); + const next = mockNext(); + expect(() => validateBody(createEventSchema)(req, null, next)).toThrow( + /length must be less than or equal to/ + ); + expect(next).not.toBeCalled(); + }); + }); + + describe("date fields", () => { + it('should error when "initialDate" is in wrong format', () => { + const req = mockRequest(sample.initialDateWrongFormat); + const next = mockNext(); + expect(() => validateBody(createEventSchema)(req, null, next)).toThrow( + /fails to match the required pattern/ + ); + expect(next).not.toBeCalled(); + }); + + it("should error when start date is in the past", () => { + const req = mockRequest(sample.startDateInThePast); + const next = mockNext(); + expect(() => validateBody(createEventSchema)(req, null, next)).toThrow( + /Event should start in the future/ + ); + expect(next).not.toBeCalled(); + }); + + it("should error when 'finalDate' is not equal to 'initialDate' for nonrecurring events", () => { + const req = mockRequest(sample.finalDateGreaterThanStartDateNonrecurr); + const next = mockNext(); + expect(() => validateBody(createEventSchema)(req, null, next)).toThrow( + /finalDate should be equal to initialDate for non-recurring events/ + ); + expect(next).not.toBeCalled(); + }); + + it("should error when 'finalDate' is before 'initialDate' for recurring events", () => { + const req = mockRequest(sample.finalDateLessThanStartDateRecurr); + const next = mockNext(); + expect(() => validateBody(createEventSchema)(req, null, next)).toThrow( + /finalDate must be greater than or equal to initialDate for recurring events/ + ); + expect(next).not.toBeCalled(); + }); + + it(`should error when 'finalDate' is more than ${MAX_RECURRENCE_PERIOD} days from 'initialDate' for recurring events`, () => { + const req = mockRequest(sample.exceedMaxPeriod); + const next = mockNext(); + expect(() => validateBody(createEventSchema)(req, null, next)).toThrow( + /must be within/ + ); + expect(next).not.toBeCalled(); + }); + + it(`should error when recurring event starts before and ends after ${EVENT_MAX_DATE}`, () => { + const req = mockRequest(sample.startBeforeEndAfterMAX); + const next = mockNext(); + expect(() => validateBody(createEventSchema)(req, null, next)).toThrow( + /finalDate must be before/ + ); + expect(next).not.toBeCalled(); + }); + }); + + describe("'recurring' field", () => { + it('should error when rucurring rate is not "noRecurr" or "weekly"', () => { + const req = mockRequest(sample.invalidRate); + const next = mockNext(); + expect(() => validateBody(createEventSchema)(req, null, next)).toThrow( + /must be one of/ + ); + expect(next).not.toBeCalled(); + }); + + it('should error when rate if "noRecurr" and days is not empty', () => { + const req = mockRequest(sample.invalidNonRecurDays); + const next = mockNext(); + expect(() => validateBody(createEventSchema)(req, null, next)).toThrow( + /must contain 0 items/ + ); + expect(next).not.toBeCalled(); + }); + + it("should error when day of the week is invalid", () => { + const req = mockRequest(sample.invalidDayName); + const next = mockNext(); + expect(() => validateBody(createEventSchema)(req, null, next)).toThrow( + /must be one of/ + ); + expect(next).not.toBeCalled(); + }); + }); +}); diff --git a/server/test/unit/validateBodyMockData.js b/server/test/unit/validateBodyMockData.js new file mode 100644 index 00000000..89ad249b --- /dev/null +++ b/server/test/unit/validateBodyMockData.js @@ -0,0 +1,138 @@ +"use strict"; + +const { Temporal } = require("@js-temporal/polyfill"); + +const { + STRING_MAX_LENGTH, + MAX_RECURRENCE_PERIOD, + EVENT_MAX_DATE, +} = require("../../models/Event"); + +const dateNow = Temporal.Now.plainDateISO(); +const dateYesterday = dateNow.subtract({ days: 1 }); +const dateTomorrow = dateNow.add({ days: 1 }); +const dateIn5Days = dateNow.add({ days: 5 }); +const dateAfterMaxPeriod = dateNow.add({ days: MAX_RECURRENCE_PERIOD + 1 }); +const dateBeforeMax = Temporal.PlainDate.from(EVENT_MAX_DATE).subtract({ + days: 2, +}); +const dateAfterMax = Temporal.PlainDate.from(EVENT_MAX_DATE).add({ + days: 2, +}); + +const timeNow = Temporal.Now.plainTimeISO().round({ + smallestUnit: "minute", + roundingMode: "ceil", +}); +const timeIn1hour = timeNow.subtract({ hours: 1 }); + +const validFormDataNonRecurr = { + title: "test", + description: "test", + location: "test", + discordName: "test", + recurring: { + rate: "noRecurr", + days: [], + }, + initialDate: dateNow.toString(), + startTime: timeNow.toString().slice(0, 5), + finalDate: dateNow.toString(), + endTime: timeIn1hour.toString().slice(0, 5), + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, +}; + +const validFormDataRecurr = { + title: "test", + description: "test", + location: "test", + discordName: "test", + recurring: { + rate: "weekly", + days: ["1", "2", "3", "4", "5", "6", "7"], + }, + initialDate: dateNow.toString(), + startTime: timeNow.toString().slice(0, 5), + finalDate: dateIn5Days.toString(), + endTime: timeIn1hour.toString().slice(0, 5), + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, +}; + +const missingTitle = { + ...validFormDataNonRecurr, +}; +delete missingTitle["title"]; + +const emptyTitle = { ...validFormDataNonRecurr, title: "" }; + +const titleToLong = { + ...validFormDataNonRecurr, + title: "f".repeat(STRING_MAX_LENGTH + 1), +}; + +const initialDateWrongFormat = { + ...validFormDataNonRecurr, + initialDate: "2023/11/30", +}; + +const startDateInThePast = { + ...validFormDataRecurr, + initialDate: dateYesterday.toString(), +}; + +const finalDateLessThanStartDateRecurr = { + ...validFormDataRecurr, + initialDate: dateIn5Days.toString(), + finalDate: dateTomorrow.toString(), +}; + +const finalDateGreaterThanStartDateNonrecurr = { + ...validFormDataNonRecurr, + finalDate: dateTomorrow.toString(), +}; + +const exceedMaxPeriod = { + ...validFormDataRecurr, + finalDate: dateAfterMaxPeriod.toString(), +}; + +const startBeforeEndAfterMAX = { + ...validFormDataRecurr, + initialDate: dateBeforeMax.toString(), + finalDate: dateAfterMax.toString(), +}; + +const alienProp = { ...validFormDataNonRecurr, a: 1 }; + +const invalidRate = { + ...validFormDataNonRecurr, + recurring: { rate: "invalid value", days: [] }, +}; + +const invalidNonRecurDays = { + ...validFormDataNonRecurr, + recurring: { rate: "noRecurr", days: ["Monday"] }, +}; + +const invalidDayName = { + ...validFormDataRecurr, + recurring: { rate: "weekly", days: ["January!"] }, +}; + +module.exports = { + validFormDataNonRecurr, + validFormDataRecurr, + missingTitle, + emptyTitle, + titleToLong, + initialDateWrongFormat, + startDateInThePast, + finalDateGreaterThanStartDateNonrecurr, + exceedMaxPeriod, + finalDateLessThanStartDateRecurr, + startBeforeEndAfterMAX, + alienProp, + invalidRate, + invalidNonRecurDays, + invalidDayName, +}; diff --git a/server/test/unit/validateObjectId.test.js b/server/test/unit/validateObjectId.test.js new file mode 100644 index 00000000..64780560 --- /dev/null +++ b/server/test/unit/validateObjectId.test.js @@ -0,0 +1,26 @@ +const validateObjectId = require("../../middleware/validateObjectId"); + +// Request is an object that contains 'params' object +const mockRequest = params => ({ params }); +// const mockResponse = () => { +// const res = {}; +// res.status = jest.fn().mockReturnValue(res); +// return res; +// }; +const mockNext = () => jest.fn(); + +describe("validateObjectId", () => { + it("should throw an error if mongoose ObjectId is invalid", () => { + const req = mockRequest({ id: "1" }); + const next = mockNext(); + expect(() => validateObjectId(req, null, next)).toThrow("Not found"); + expect(next).not.toHaveBeenCalled(); + }); + + it("should call next() if mongoose ObjectId is valid", () => { + const req = mockRequest({ id: "63c722239b1a9104e164d728" }); + const next = mockNext(); + expect(() => validateObjectId(req, null, next)).not.toThrow("Not found"); + expect(next).toHaveBeenCalled(); + }); +}); diff --git a/server/test/utils.js b/server/test/utils.js new file mode 100644 index 00000000..ee571798 --- /dev/null +++ b/server/test/utils.js @@ -0,0 +1,35 @@ +"use strict"; + +const mongoose = require("mongoose"); +const { MongoMemoryServer } = require("mongodb-memory-server"); + +class Database { + async setUp() { + try { + this.mongod = await MongoMemoryServer.create(); + const url = this.mongod.getUri(); + await mongoose.connect(url, { + useNewUrlParser: true, + useUnifiedTopology: true, + useFindAndModify: false, + useCreateIndex: true, + }); + } catch (err) { + console.error(err); + process.exit(1); + } + } + + async tearDown() { + try { + await mongoose.connection.dropDatabase(); + await mongoose.disconnect(); + await this.mongod.stop(); + } catch (err) { + console.error(err); + process.exit(1); + } + } +} + +module.exports = { Database }; diff --git a/server/utilities/createEventsArray.js b/server/utilities/createEventsArray.js index 4c37c949..646d221c 100644 --- a/server/utilities/createEventsArray.js +++ b/server/utilities/createEventsArray.js @@ -1,6 +1,19 @@ -const eachDayOfInterval = require("date-fns/eachDayOfInterval"); -const format = require("date-fns/format"); +"use strict"; + const { nanoid } = require("nanoid"); +const { Temporal } = require("@js-temporal/polyfill"); + +/** + * @param {string} date 'yyyy-mm-dd' + * @param {string} time 'hh:mm' + * @returns {Object} { year, month, day, hour, minute } + */ +function parseHtmlDatetime(date, time) { + // 'yyyy-mm-dd' and 'hh:mm' + const [year, month, day] = date.split("-"); + const [hour, minute] = time.split(":"); + return { year, month, day, hour, minute }; +} /** * Generates an array of events @@ -12,42 +25,68 @@ const createEventsArray = ({ title, description, location, - firstEventStart, - firstEventEnd, - lastEventStart, + initialDate, + finalDate, + startTime, + endTime, + timeZone, }) => { const { rate, days } = recurring; + // Time on the clock when the first event starts + const plainFirstEventStart = Temporal.PlainDateTime.from( + parseHtmlDatetime(initialDate, startTime) + ); + + // Time on the clock when the first event ends + const plainFirstEventEnd = Temporal.PlainDateTime.from( + parseHtmlDatetime(initialDate, endTime) + ); + + // Time on the clock when the last event starts + const plainLastEventStart = Temporal.PlainDateTime.from( + parseHtmlDatetime(finalDate, startTime) + ); + + // Duration is calculated based on the time clock independent of the timezone + // e.g. event on March 12, 1AM-2AM implies that the duration is 1 hour + const duration = + plainFirstEventStart.until(plainFirstEventEnd).sign === 1 + ? plainFirstEventStart.until(plainFirstEventEnd) + : plainFirstEventStart.until(plainFirstEventEnd.add({ days: 1 })); + + // Add timezone to the plain clock times + const zonedFirstEventStart = plainFirstEventStart.toZonedDateTime(timeZone); + const zonedLastEventStart = plainLastEventStart.toZonedDateTime(timeZone); + // Array of start times const eventStartDates = []; - let iter = new Date(firstEventStart); - while (iter <= lastEventStart) { - const utcDay = iter.getUTCDay().toString(); - // push to array if the recurring day in in the list, or if event is non-recurring - if (days.includes(utcDay) || rate === "noRecurr") { - eventStartDates.push(new Date(iter)); + + let i = 0; + while ( + Temporal.ZonedDateTime.compare( + zonedFirstEventStart.add({ days: i }), + zonedLastEventStart + ) <= 0 + ) { + if ( + days.includes( + zonedFirstEventStart.add({ days: i }).dayOfWeek.toString() + ) || + rate === "noRecurr" + ) { + eventStartDates.push(zonedFirstEventStart.add({ days: i })); } - iter.setDate(iter.getDate() + 1); + i += 1; } // Recurring events have the same group id. This allows deleting them all at once by this id. const groupId = rate === "noRecurr" ? null : nanoid(); // Create dates array with events information - const events = eventStartDates.map(startAt => { - let endAt = new Date(firstEventEnd); - // The order of setting date, month, and year is important! - endAt.setDate(startAt.getDate()); - endAt.setMonth(startAt.getMonth()); - endAt.setFullYear(startAt.getFullYear()); - - if (startAt > endAt) { - endAt.setDate(endAt.getDate() + 1); - } - - startAt = startAt.getTime(); - endAt = endAt.getTime(); - + const events = eventStartDates.map(date => { + const startAt = date.epochMilliseconds; + const endAt = date.add(duration).epochMilliseconds; return { title, description, location, groupId, startAt, endAt, rsvp: [] }; }); return events;