├── .eslintrc.js ├── .github ├── CODEOWNERS ├── PULL_REQUEST_TEMPLATE.md ├── renovate.json └── workflows │ ├── cd.yml │ └── ci.yml ├── .gitignore ├── .gitmodules ├── .prettierrc.js ├── .vscode ├── extensions.json └── settings.json ├── .yarn ├── plugins │ └── @yarnpkg │ │ ├── plugin-interactive-tools.cjs │ │ └── plugin-workspace-tools.cjs └── releases │ └── yarn-3.6.3.cjs ├── .yarnrc.yml ├── LICENSE ├── README.md ├── docker ├── .env_example ├── Dockerfile ├── dev.Dockerfile ├── docker-compose.dev.yml ├── docker-compose.prod.yml ├── docker-compose.yml ├── logs.sh ├── start.sh └── stop.sh ├── jest.config.js ├── package.json ├── sql_scripts ├── .env_example ├── Dockerfile ├── init.sql ├── migration_1.sql └── restore_backup.sh ├── src ├── app.module.ts ├── config │ ├── config.ts │ ├── config.types.ts │ └── index.ts ├── main.ts ├── relation-rules │ ├── __tests__ │ │ ├── anime-relations.test.txt │ │ ├── relation-rules.controller.v1.integration.test.ts │ │ ├── relation-rules.controller.v1.unit.test.ts │ │ ├── relation-rules.controller.v2.integration.test.ts │ │ ├── relation-rules.controller.v2.unit.test.ts │ │ └── relation-rules.service.unit.test.ts │ ├── index.ts │ ├── models │ │ ├── index.ts │ │ ├── v1 │ │ │ ├── get-relation-rules │ │ │ │ ├── get-relation-rules.request-params.v1.ts │ │ │ │ ├── get-relation-rules.response.v1.ts │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ └── v2 │ │ │ ├── get-relation-rules │ │ │ ├── get-relation-rules.request-params.v2.ts │ │ │ ├── get-relation-rules.response.v2.ts │ │ │ └── index.ts │ │ │ └── index.ts │ ├── relation-rules.controller.v1.ts │ ├── relation-rules.controller.v2.ts │ ├── relation-rules.module.ts │ ├── relation-rules.service.ts │ └── relation-rules.types.ts ├── repositories │ ├── __tests__ │ │ └── skip-times.repository.unit.test.ts │ ├── index.ts │ ├── repositories.module.ts │ └── skip-times.repository.ts ├── skip-times │ ├── __tests__ │ │ ├── skip-times.controller.v1.integration.test.ts │ │ ├── skip-times.controller.v1.unit.test.ts │ │ ├── skip-times.controller.v2.integration.test.ts │ │ ├── skip-times.controller.v2.unit.test.ts │ │ ├── skip-times.service.v1.unit.test.ts │ │ └── skip-times.service.v2.unit.test.ts │ ├── index.ts │ ├── models │ │ ├── index.ts │ │ ├── v1 │ │ │ ├── get-skip-times │ │ │ │ ├── get-skip-times.request-params.v1.ts │ │ │ │ ├── get-skip-times.request-query.v1.ts │ │ │ │ ├── get-skip-times.response.v1.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── post-create-skip-time │ │ │ │ ├── index.ts │ │ │ │ ├── post-create-skip-time.request-body.v1.ts │ │ │ │ ├── post-create-skip-time.request-params.v1.ts │ │ │ │ └── post-create-skip-time.response.v1.ts │ │ │ └── post-vote │ │ │ │ ├── index.ts │ │ │ │ ├── post-vote.request-body.v1.ts │ │ │ │ ├── post-vote.request-params.v1.ts │ │ │ │ └── post-vote.response.v1.ts │ │ └── v2 │ │ │ ├── get-skip-times │ │ │ ├── get-skip-times.request-params.v2.ts │ │ │ ├── get-skip-times.request-query.v2.ts │ │ │ ├── get-skip-times.response.v2.ts │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── post-create-skip-time │ │ │ ├── index.ts │ │ │ ├── post-create-skip-time.request-body.v2.ts │ │ │ ├── post-create-skip-time.request-params.v2.ts │ │ │ └── post-create-skip-time.response.v2.ts │ │ │ └── post-vote │ │ │ ├── index.ts │ │ │ ├── post-vote.request-body.v2.ts │ │ │ ├── post-vote.request-params.v2.ts │ │ │ └── post-vote.response.v2.ts │ ├── skip-times.controller.v1.ts │ ├── skip-times.controller.v2.ts │ ├── skip-times.module.ts │ ├── skip-times.service.v1.ts │ ├── skip-times.service.v2.ts │ └── skip-times.types.ts ├── utils │ ├── index.ts │ ├── logging │ │ ├── __tests__ │ │ │ └── morgan.middleware.integration.test.ts │ │ ├── index.ts │ │ └── morgan.middleware.ts │ ├── testing │ │ ├── index.ts │ │ ├── morgan-test.controller.ts │ │ ├── morgan-test.module.ts │ │ ├── post-skip-times-throttler-guard-test.controller.ts │ │ ├── post-skip-times-throttler-guard-test.module.ts │ │ ├── post-vote-skip-times-throttler-guard-test.controller.ts │ │ └── post-vote-skip-times-throttler-guard-test.module.ts │ ├── throttling │ │ ├── __tests__ │ │ │ ├── post-skip-times-throttler.v1.guard.integration.test.ts │ │ │ ├── post-skip-times-throttler.v2.guard.integration.test.ts │ │ │ ├── post-vote-skip-times-throttler.v1.guard.integration.test.ts │ │ │ └── post-vote-skip-times-throttler.v2.guard.integration.test.ts │ │ ├── index.ts │ │ ├── post-skip-times-throttler.v1.guard.ts │ │ ├── post-skip-times-throttler.v2.guard.ts │ │ ├── post-vote-skip-times-throttler.v1.guard.ts │ │ └── post-vote-skip-times-throttler.v2.guard.ts │ └── validation │ │ ├── __tests__ │ │ └── validators.unit.test.ts │ │ ├── index.ts │ │ └── validators.ts └── vote │ ├── __tests__ │ └── vote.service.unit.test.ts │ ├── index.ts │ ├── vote.module.ts │ ├── vote.service.ts │ └── vote.types.ts ├── tsconfig.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: ['@typescript-eslint', 'prettier'], 5 | rules: { 6 | 'prettier/prettier': 'error', 7 | 'import/prefer-default-export': 'off', 8 | '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 9 | '@typescript-eslint/explicit-function-return-type': 'off', 10 | '@typescript-eslint/interface-name-prefix': 'off', 11 | '@typescript-eslint/explicit-module-boundary-types': 'off', 12 | '@typescript-eslint/no-explicit-any': 'off', 13 | 'class-methods-use-this': 'off', 14 | }, 15 | overrides: [ 16 | { 17 | files: ['*.ts'], 18 | rules: { 19 | '@typescript-eslint/explicit-function-return-type': ['error'], 20 | }, 21 | }, 22 | { 23 | files: ['*.test.ts'], 24 | rules: { 25 | 'max-classes-per-file': ['off'], 26 | }, 27 | }, 28 | ], 29 | extends: ['airbnb-base', 'airbnb-typescript/base', 'prettier'], 30 | parserOptions: { 31 | project: './tsconfig.json', 32 | sourceType: 'module', 33 | createDefaultProgram: true, 34 | }, 35 | env: { 36 | node: true, 37 | jest: true, 38 | }, 39 | }; 40 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @lexesjan @dabreadman -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Purpose 2 | 3 | _Describe the problem or feature in addition to a link to the issues._ 4 | 5 | ## Proposed Change 6 | 7 | _How does this change address the problem?_ 8 | 9 | ## Checklist 10 | 11 | - [ ] Task 1 12 | - [ ] Task 2 13 | - [ ] Task 3 14 | 15 | ## Additional 16 | 17 | _What else is note worthy?_ 18 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "bumpVersion": "patch", 4 | "labels": ["automated update"], 5 | "packageRules": [ 6 | { 7 | "matchPackagePatterns": ["*"], 8 | "rangeStrategy": "bump" 9 | }, 10 | { 11 | "matchUpdateTypes": ["minor", "patch", "pin", "digest"], 12 | "automerge": true 13 | }, 14 | { 15 | "matchDepTypes": ["devDependencies"], 16 | "automerge": true 17 | } 18 | ], 19 | "platformAutomerge": true, 20 | "git-submodules": { 21 | "enabled": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: CD 2 | 3 | env: 4 | DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} 5 | DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} 6 | HOST: ${{ secrets.HOST }} 7 | USERNAME: ${{ secrets.USERNAME }} 8 | KEY: ${{ secrets.KEY }} 9 | PORT: ${{ secrets.PORT }} 10 | REPO_PATH: ${{ secrets.REPO_PATH }} 11 | 12 | on: 13 | push: 14 | branches: 15 | - main 16 | paths: 17 | - .github/workflows/cd.yml 18 | - src/** 19 | - sql_scripts/** 20 | - docker/** 21 | - package.json 22 | # add manual trigger 23 | workflow_dispatch: 24 | 25 | jobs: 26 | continuous-delivery: 27 | runs-on: ubuntu-latest 28 | defaults: 29 | run: 30 | working-directory: ./docker 31 | 32 | steps: 33 | - name: Checkout repo and submodules 34 | uses: actions/checkout@v3 35 | with: 36 | submodules: recursive 37 | 38 | - name: Docker login 39 | run: docker login --username ${{ env.DOCKER_USERNAME }} --password ${{ env.DOCKER_TOKEN }} 40 | 41 | - name: Docker pull latest images 42 | run: docker-compose pull 43 | 44 | - name: Docker build 45 | run: docker-compose -f ./docker-compose.yml -f ./docker-compose.prod.yml build 46 | 47 | - name: Determine api tag by version 48 | if: env.API_TAG == '' 49 | run: echo "API_TAG=$(jq -r .version ../package.json)" >> $GITHUB_ENV 50 | 51 | - name: Determine db tag by version 52 | if: env.DB_TAG == '' 53 | run: echo "DB_TAG=$(grep -oP '(\d+\.){2}\d+' ../sql_scripts/init.sql)" >> $GITHUB_ENV 54 | 55 | - name: Docker tag 56 | run: | 57 | docker tag ${{ env.DOCKER_USERNAME }}/aniskip-database:latest ${{ env.DOCKER_USERNAME }}/aniskip-database:${{ env.DB_TAG }} 58 | docker tag ${{ env.DOCKER_USERNAME }}/aniskip-api:latest ${{ env.DOCKER_USERNAME }}/aniskip-api:${{ env.API_TAG }} 59 | 60 | - name: Docker push with tag 61 | run: | 62 | docker push ${{ env.DOCKER_USERNAME }}/aniskip-database:${{ env.DB_TAG }} 63 | docker push ${{ env.DOCKER_USERNAME }}/aniskip-api:${{ env.API_TAG }} 64 | 65 | - name: Docker push with latest tag 66 | run: docker-compose -f ./docker-compose.yml -f ./docker-compose.prod.yml push 67 | 68 | - name: Deploy to production 69 | uses: appleboy/ssh-action@master 70 | with: 71 | host: ${{ env.HOST }} 72 | username: ${{ env.USERNAME }} 73 | KEY: ${{ env.KEY }} 74 | port: ${{ env.PORT }} 75 | script: | 76 | cd ${{ env.REPO_PATH }}/docker 77 | git checkout main 78 | git pull --recurse-submodules 79 | docker-compose pull 80 | ./stop.sh 81 | ./start.sh prod 82 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | env: 4 | POSTGRES_USER: postgres 5 | POSTGRES_PASSWORD: password 6 | 7 | on: 8 | push: 9 | branches: 10 | - main 11 | paths: 12 | - .github/workflows/ci.yml 13 | - sql_scripts/** 14 | - docker/** 15 | - src/** 16 | - package.json 17 | - yarn.lock 18 | pull_request: 19 | branches: 20 | - main 21 | 22 | # add manual trigger 23 | workflow_dispatch: 24 | 25 | jobs: 26 | continuous-integration-tests: 27 | runs-on: ubuntu-latest 28 | 29 | steps: 30 | - name: Checkout repo and submodules 31 | uses: actions/checkout@v3 32 | with: 33 | submodules: recursive 34 | 35 | # run tests 36 | - run: yarn install 37 | - run: yarn lint 38 | - run: yarn build 39 | - run: yarn test 40 | 41 | continuous-integration-docker: 42 | runs-on: ubuntu-latest 43 | 44 | steps: 45 | - name: Checkout repo and submodules 46 | uses: actions/checkout@v3 47 | with: 48 | submodules: recursive 49 | 50 | # test docker build 51 | - name: Docker pull latest images 52 | working-directory: ./docker 53 | run: docker-compose pull 54 | 55 | - name: Docker build 56 | working-directory: ./docker 57 | run: docker-compose -f ./docker-compose.yml -f ./docker-compose.prod.yml build 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | *.swp 10 | 11 | pids 12 | logs 13 | results 14 | tmp 15 | 16 | # Build 17 | public/css/main.css 18 | 19 | # Coverage reports 20 | coverage 21 | 22 | # API keys and secrets 23 | .env 24 | 25 | # Dependency directory 26 | node_modules 27 | bower_components 28 | 29 | # Editors 30 | .idea 31 | *.iml 32 | 33 | # OS metadata 34 | .DS_Store 35 | Thumbs.db 36 | 37 | # Ignore built ts files 38 | dist 39 | 40 | # yarn 41 | .yarn/* 42 | !.yarn/patches 43 | !.yarn/releases 44 | !.yarn/plugins 45 | !.yarn/sdks 46 | !.yarn/versions 47 | .pnp.* -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "deps/anime-relations"] 2 | path = deps/anime-relations 3 | url = https://github.com/erengy/anime-relations 4 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: "es5", 3 | tabWidth: 2, 4 | semi: true, 5 | singleQuote: true, 6 | }; -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "eamodio.gitlens", 5 | "esbenp.prettier-vscode", 6 | "mhutchie.git-graph", 7 | "tombonnike.vscode-status-bar-format-toggle" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.insertSpaces": true, 4 | "editor.tabSize": 2, 5 | "search.exclude": { 6 | "**/.yarn": true, 7 | "**/.pnp.*": true 8 | }, 9 | "files.eol": "\n", 10 | "typescript.preferences.importModuleSpecifier": "relative" 11 | } 12 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | plugins: 4 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs 5 | spec: "@yarnpkg/plugin-interactive-tools" 6 | - path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs 7 | spec: "@yarnpkg/plugin-workspace-tools" 8 | 9 | yarnPath: .yarn/releases/yarn-3.6.3.cjs 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Lexes Jan Mantiquilla 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 | # typescript-aniskip-api 2 | 3 | API for Aniskip web browser extension 4 | 5 | ## Getting started 6 | 7 | ### API Documentation 8 | 9 | The documentation for the API can be found at [`https://api.aniskip.com/api-docs`](https://api.aniskip.com/api-docs) 10 | 11 | ### Running the API 12 | 13 | #### Prerequisites 14 | 15 | You will need to have installed: 16 | 17 | 1. Docker 18 | 19 | #### Deploying the API for production 20 | 21 | The built images for this project can be found on [Docker Hub](https://hub.docker.com/u/lexesjan) 22 | 23 | 1. Create a `docker-compose.yml` file. An example one can be found [here](https://github.com/lexesjan/typescript-aniskip-api/blob/main/docker/docker-compose.yml) 24 | 1. Create a `.env` file in the same directory. An example one can be found [here](https://github.com/lexesjan/typescript-aniskip-api/blob/main/docker/.env_example) 25 | 1. Start the docker containers 26 | ``` 27 | docker-compose up -d 28 | ``` 29 | 1. Add TLS/SSL using a reverse proxy. You can use reverse proxy software like `traefik` or `NGINX`. You can easily create an `NGINX` config [here](https://www.digitalocean.com/community/tools/nginx) 30 | 31 | #### Running the API for development 32 | 33 | 1. Clone the repo 34 | ``` 35 | git clone https://github.com/lexesjan/typescript-aniskip-api 36 | ``` 37 | 1. Navigate into the cloned GitHub repository 38 | ``` 39 | cd typescript-aniskip-api 40 | ``` 41 | 1. Copy `.env_example` in the docker folder to `./docker/.env` and change `POSTGRES_PASSWORD` 42 | 1. Run the start script 43 | ``` 44 | ./docker/start.sh dev 45 | ``` 46 | -------------------------------------------------------------------------------- /docker/.env_example: -------------------------------------------------------------------------------- 1 | COMPOSE_PROJECT_NAME=aniskip 2 | POSTGRES_PASSWORD=strong_password -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | # Install dev dependencies stage 2 | FROM node:20-alpine as install-dev 3 | 4 | WORKDIR /usr/src/app 5 | 6 | COPY .yarn ./.yarn 7 | COPY package.json yarn.lock .yarnrc.yml ./ 8 | 9 | RUN yarn install 10 | 11 | # Build stage 12 | FROM node:20-alpine as build 13 | 14 | WORKDIR /usr/src/app 15 | 16 | COPY package.json tsconfig.json yarn.lock .yarnrc.yml ./ 17 | COPY src ./src 18 | COPY --from=install-dev /usr/src/app/.yarn ./.yarn 19 | COPY --from=install-dev /usr/src/app/node_modules ./node_modules 20 | 21 | RUN yarn build 22 | 23 | # Install prod dependencies stage 24 | FROM node:20-alpine as install-prod 25 | 26 | WORKDIR /usr/src/app 27 | 28 | COPY package.json yarn.lock .yarnrc.yml ./ 29 | COPY --from=install-dev /usr/src/app/.yarn ./.yarn 30 | 31 | RUN yarn workspaces focus --production 32 | 33 | # Run stage 34 | FROM node:20-alpine 35 | 36 | WORKDIR /usr/src/app 37 | 38 | COPY package.json ./ 39 | COPY deps/anime-relations/anime-relations.txt ./dist/deps/anime-relations/anime-relations.txt 40 | COPY --from=build /usr/src/app/dist ./dist 41 | COPY --from=install-prod /usr/src/app/node_modules ./node_modules 42 | 43 | RUN apk add --no-cache tini 44 | ENTRYPOINT ["/sbin/tini", "--"] 45 | 46 | CMD ["yarn", "node", "dist/src/main.js"] 47 | -------------------------------------------------------------------------------- /docker/dev.Dockerfile: -------------------------------------------------------------------------------- 1 | # Install dependencies stage 2 | FROM node:20-alpine as install 3 | 4 | WORKDIR /usr/src/app 5 | 6 | COPY .yarn ./.yarn 7 | COPY package.json yarn.lock .yarnrc.yml ./ 8 | 9 | RUN yarn install 10 | 11 | # Run stage 12 | FROM node:20-alpine 13 | 14 | WORKDIR /usr/src/app 15 | 16 | COPY package.json tsconfig.json yarn.lock .yarnrc.yml ./ 17 | COPY deps/anime-relations/anime-relations.txt ./deps/anime-relations/anime-relations.txt 18 | COPY src ./src 19 | COPY --from=install /usr/src/app/.yarn ./.yarn 20 | COPY --from=install /usr/src/app/node_modules ./node_modules 21 | 22 | RUN apk add --no-cache tini 23 | ENTRYPOINT ["/sbin/tini", "--"] 24 | 25 | CMD ["yarn", "dev"] 26 | -------------------------------------------------------------------------------- /docker/docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | services: 3 | api: 4 | build: 5 | dockerfile: ./docker/dev.Dockerfile 6 | context: '..' 7 | volumes: 8 | - ../src:/usr/src/app/src 9 | - ../package.json:/usr/src/app/package.json 10 | - ../yarn.lock:/usr/src/app/yarn.lock 11 | -------------------------------------------------------------------------------- /docker/docker-compose.prod.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | services: 3 | api: 4 | build: 5 | dockerfile: ./docker/Dockerfile 6 | context: '..' 7 | 8 | db: 9 | build: 10 | dockerfile: ./sql_scripts/Dockerfile 11 | context: '..' 12 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | services: 3 | api: 4 | image: lexesjan/aniskip-api 5 | restart: always 6 | ports: 7 | - '5000:5000' 8 | networks: 9 | - network 10 | environment: 11 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 12 | POSTGRES_HOST: db 13 | depends_on: 14 | - db 15 | 16 | db: 17 | image: lexesjan/aniskip-database 18 | restart: always 19 | environment: 20 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 21 | networks: 22 | - network 23 | volumes: 24 | - database_data:/var/lib/postgresql/data 25 | 26 | redis: 27 | image: redis:7-alpine 28 | restart: always 29 | networks: 30 | - network 31 | volumes: 32 | - redis_data:/data 33 | 34 | networks: 35 | network: 36 | driver: bridge 37 | 38 | volumes: 39 | database_data: 40 | redis_data: 41 | -------------------------------------------------------------------------------- /docker/logs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd "${BASH_SOURCE%/*}" 4 | 5 | docker-compose -f ./docker-compose.yml logs -f $1 -------------------------------------------------------------------------------- /docker/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | main () { 4 | if [ "$#" -ne 1 ] || [ "$1" != "prod" ] && [ "$1" != "prod-local" ] && [ "$1" != "dev" ]; then 5 | echo "Usage: $0 TYPE" 6 | echo "Start the aniskip api using docker" 7 | echo 8 | echo "TYPE: the startup type" 9 | echo " prod: deploying for production" 10 | echo " prod-local: start production build locally" 11 | echo " dev: start development build" 12 | return 1 13 | fi 14 | 15 | # ensure script can be ran anywhere 16 | cd "${BASH_SOURCE%/*}" 17 | 18 | case $1 in 19 | "prod") 20 | docker-compose up -d 21 | ;; 22 | "prod-local") 23 | docker-compose -f ./docker-compose.yml -f ./docker-compose.prod.yml up --build -d 24 | ;; 25 | "dev") 26 | docker-compose -f ./docker-compose.yml -f ./docker-compose.prod.yml -f ./docker-compose.dev.yml up --build -d 27 | ;; 28 | esac 29 | } 30 | 31 | main "$@" 32 | -------------------------------------------------------------------------------- /docker/stop.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd "${BASH_SOURCE%/*}" 4 | 5 | docker-compose -f ./docker-compose.yml down -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | coverageThreshold: { 5 | global: { 6 | branches: 80, 7 | functions: 80, 8 | lines: 80, 9 | statements: 80, 10 | }, 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript-aniskip-api", 3 | "version": "2.0.576", 4 | "description": "Provides the opening and ending skip times for the Aniskip extension", 5 | "repository": "https://github.com/lexesjan/typescript-aniskip-api", 6 | "contributors": [ 7 | "Lexes Jan Mantiquilla ", 8 | "Dabreadman " 9 | ], 10 | "license": "MIT", 11 | "private": true, 12 | "scripts": { 13 | "build": "tsc --project ./", 14 | "clean": "rimraf dist", 15 | "dev": "cross-env NODE_ENV=development nodemon --exec 'yarn ts-node' --legacy-watch src/main.ts", 16 | "lint": "eslint ./src --ext .js,.jsx,.ts,.tsx", 17 | "start": "cross-env NODE_ENV=production node dist/src/main.js", 18 | "test": "jest --coverage --maxWorkers=25% --testPathIgnorePatterns=\"/node_modules/|/dist/\"" 19 | }, 20 | "dependencies": { 21 | "@nestjs/common": "^9.4.3", 22 | "@nestjs/config": "^2.3.4", 23 | "@nestjs/core": "^9.4.3", 24 | "@nestjs/platform-express": "^9.4.3", 25 | "@nestjs/swagger": "^6.3.0", 26 | "@nestjs/throttler": "^4.2.1", 27 | "class-transformer": "^0.5.1", 28 | "class-validator": "^0.14.0", 29 | "express": "^4.18.2", 30 | "helmet": "^7.0.0", 31 | "ioredis": "^5.3.2", 32 | "md5": "^2.3.0", 33 | "morgan": "^1.10.0", 34 | "nestjs-throttler-storage-redis": "^0.4.0", 35 | "pg": "^8.11.3", 36 | "reflect-metadata": "^0.1.13", 37 | "rxjs": "^7.8.1", 38 | "swagger-ui-express": "^4.6.3" 39 | }, 40 | "devDependencies": { 41 | "@nestjs/testing": "^9.4.3", 42 | "@types/express": "^4.17.17", 43 | "@types/ioredis": "^5.0.0", 44 | "@types/jest": "^29.5.5", 45 | "@types/md5": "^2.3.2", 46 | "@types/morgan": "^1.9.5", 47 | "@types/node": "^20.6.1", 48 | "@types/pg": "^8.10.2", 49 | "@types/supertest": "^2.0.12", 50 | "@types/uuid": "^9.0.4", 51 | "@typescript-eslint/eslint-plugin": "^6.7.4", 52 | "@typescript-eslint/parser": "^6.7.4", 53 | "cross-env": "^7.0.3", 54 | "eslint": "8.49.0", 55 | "eslint-config-airbnb": "^19.0.4", 56 | "eslint-config-airbnb-base": "^15.0.0", 57 | "eslint-config-airbnb-typescript": "^17.1.0", 58 | "eslint-config-prettier": "^9.0.0", 59 | "eslint-import-resolver-node": "^0.3.9", 60 | "eslint-plugin-import": "^2.28.1", 61 | "eslint-plugin-jsx-a11y": "^6.7.1", 62 | "eslint-plugin-prettier": "^5.0.0", 63 | "jest": "^29.7.0", 64 | "nodemon": "^3.0.1", 65 | "pg-mem": "^2.6.13", 66 | "prettier": "^3.0.3", 67 | "rimraf": "^5.0.1", 68 | "supertest": "^6.3.3", 69 | "ts-jest": "^29.1.1", 70 | "ts-node": "^10.9.1", 71 | "typescript": "^5.2.2", 72 | "uuid": "^9.0.1" 73 | }, 74 | "packageManager": "yarn@3.6.3" 75 | } 76 | -------------------------------------------------------------------------------- /sql_scripts/.env_example: -------------------------------------------------------------------------------- 1 | GITHUB_PAT=d645b99a197d32a71de199e329f9c986b9427348 -------------------------------------------------------------------------------- /sql_scripts/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:15-alpine 2 | 3 | COPY ./sql_scripts/*.sql /docker-entrypoint-initdb.d/ 4 | -------------------------------------------------------------------------------- /sql_scripts/init.sql: -------------------------------------------------------------------------------- 1 | -- v1.1.1 2 | CREATE DATABASE db; 3 | 4 | \c db; 5 | CREATE TABLE skip_times ( 6 | skip_id uuid UNIQUE NOT NULL DEFAULT gen_random_uuid (), 7 | anime_id integer NOT NULL, 8 | episode_number real NOT NULL, 9 | provider_name varchar(64) NOT NULL, 10 | skip_type char(2) NOT NULL, 11 | votes integer NOT NULL DEFAULT 0, 12 | start_time real NOT NULL, 13 | end_time real NOT NULL, 14 | episode_length real NOT NULL, 15 | submit_date timestamp NOT NULL DEFAULT NOW() ::timestamp, 16 | submitter_id uuid NOT NULL, 17 | PRIMARY KEY (skip_id), 18 | CONSTRAINT check_type CHECK (skip_type IN ('op', 'ed')), 19 | CONSTRAINT check_anime_length CHECK (episode_length >= 0), 20 | CONSTRAINT check_start_time CHECK (start_time >= 0), 21 | CONSTRAINT check_anime_id CHECK (anime_id >= 0), 22 | CONSTRAINT check_episode_number CHECK (episode_number >= 0.5), 23 | CONSTRAINT check_end_time CHECK (end_time >= 0 AND end_time > start_time AND end_time <= episode_length) 24 | ); 25 | 26 | -------------------------------------------------------------------------------- /sql_scripts/migration_1.sql: -------------------------------------------------------------------------------- 1 | \c db; 2 | ALTER TABLE skip_times 3 | DROP CONSTRAINT check_type; 4 | 5 | ALTER TABLE skip_times 6 | ADD CONSTRAINT check_type CHECK (skip_type IN ('op', 'ed', 'mixed-op', 'mixed-ed', 'recap')); 7 | 8 | ALTER TABLE skip_times 9 | ALTER COLUMN skip_type TYPE VARCHAR(32); 10 | 11 | ALTER TABLE skip_times 12 | ALTER COLUMN skip_type SET NOT NULL; 13 | 14 | ALTER TABLE skip_times 15 | DROP CONSTRAINT check_episode_number; 16 | 17 | ALTER TABLE skip_times 18 | ADD CONSTRAINT check_episode_number CHECK (episode_number >= 0.0); 19 | 20 | -------------------------------------------------------------------------------- /sql_scripts/restore_backup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd "${BASH_SOURCE%/*}" 4 | 5 | source .env 6 | 7 | docker exec aniskip_db_1 psql -U postgres -d db -c 'TRUNCATE skip_times' 8 | curl https://$GITHUB_PAT@raw.githubusercontent.com/lexesjan/aniskip-database-backup/main/db.dump | docker exec -i aniskip_db_1 psql -v ON_ERROR_STOP=1 -U postgres db -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import { ThrottlerModule } from '@nestjs/throttler'; 4 | import { ThrottlerStorageRedisService } from 'nestjs-throttler-storage-redis'; 5 | import { config } from './config'; 6 | import { RelationRulesModule } from './relation-rules/relation-rules.module'; 7 | import { SkipTimesModule } from './skip-times/skip-times.module'; 8 | import { MorganMiddleware } from './utils'; 9 | 10 | @Module({ 11 | imports: [ 12 | ConfigModule.forRoot({ load: [config] }), 13 | ThrottlerModule.forRootAsync({ 14 | imports: [ConfigModule], 15 | inject: [ConfigService], 16 | useFactory: (configService: ConfigService) => ({ 17 | storage: new ThrottlerStorageRedisService(configService.get('redis')), 18 | }), 19 | }), 20 | SkipTimesModule, 21 | RelationRulesModule, 22 | ], 23 | }) 24 | export class AppModule implements NestModule { 25 | configure(consumer: MiddlewareConsumer): void { 26 | consumer.apply(MorganMiddleware).forRoutes('*'); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/config/config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { Config } from './config.types'; 3 | 4 | export const config = (): Config => ({ 5 | postgres: { 6 | user: 'postgres', 7 | host: process.env.POSTGRES_HOST, 8 | database: 'db', 9 | password: process.env.POSTGRES_PASSWORD, 10 | port: 5432, 11 | max: 20, 12 | idleTimeoutMillis: 30000, 13 | connectionTimeoutMillis: 2000, 14 | }, 15 | redis: { 16 | host: 'redis', 17 | port: 6379, 18 | }, 19 | relations: { 20 | filePath: path.join( 21 | __dirname, 22 | '..', 23 | '..', 24 | 'deps', 25 | 'anime-relations', 26 | 'anime-relations.txt' 27 | ), 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /src/config/config.types.ts: -------------------------------------------------------------------------------- 1 | import { RedisOptions } from 'ioredis'; 2 | import { PoolConfig } from 'pg'; 3 | 4 | export type RelationsConfig = { 5 | filePath: string; 6 | }; 7 | 8 | export type Config = { 9 | postgres: PoolConfig; 10 | redis: RedisOptions; 11 | relations: RelationsConfig; 12 | }; 13 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | export * from './config'; 2 | export * from './config.types'; 3 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipe } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { NestExpressApplication } from '@nestjs/platform-express'; 4 | import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; 5 | import helmet from 'helmet'; 6 | import { AppModule } from './app.module'; 7 | import packageJson from '../package.json'; 8 | 9 | async function bootstrap(): Promise { 10 | const app = await NestFactory.create(AppModule); 11 | 12 | // Add middlewares. 13 | app.enableVersioning(); 14 | app.useGlobalPipes(new ValidationPipe({ transform: true })); 15 | app.set('trust proxy', ['loopback', 'linklocal', 'uniquelocal']); 16 | app.enableCors({ origin: '*' }); 17 | app.use(helmet()); 18 | 19 | // Swagger config. 20 | const server = 21 | process.env.NODE_ENV === 'development' 22 | ? 'http://localhost:5000' 23 | : 'https://api.aniskip.com'; 24 | 25 | const config = new DocumentBuilder() 26 | .setTitle(packageJson.name) 27 | .setDescription(packageJson.description) 28 | .setVersion(packageJson.version) 29 | .addServer(server) 30 | .build(); 31 | const document = SwaggerModule.createDocument(app, config); 32 | SwaggerModule.setup('api-docs', app, document); 33 | 34 | await app.listen(5000); 35 | } 36 | bootstrap(); 37 | -------------------------------------------------------------------------------- /src/relation-rules/__tests__/anime-relations.test.txt: -------------------------------------------------------------------------------- 1 | # Test comment 2 | 3 | ::meta 4 | 5 | # Do not change this line. 6 | - version: 1.3.0 7 | 8 | # Update this date when you add, remove or modify a rule. 9 | - last_modified: 2021-07-09 10 | 11 | ::rules 12 | 13 | # 11eyes -> ~: Momoiro Genmutan 14 | - 6682|4662|6682:13 -> 7739|5102|7739:1 15 | 16 | # Kyoukai no Kanata -> ~: Shinonome 17 | - 18153|7714|18153:0 -> 23385|8379|20779:1 18 | 19 | # Ajin -> ~ 2nd Season 20 | - 31580|11368|21341:14-26 -> 33253|12115|21799:1-13! 21 | 22 | # 3-gatsu no Lion -> ~ 2nd season 23 | - 31646|11380|21366:23-44 -> 35180|13401|98478:1-22 24 | 25 | # Kiratto Pri☆chan -> ~ Season 2 26 | - 37178|41141|101097:52-102 -> 38804|42114|108625:1-51 27 | # Kiratto Pri☆chan -> ~ Season 3 28 | - 37178|?|101097:103-? -> 40880|?|113990:1-? 29 | 30 | # Satsuriku no Tenshi -> ~ ONA 31 | - ?|13715|99629:13-16 -> ?|41963|104243:1-4 32 | 33 | # Ao Oni The Animation (Movie) -> ~ 34 | - 33820|12478|21898:0 -> ~|~|~:1 -------------------------------------------------------------------------------- /src/relation-rules/__tests__/relation-rules.controller.v1.integration.test.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus, INestApplication, ValidationPipe } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { Test } from '@nestjs/testing'; 4 | import { ThrottlerModule } from '@nestjs/throttler'; 5 | import request from 'supertest'; 6 | import { Config, config } from '../../config'; 7 | import { RelationRulesControllerV1 } from '../relation-rules.controller.v1'; 8 | import { RelationRulesService } from '../relation-rules.service'; 9 | 10 | describe('RelationRulesControllerV1', () => { 11 | let app: INestApplication; 12 | 13 | beforeAll(async () => { 14 | const partialConfig = (): Partial => ({ 15 | relations: config().relations, 16 | }); 17 | 18 | const module = await Test.createTestingModule({ 19 | imports: [ 20 | ConfigModule.forRoot({ load: [partialConfig] }), 21 | ThrottlerModule.forRoot(), 22 | ], 23 | controllers: [RelationRulesControllerV1], 24 | providers: [RelationRulesService], 25 | }).compile(); 26 | 27 | app = module.createNestApplication(); 28 | app.useGlobalPipes(new ValidationPipe({ transform: true })); 29 | await app.init(); 30 | }); 31 | 32 | describe('GET /rules/{anime_id}', () => { 33 | it('should respond with a rule', (done) => { 34 | request(app.getHttpServer()) 35 | .get('/rules/40028') 36 | .expect({ 37 | found: true, 38 | rules: [ 39 | { 40 | from: { start: 60, end: 75 }, 41 | to: { malId: 40028, start: 1, end: 16 }, 42 | }, 43 | { 44 | from: { start: 17, end: 75 }, 45 | to: { malId: 48583, start: 1, end: 59 }, 46 | }, 47 | { from: { start: 76 }, to: { malId: 48583, start: 1 } }, 48 | ], 49 | }) 50 | .expect(HttpStatus.OK, done); 51 | }); 52 | }); 53 | 54 | afterAll(async () => { 55 | await app.close(); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/relation-rules/__tests__/relation-rules.controller.v1.unit.test.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; 3 | import { RelationRulesService } from '../relation-rules.service'; 4 | import { RelationRulesControllerV1 } from '../relation-rules.controller.v1'; 5 | import { GetRelationRulesRequestParamsV1 } from '../models'; 6 | 7 | describe('RelationRulesControllerV1', () => { 8 | let relationRulesController: RelationRulesControllerV1; 9 | let relationRulesService: RelationRulesService; 10 | 11 | beforeEach(async () => { 12 | const mockRelationRulesServiceProvider = { 13 | provide: RelationRulesService, 14 | useValue: { getRule: jest.fn() }, 15 | }; 16 | 17 | const mockThrottlerGuardProvider = { 18 | provide: ThrottlerGuard, 19 | useValue: { canActivate: jest.fn(() => true) }, 20 | }; 21 | 22 | const module: TestingModule = await Test.createTestingModule({ 23 | imports: [ThrottlerModule.forRoot()], 24 | controllers: [RelationRulesControllerV1], 25 | providers: [mockThrottlerGuardProvider, mockRelationRulesServiceProvider], 26 | }).compile(); 27 | 28 | relationRulesController = module.get( 29 | RelationRulesControllerV1 30 | ); 31 | relationRulesService = 32 | module.get(RelationRulesService); 33 | }); 34 | 35 | it('should be defined', () => { 36 | expect(relationRulesController).toBeDefined(); 37 | }); 38 | 39 | describe('getRules', () => { 40 | it('should return rules', () => { 41 | const testRules = [ 42 | { 43 | from: { 44 | start: 13, 45 | end: 13, 46 | }, 47 | to: { 48 | malId: 7739, 49 | start: 1, 50 | end: 1, 51 | }, 52 | }, 53 | ]; 54 | 55 | jest 56 | .spyOn(relationRulesService, 'getRule') 57 | .mockImplementation(() => testRules); 58 | 59 | const params = new GetRelationRulesRequestParamsV1(); 60 | params.anime_id = 1; 61 | 62 | const response = relationRulesController.getRules(params); 63 | 64 | expect(response.found).toBeTruthy(); 65 | expect(response.rules).toEqual(testRules); 66 | }); 67 | 68 | it('should return no rules', () => { 69 | jest.spyOn(relationRulesService, 'getRule').mockImplementation(() => []); 70 | 71 | const params = new GetRelationRulesRequestParamsV1(); 72 | params.anime_id = 1; 73 | 74 | const response = relationRulesController.getRules(params); 75 | 76 | expect(response.found).toBeFalsy(); 77 | expect(response.rules).toEqual([]); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/relation-rules/__tests__/relation-rules.controller.v2.integration.test.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus, INestApplication, ValidationPipe } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { Test } from '@nestjs/testing'; 4 | import { ThrottlerModule } from '@nestjs/throttler'; 5 | import request from 'supertest'; 6 | import { Config, config } from '../../config'; 7 | import { RelationRulesControllerV2 } from '../relation-rules.controller.v2'; 8 | import { RelationRulesService } from '../relation-rules.service'; 9 | 10 | describe('RelationRulesControllerV2', () => { 11 | let app: INestApplication; 12 | 13 | beforeAll(async () => { 14 | const partialConfig = (): Partial => ({ 15 | relations: config().relations, 16 | }); 17 | 18 | const module = await Test.createTestingModule({ 19 | imports: [ 20 | ConfigModule.forRoot({ load: [partialConfig] }), 21 | ThrottlerModule.forRoot(), 22 | ], 23 | controllers: [RelationRulesControllerV2], 24 | providers: [RelationRulesService], 25 | }).compile(); 26 | 27 | app = module.createNestApplication(); 28 | app.useGlobalPipes(new ValidationPipe({ transform: true })); 29 | await app.init(); 30 | }); 31 | 32 | describe('GET /relation-rules/{animeId}', () => { 33 | it('should respond with a rule', (done) => { 34 | request(app.getHttpServer()) 35 | .get('/relation-rules/40028') 36 | .expect({ 37 | statusCode: HttpStatus.OK, 38 | message: "Successfully found rules for animeId '40028'", 39 | found: true, 40 | rules: [ 41 | { 42 | from: { start: 60, end: 75 }, 43 | to: { malId: 40028, start: 1, end: 16 }, 44 | }, 45 | { 46 | from: { start: 17, end: 75 }, 47 | to: { malId: 48583, start: 1, end: 59 }, 48 | }, 49 | { from: { start: 76 }, to: { malId: 48583, start: 1 } }, 50 | ], 51 | }) 52 | .expect(HttpStatus.OK, done); 53 | }); 54 | }); 55 | 56 | afterAll(async () => { 57 | await app.close(); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/relation-rules/__tests__/relation-rules.controller.v2.unit.test.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; 3 | import { HttpException, HttpStatus } from '@nestjs/common'; 4 | import { RelationRulesService } from '../relation-rules.service'; 5 | import { RelationRulesControllerV2 } from '../relation-rules.controller.v2'; 6 | import { GetRelationRulesRequestParamsV2 } from '../models'; 7 | 8 | describe('RelationRulesControllerV2', () => { 9 | let relationRulesController: RelationRulesControllerV2; 10 | let relationRulesService: RelationRulesService; 11 | 12 | beforeEach(async () => { 13 | const mockRelationRulesServiceProvider = { 14 | provide: RelationRulesService, 15 | useValue: { getRule: jest.fn() }, 16 | }; 17 | 18 | const mockThrottlerGuardProvider = { 19 | provide: ThrottlerGuard, 20 | useValue: { canActivate: jest.fn(() => true) }, 21 | }; 22 | 23 | const module: TestingModule = await Test.createTestingModule({ 24 | imports: [ThrottlerModule.forRoot()], 25 | controllers: [RelationRulesControllerV2], 26 | providers: [mockThrottlerGuardProvider, mockRelationRulesServiceProvider], 27 | }).compile(); 28 | 29 | relationRulesController = module.get( 30 | RelationRulesControllerV2 31 | ); 32 | relationRulesService = 33 | module.get(RelationRulesService); 34 | }); 35 | 36 | it('should be defined', () => { 37 | expect(relationRulesController).toBeDefined(); 38 | }); 39 | 40 | describe('getRules', () => { 41 | it('should return rules', () => { 42 | const testRules = [ 43 | { 44 | from: { 45 | start: 13, 46 | end: 13, 47 | }, 48 | to: { 49 | malId: 7739, 50 | start: 1, 51 | end: 1, 52 | }, 53 | }, 54 | ]; 55 | 56 | jest 57 | .spyOn(relationRulesService, 'getRule') 58 | .mockImplementation(() => testRules); 59 | 60 | const params = new GetRelationRulesRequestParamsV2(); 61 | params.animeId = 1; 62 | 63 | const response = relationRulesController.getRules(params); 64 | 65 | expect(response.found).toBeTruthy(); 66 | expect(response.message).toBeDefined(); 67 | expect(response.rules).toEqual(testRules); 68 | expect(response.statusCode).toBe(HttpStatus.OK); 69 | }); 70 | 71 | it('should return no rules', () => { 72 | jest.spyOn(relationRulesService, 'getRule').mockImplementation(() => []); 73 | 74 | const params = new GetRelationRulesRequestParamsV2(); 75 | params.animeId = 1; 76 | 77 | expect(() => relationRulesController.getRules(params)).toThrow( 78 | HttpException 79 | ); 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /src/relation-rules/__tests__/relation-rules.service.unit.test.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import path from 'path'; 4 | import { RelationsConfig } from '../../config'; 5 | import { RelationRulesService } from '../relation-rules.service'; 6 | 7 | describe('RelationRulesService', () => { 8 | let relationRulesService: RelationRulesService; 9 | 10 | beforeEach(async () => { 11 | const mockConfigService = { 12 | provide: ConfigService, 13 | useValue: { 14 | get: jest.fn( 15 | (): RelationsConfig => ({ 16 | filePath: path.join(__dirname, 'anime-relations.test.txt'), 17 | }) 18 | ), 19 | }, 20 | }; 21 | 22 | const module: TestingModule = await Test.createTestingModule({ 23 | providers: [mockConfigService, RelationRulesService], 24 | }).compile(); 25 | 26 | relationRulesService = 27 | module.get(RelationRulesService); 28 | }); 29 | 30 | it('should be defined', () => { 31 | expect(relationRulesService).toBeDefined(); 32 | }); 33 | 34 | it('should read no rules if file path is not defined', async () => { 35 | const mockConfigService = { 36 | provide: ConfigService, 37 | useValue: { 38 | get: jest.fn(), 39 | }, 40 | }; 41 | 42 | const module: TestingModule = await Test.createTestingModule({ 43 | providers: [mockConfigService, RelationRulesService], 44 | }).compile(); 45 | 46 | relationRulesService = 47 | module.get(RelationRulesService); 48 | 49 | expect(relationRulesService.version).toBe(''); 50 | }); 51 | 52 | describe('getRule', () => { 53 | it('should return no rules', () => { 54 | const rules = relationRulesService.getRule(1); 55 | 56 | expect(rules).toEqual([]); 57 | }); 58 | 59 | it('should return a single episode rule', () => { 60 | const rules = relationRulesService.getRule(6682); 61 | 62 | expect(rules).toEqual([ 63 | { 64 | from: { 65 | start: 13, 66 | end: 13, 67 | }, 68 | to: { 69 | malId: 7739, 70 | start: 1, 71 | end: 1, 72 | }, 73 | }, 74 | ]); 75 | }); 76 | 77 | it('should return a single episode rule with episode 0', () => { 78 | const rules = relationRulesService.getRule(18153); 79 | 80 | expect(rules).toEqual([ 81 | { 82 | from: { start: 0, end: 0 }, 83 | to: { malId: 23385, start: 1, end: 1 }, 84 | }, 85 | ]); 86 | }); 87 | 88 | it('should return a range rule', () => { 89 | const rules = relationRulesService.getRule(31646); 90 | 91 | expect(rules).toEqual([ 92 | { 93 | from: { 94 | start: 23, 95 | end: 44, 96 | }, 97 | to: { 98 | malId: 35180, 99 | start: 1, 100 | end: 22, 101 | }, 102 | }, 103 | ]); 104 | }); 105 | 106 | it('should return a self redirecting rule', () => { 107 | const rules = relationRulesService.getRule(31580); 108 | 109 | expect(rules).toEqual([ 110 | { 111 | from: { 112 | start: 14, 113 | end: 26, 114 | }, 115 | to: { 116 | malId: 33253, 117 | start: 1, 118 | end: 13, 119 | }, 120 | }, 121 | ]); 122 | }); 123 | 124 | it('should return a rule with an unknown ending range', () => { 125 | const rules = relationRulesService.getRule(37178); 126 | 127 | expect(rules).toEqual([ 128 | { 129 | from: { 130 | start: 52, 131 | end: 102, 132 | }, 133 | to: { 134 | malId: 38804, 135 | start: 1, 136 | end: 51, 137 | }, 138 | }, 139 | { 140 | from: { 141 | start: 103, 142 | }, 143 | to: { 144 | malId: 40880, 145 | start: 1, 146 | }, 147 | }, 148 | ]); 149 | }); 150 | 151 | it('should return a rule with from mal id is equal to to mal id', () => { 152 | const rules = relationRulesService.getRule(33820); 153 | 154 | expect(rules).toEqual([ 155 | { 156 | from: { 157 | start: 0, 158 | end: 0, 159 | }, 160 | to: { 161 | malId: 33820, 162 | start: 1, 163 | end: 1, 164 | }, 165 | }, 166 | ]); 167 | }); 168 | }); 169 | }); 170 | -------------------------------------------------------------------------------- /src/relation-rules/index.ts: -------------------------------------------------------------------------------- 1 | export * from './relation-rules.controller.v1'; 2 | export * from './relation-rules.controller.v2'; 3 | export * from './relation-rules.service'; 4 | -------------------------------------------------------------------------------- /src/relation-rules/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './v1'; 2 | export * from './v2'; 3 | -------------------------------------------------------------------------------- /src/relation-rules/models/v1/get-relation-rules/get-relation-rules.request-params.v1.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Type } from 'class-transformer'; 3 | import { IsInt, Min } from 'class-validator'; 4 | 5 | export class GetRelationRulesRequestParamsV1 { 6 | @IsInt() 7 | @Min(1) 8 | @Type(() => Number) 9 | @ApiProperty({ 10 | type: 'integer', 11 | format: 'int64', 12 | minimum: 1, 13 | description: 'MAL id of the anime to get rules for', 14 | }) 15 | anime_id!: number; 16 | } 17 | -------------------------------------------------------------------------------- /src/relation-rules/models/v1/get-relation-rules/get-relation-rules.response.v1.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Rule } from 'src/relation-rules/relation-rules.types'; 3 | 4 | export class GetRelationRulesResponseV1 { 5 | @ApiProperty() 6 | found!: boolean; 7 | 8 | @ApiProperty({ 9 | type: 'array', 10 | items: { 11 | type: 'object', 12 | properties: { 13 | from: { 14 | type: 'object', 15 | required: ['start'], 16 | properties: { 17 | start: { 18 | type: 'integer', 19 | format: 'int64', 20 | minimum: 1, 21 | }, 22 | end: { 23 | type: 'integer', 24 | format: 'int64', 25 | minimum: 1, 26 | }, 27 | }, 28 | }, 29 | to: { 30 | type: 'object', 31 | required: ['start', 'malId'], 32 | properties: { 33 | malId: { 34 | type: 'integer', 35 | format: 'int64', 36 | minimum: 1, 37 | }, 38 | start: { 39 | type: 'integer', 40 | format: 'int64', 41 | minimum: 1, 42 | }, 43 | end: { 44 | type: 'integer', 45 | format: 'int64', 46 | minimum: 1, 47 | }, 48 | }, 49 | }, 50 | }, 51 | }, 52 | }) 53 | rules!: Rule[]; 54 | } 55 | -------------------------------------------------------------------------------- /src/relation-rules/models/v1/get-relation-rules/index.ts: -------------------------------------------------------------------------------- 1 | export * from './get-relation-rules.request-params.v1'; 2 | export * from './get-relation-rules.response.v1'; 3 | -------------------------------------------------------------------------------- /src/relation-rules/models/v1/index.ts: -------------------------------------------------------------------------------- 1 | export * from './get-relation-rules'; 2 | -------------------------------------------------------------------------------- /src/relation-rules/models/v2/get-relation-rules/get-relation-rules.request-params.v2.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Type } from 'class-transformer'; 3 | import { IsInt, Min } from 'class-validator'; 4 | 5 | export class GetRelationRulesRequestParamsV2 { 6 | @IsInt() 7 | @Min(1) 8 | @Type(() => Number) 9 | @ApiProperty({ 10 | type: 'integer', 11 | format: 'int64', 12 | minimum: 1, 13 | description: 'MAL id of the anime to get rules for', 14 | }) 15 | animeId!: number; 16 | } 17 | -------------------------------------------------------------------------------- /src/relation-rules/models/v2/get-relation-rules/get-relation-rules.response.v2.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Rule } from 'src/relation-rules/relation-rules.types'; 3 | 4 | export class GetRelationRulesResponseV2 { 5 | @ApiProperty() 6 | statusCode!: number; 7 | 8 | @ApiProperty() 9 | message!: string; 10 | 11 | @ApiProperty() 12 | found!: boolean; 13 | 14 | @ApiProperty({ 15 | type: 'array', 16 | items: { 17 | type: 'object', 18 | properties: { 19 | from: { 20 | type: 'object', 21 | required: ['start'], 22 | properties: { 23 | start: { 24 | type: 'integer', 25 | format: 'int64', 26 | minimum: 1, 27 | }, 28 | end: { 29 | type: 'integer', 30 | format: 'int64', 31 | minimum: 1, 32 | }, 33 | }, 34 | }, 35 | to: { 36 | type: 'object', 37 | required: ['start', 'malId'], 38 | properties: { 39 | malId: { 40 | type: 'integer', 41 | format: 'int64', 42 | minimum: 1, 43 | }, 44 | start: { 45 | type: 'integer', 46 | format: 'int64', 47 | minimum: 1, 48 | }, 49 | end: { 50 | type: 'integer', 51 | format: 'int64', 52 | minimum: 1, 53 | }, 54 | }, 55 | }, 56 | }, 57 | }, 58 | }) 59 | rules!: Rule[]; 60 | } 61 | -------------------------------------------------------------------------------- /src/relation-rules/models/v2/get-relation-rules/index.ts: -------------------------------------------------------------------------------- 1 | export * from './get-relation-rules.request-params.v2'; 2 | export * from './get-relation-rules.response.v2'; 3 | -------------------------------------------------------------------------------- /src/relation-rules/models/v2/index.ts: -------------------------------------------------------------------------------- 1 | export * from './get-relation-rules'; 2 | -------------------------------------------------------------------------------- /src/relation-rules/relation-rules.controller.v1.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Param, UseGuards } from '@nestjs/common'; 2 | import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; 3 | import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; 4 | import { 5 | GetRelationRulesRequestParamsV1, 6 | GetRelationRulesResponseV1, 7 | } from './models'; 8 | import { RelationRulesService } from './relation-rules.service'; 9 | 10 | @Controller({ 11 | path: 'rules', 12 | version: '1', 13 | }) 14 | @ApiTags('relation-rules') 15 | export class RelationRulesControllerV1 { 16 | constructor(private relationRulesService: RelationRulesService) {} 17 | 18 | @UseGuards(ThrottlerGuard) 19 | // Maximum 120 times in 1 minute. 20 | @Throttle(120, 60) 21 | @Get('/:anime_id') 22 | @ApiOperation({ 23 | description: 'Retrieves anime episode number redirection rules', 24 | }) 25 | @ApiOkResponse({ 26 | type: GetRelationRulesResponseV1, 27 | description: 'Rules object', 28 | }) 29 | getRules( 30 | @Param() params: GetRelationRulesRequestParamsV1 31 | ): GetRelationRulesResponseV1 { 32 | const episodeRules = this.relationRulesService.getRule(params.anime_id); 33 | 34 | const response = new GetRelationRulesResponseV1(); 35 | const found = episodeRules.length !== 0; 36 | response.found = found; 37 | response.rules = episodeRules; 38 | 39 | return response; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/relation-rules/relation-rules.controller.v2.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | HttpException, 5 | HttpStatus, 6 | Param, 7 | UseGuards, 8 | } from '@nestjs/common'; 9 | import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; 10 | import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; 11 | import { 12 | GetRelationRulesRequestParamsV2, 13 | GetRelationRulesResponseV2, 14 | } from './models'; 15 | import { RelationRulesService } from './relation-rules.service'; 16 | 17 | @Controller({ 18 | path: 'relation-rules', 19 | version: '2', 20 | }) 21 | @ApiTags('relation-rules') 22 | export class RelationRulesControllerV2 { 23 | constructor(private relationRulesService: RelationRulesService) {} 24 | 25 | @UseGuards(ThrottlerGuard) 26 | // Maximum 120 times in 1 minute. 27 | @Throttle(120, 60) 28 | @Get('/:animeId') 29 | @ApiOperation({ 30 | description: 'Retrieves anime episode number redirection rules', 31 | }) 32 | @ApiOkResponse({ 33 | type: GetRelationRulesResponseV2, 34 | description: 'Rules object', 35 | }) 36 | getRules( 37 | @Param() params: GetRelationRulesRequestParamsV2 38 | ): GetRelationRulesResponseV2 { 39 | const episodeRules = this.relationRulesService.getRule(params.animeId); 40 | 41 | const response = new GetRelationRulesResponseV2(); 42 | const found = episodeRules.length !== 0; 43 | response.statusCode = found ? HttpStatus.OK : HttpStatus.NOT_FOUND; 44 | response.message = found 45 | ? `Successfully found rules for animeId '${params.animeId}'` 46 | : `No rules found for animeId '${params.animeId}'`; 47 | response.found = found; 48 | response.rules = episodeRules; 49 | 50 | if (!found) { 51 | throw new HttpException(response, response.statusCode); 52 | } 53 | 54 | return response; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/relation-rules/relation-rules.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { RelationRulesControllerV1 } from './relation-rules.controller.v1'; 4 | import { RelationRulesControllerV2 } from './relation-rules.controller.v2'; 5 | import { RelationRulesService } from './relation-rules.service'; 6 | 7 | @Module({ 8 | imports: [ConfigModule], 9 | controllers: [RelationRulesControllerV1, RelationRulesControllerV2], 10 | providers: [RelationRulesService], 11 | }) 12 | export class RelationRulesModule {} 13 | -------------------------------------------------------------------------------- /src/relation-rules/relation-rules.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import fs from 'fs'; 4 | import { RelationsConfig } from '../config'; 5 | import { Rule, SectionType } from './relation-rules.types'; 6 | 7 | @Injectable() 8 | export class RelationRulesService { 9 | private rules: Record; 10 | 11 | version: string; 12 | 13 | lastModified: Date; 14 | 15 | constructor(private configService: ConfigService) { 16 | this.rules = {}; 17 | this.version = ''; 18 | this.lastModified = new Date(); 19 | 20 | this.readRelations(); 21 | } 22 | 23 | /** 24 | * Read and parse anime-relations rules. 25 | */ 26 | private readRelations(): void { 27 | const animeRelationsFilePath = 28 | this.configService.get('relations')?.filePath; 29 | 30 | if (!animeRelationsFilePath) { 31 | return; 32 | } 33 | 34 | const animeRelations = fs.readFileSync(animeRelationsFilePath, 'utf-8'); 35 | 36 | let section: SectionType = 'unknown'; 37 | animeRelations.split('\n').forEach((line) => { 38 | if (line.startsWith('#') || line === '') { 39 | return; 40 | } 41 | 42 | if (line.startsWith('::meta')) { 43 | section = 'meta'; 44 | } else if (line.startsWith('::rules')) { 45 | section = 'rules'; 46 | } 47 | 48 | switch (section) { 49 | case 'meta': { 50 | const matches = line.match(/([a-z_]+): ([0-9.-]+)/); 51 | if (!matches) { 52 | break; 53 | } 54 | 55 | const label = matches[1]; 56 | const value = matches[2]; 57 | switch (label) { 58 | case 'version': 59 | this.version = value; 60 | break; 61 | case 'last_modified': 62 | this.lastModified = new Date(value); 63 | break; 64 | // no default 65 | } 66 | break; 67 | } 68 | case 'rules': 69 | this.parseRule(line.replace('- ', '')); 70 | break; 71 | // no default 72 | } 73 | }); 74 | } 75 | 76 | /** 77 | * Parses a rule and adds it the rules table. 78 | * 79 | * @param ruleString Rule as a string to parse. 80 | */ 81 | private parseRule(ruleString: string): void { 82 | const idsPattern = /(\d+|[?~])\|(\d+|[?~])\|(\d+|[?~])/; 83 | const episodePattern = /(\d+|[?])(?:-(\d+|[?]))?/; 84 | const rulePattern = new RegExp( 85 | `${idsPattern.source}:${episodePattern.source} -> ${idsPattern.source}:${episodePattern.source}(!)?` 86 | ); 87 | 88 | const matches = ruleString.match(rulePattern); 89 | if (!matches) { 90 | return; 91 | } 92 | 93 | const getRange = ( 94 | firstIndex: number, 95 | secondIndex: number 96 | ): { start: number; end?: number } => { 97 | const start = parseInt(matches[firstIndex], 10); 98 | let end: number | null; 99 | 100 | if (matches[secondIndex]) { 101 | if (matches[secondIndex] === '?') { 102 | // Unknown range end (airing series). 103 | end = null; 104 | } else { 105 | end = parseInt(matches[secondIndex], 10); 106 | } 107 | } else { 108 | // Singular episode. 109 | end = start; 110 | } 111 | 112 | return { start, ...(end !== null && { end }) }; 113 | }; 114 | 115 | const fromMalId = parseInt(matches[1], 10); 116 | if (!fromMalId) { 117 | return; 118 | } 119 | 120 | let rules = this.rules[fromMalId] || []; 121 | const toMalId = matches[6] === '~' ? fromMalId : parseInt(matches[6], 10); 122 | const from = getRange(4, 5); 123 | const toRange = getRange(9, 10); 124 | const to = { malId: toMalId, ...toRange }; 125 | const rule = { from, to }; 126 | rules.push(rule); 127 | this.rules[fromMalId] = rules; 128 | 129 | if (matches[11] === '!') { 130 | rules = this.rules[toMalId] || []; 131 | rules.push(rule); 132 | this.rules[toMalId] = rules; 133 | } 134 | } 135 | 136 | /** 137 | * Get the rules for the given MAL id. 138 | * 139 | * @param animeId MAL id of the anime to retrieve the rules of. 140 | */ 141 | getRule(animeId: number): Rule[] { 142 | return this.rules[animeId] || []; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/relation-rules/relation-rules.types.ts: -------------------------------------------------------------------------------- 1 | export type Range = { 2 | start: number; 3 | end?: number; 4 | }; 5 | 6 | export type Rule = { 7 | from: Range; 8 | to: { malId: number } & Range; 9 | }; 10 | 11 | export type SectionType = 'meta' | 'rules' | 'unknown'; 12 | -------------------------------------------------------------------------------- /src/repositories/__tests__/skip-times.repository.unit.test.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { Pool } from 'pg'; 3 | import { DataType, IBackup, IMemoryDb, newDb } from 'pg-mem'; 4 | import { v4 as uuidv4 } from 'uuid'; 5 | import { DatabaseSkipTime, SkipTypeV1 } from '../../skip-times'; 6 | import { SkipTimesRepository } from '../skip-times.repository'; 7 | 8 | describe('SkipTimesService', () => { 9 | let database: IMemoryDb; 10 | let databaseBackup: IBackup; 11 | let skipTimesRepository: SkipTimesRepository; 12 | 13 | beforeAll(async () => { 14 | database = newDb(); 15 | 16 | database.public.registerFunction({ 17 | name: 'gen_random_uuid', 18 | returns: DataType.uuid, 19 | implementation: uuidv4, 20 | impure: true, 21 | }); 22 | 23 | database.public.registerFunction({ 24 | name: 'abs', 25 | args: [DataType.float], 26 | returns: DataType.float, 27 | implementation: Math.abs, 28 | }); 29 | 30 | database.public.none(` 31 | CREATE TABLE skip_times ( 32 | skip_id uuid UNIQUE NOT NULL DEFAULT gen_random_uuid (), 33 | anime_id integer NOT NULL, 34 | episode_number real NOT NULL, 35 | provider_name varchar(64) NOT NULL, 36 | skip_type varchar(32) NOT NULL, 37 | votes integer NOT NULL DEFAULT 0, 38 | start_time real NOT NULL, 39 | end_time real NOT NULL, 40 | episode_length real NOT NULL, 41 | submit_date timestamp NOT NULL DEFAULT NOW() ::timestamp, 42 | submitter_id uuid NOT NULL, 43 | PRIMARY KEY (skip_id), 44 | CONSTRAINT check_type CHECK (skip_type IN ('op', 'ed', 'mixed-op', 'mixed-ed', 'recap')), 45 | CONSTRAINT check_anime_length CHECK (episode_length >= 0), 46 | CONSTRAINT check_start_time CHECK (start_time >= 0), 47 | CONSTRAINT check_anime_id CHECK (anime_id >= 0), 48 | CONSTRAINT check_episode_number CHECK (episode_number >= 0), 49 | CONSTRAINT check_end_time CHECK (end_time >= 0 AND end_time > start_time AND end_time <= episode_length) 50 | ); 51 | `); 52 | 53 | databaseBackup = database.backup(); 54 | }); 55 | 56 | beforeEach(async () => { 57 | const MockPool = database.adapters.createPg().Pool; 58 | 59 | const mockPoolProvider = { 60 | provide: Pool, 61 | useValue: new MockPool(), 62 | }; 63 | 64 | const module: TestingModule = await Test.createTestingModule({ 65 | providers: [mockPoolProvider, SkipTimesRepository], 66 | }).compile(); 67 | 68 | skipTimesRepository = module.get(SkipTimesRepository); 69 | }); 70 | 71 | it('should be defined', () => { 72 | expect(skipTimesRepository).toBeDefined(); 73 | }); 74 | 75 | describe('upvoteSkipTime', () => { 76 | beforeAll(() => { 77 | databaseBackup.restore(); 78 | 79 | database.public.none(` 80 | INSERT INTO skip_times 81 | VALUES ('c9dfd857-0351-4a90-b37e-582a44253910', 1, 1, 'ProviderName', 'op', 0, 208, 298.556, 1435.122, '2021-02-19 02:14:22.872759', 'e93e3787-3071-4d1f-833f-a78755702f6b'); 82 | `); 83 | }); 84 | 85 | it('should upvote a skip time', async () => { 86 | const isSuccessful = await skipTimesRepository.upvoteSkipTime( 87 | 'c9dfd857-0351-4a90-b37e-582a44253910' 88 | ); 89 | 90 | expect(isSuccessful).toBeTruthy(); 91 | }); 92 | 93 | it('should not upvote successfully', async () => { 94 | const isSuccessful = await skipTimesRepository.upvoteSkipTime( 95 | 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' 96 | ); 97 | 98 | expect(isSuccessful).toBeFalsy(); 99 | }); 100 | }); 101 | 102 | describe('downvoteSkipTime', () => { 103 | beforeAll(() => { 104 | databaseBackup.restore(); 105 | 106 | database.public.none(` 107 | INSERT INTO skip_times 108 | VALUES ('c9dfd857-0351-4a90-b37e-582a44253910', 1, 1, 'ProviderName', 'op', 0, 208, 298.556, 1435.122, '2021-02-19 02:14:22.872759', 'e93e3787-3071-4d1f-833f-a78755702f6b'); 109 | `); 110 | }); 111 | 112 | it('should downvote a skip time', async () => { 113 | const isSuccessful = await skipTimesRepository.downvoteSkipTime( 114 | 'c9dfd857-0351-4a90-b37e-582a44253910' 115 | ); 116 | 117 | expect(isSuccessful).toBeTruthy(); 118 | }); 119 | 120 | it('should not downvote successfully', async () => { 121 | const isSuccessful = await skipTimesRepository.downvoteSkipTime( 122 | 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' 123 | ); 124 | 125 | expect(isSuccessful).toBeFalsy(); 126 | }); 127 | }); 128 | 129 | describe('createSkipTime', () => { 130 | const testSkipTime: Omit = { 131 | anime_id: 3, 132 | end_time: 128.1, 133 | episode_length: 1440.05, 134 | episode_number: 2, 135 | provider_name: 'ProviderName', 136 | skip_type: 'op' as SkipTypeV1, 137 | start_time: 37.75, 138 | submitter_id: 'efb943b4-6869-4179-b3a6-81c5d97cf98b', 139 | votes: 0, 140 | }; 141 | 142 | beforeEach(() => { 143 | databaseBackup.restore(); 144 | }); 145 | 146 | it('should create a skip time', async () => { 147 | const skipId = await skipTimesRepository.createSkipTime(testSkipTime); 148 | const skipTime = database.public.one(` 149 | SELECT 150 | * 151 | FROM 152 | skip_times 153 | WHERE 154 | skip_id = '${skipId}'; 155 | `) as DatabaseSkipTime; 156 | 157 | expect(skipId).toBeDefined(); 158 | expect(skipTime.anime_id).toBe(testSkipTime.anime_id); 159 | expect(skipTime.episode_number).toBe(testSkipTime.episode_number); 160 | expect(skipTime.provider_name).toBe(testSkipTime.provider_name); 161 | expect(skipTime.skip_type).toBe(testSkipTime.skip_type); 162 | expect(skipTime.votes).toBe(testSkipTime.votes); 163 | expect(skipTime.start_time).toBe(testSkipTime.start_time); 164 | expect(skipTime.end_time).toBe(testSkipTime.end_time); 165 | expect(skipTime.episode_length).toBe(testSkipTime.episode_length); 166 | expect(skipTime.submitter_id).toBe(testSkipTime.submitter_id); 167 | expect(skipTime.skip_id).toBeDefined(); 168 | expect(skipTime.submit_date).toBeDefined(); 169 | }); 170 | 171 | it.skip('should throw episode number constraint violation', async () => { 172 | const invalidSkipTime: Omit = 173 | { 174 | ...testSkipTime, 175 | episode_number: -1, 176 | }; 177 | 178 | await expect( 179 | skipTimesRepository.createSkipTime(invalidSkipTime) 180 | ).rejects.toThrow(); 181 | }); 182 | 183 | it.skip('should throw skip type constraint violation', async () => { 184 | const invalidSkipTime: Omit = 185 | { 186 | ...testSkipTime, 187 | skip_type: 'wrong' as SkipTypeV1, 188 | }; 189 | 190 | await expect( 191 | skipTimesRepository.createSkipTime(invalidSkipTime) 192 | ).rejects.toThrow(); 193 | }); 194 | 195 | it.skip('should throw start time constraint violation', async () => { 196 | const invalidSkipTime: Omit = 197 | { 198 | ...testSkipTime, 199 | start_time: -1, 200 | }; 201 | 202 | await expect( 203 | skipTimesRepository.createSkipTime(invalidSkipTime) 204 | ).rejects.toThrow(); 205 | }); 206 | 207 | it.skip('should throw end time constraint violation', async () => { 208 | const invalidSkipTime: Omit = 209 | { 210 | ...testSkipTime, 211 | episode_length: 0, 212 | }; 213 | 214 | await expect( 215 | skipTimesRepository.createSkipTime(invalidSkipTime) 216 | ).rejects.toThrow(); 217 | }); 218 | 219 | it.skip('should throw episode length constraint violation', async () => { 220 | const invalidSkipTime: Omit = 221 | { 222 | ...testSkipTime, 223 | episode_length: -1, 224 | }; 225 | 226 | await expect( 227 | skipTimesRepository.createSkipTime(invalidSkipTime) 228 | ).rejects.toThrow(); 229 | }); 230 | 231 | it('should throw submitter id constraint violation', async () => { 232 | const invalidSkipTime: Omit = 233 | { 234 | ...testSkipTime, 235 | submitter_id: '1', 236 | }; 237 | 238 | await expect( 239 | skipTimesRepository.createSkipTime(invalidSkipTime) 240 | ).rejects.toThrow(); 241 | }); 242 | }); 243 | 244 | describe('findSkipTimes', () => { 245 | beforeAll(() => { 246 | databaseBackup.restore(); 247 | 248 | database.public.none(` 249 | INSERT INTO skip_times 250 | VALUES ('6d1c118e-0484-4b92-82df-896efdcba26e', 1, 1, 'ProviderName', 'op', 10000, 21.5, 112.25, 1445.17, '2021-02-19 01:48:41.338418', 'e93e3787-3071-4d1f-833f-a78755702f6b'); 251 | `); 252 | 253 | database.public.none(` 254 | INSERT INTO skip_times 255 | VALUES ('23ee993a-fdf5-44eb-b4f9-cb79c7935033', 1, 1, 'ProviderName', 'ed', 10000, 1349.5, 1440.485, 1445.1238, '2021-02-19 01:48:41.338418', 'e93e3787-3071-4d1f-833f-a78755702f6b'); 256 | `); 257 | }); 258 | 259 | it('should return an opening skip time', async () => { 260 | const skipTime = await skipTimesRepository.findSkipTimes(1, 1, 'op'); 261 | 262 | expect(skipTime).toEqual([ 263 | { 264 | interval: { 265 | startTime: 21.5, 266 | endTime: 112.25, 267 | }, 268 | skipType: 'op', 269 | skipId: '6d1c118e-0484-4b92-82df-896efdcba26e', 270 | episodeLength: 1445.17, 271 | }, 272 | ]); 273 | }); 274 | 275 | it('should return an ending skip time', async () => { 276 | const skipTime = await skipTimesRepository.findSkipTimes(1, 1, 'ed'); 277 | 278 | expect(skipTime).toEqual([ 279 | { 280 | interval: { 281 | startTime: 1349.5, 282 | endTime: 1440.485, 283 | }, 284 | skipType: 'ed', 285 | skipId: '23ee993a-fdf5-44eb-b4f9-cb79c7935033', 286 | episodeLength: 1445.1238, 287 | }, 288 | ]); 289 | }); 290 | 291 | it('should return no skip time', async () => { 292 | const skipTime = await skipTimesRepository.findSkipTimes(2, 1, 'ed'); 293 | 294 | expect(skipTime).toEqual([]); 295 | }); 296 | }); 297 | 298 | describe('getAverageOfLastTenSkipTimesVotes', () => { 299 | beforeEach(() => { 300 | databaseBackup.restore(); 301 | }); 302 | 303 | it('should return the average of two skip times', async () => { 304 | database.public.none(` 305 | INSERT INTO skip_times 306 | VALUES ('c9dfd857-0351-4a90-b37e-582a44253912', 1, 1, 'ProviderName', 'op', 3, 208, 298.556, 1435.122, '2021-02-19 02:14:22.872759', 'e93e3787-3071-4d1f-833f-a78755702f6b'); 307 | `); 308 | 309 | database.public.none(` 310 | INSERT INTO skip_times 311 | VALUES ('c9dfd857-0351-4a90-b37e-582a44253911', 1, 1, 'ProviderName', 'op', 4, 208, 298.556, 1435.122, '2021-02-19 02:14:22.872759', 'e93e3787-3071-4d1f-833f-a78755702f6b'); 312 | `); 313 | 314 | const average = 315 | await skipTimesRepository.getAverageOfLastTenSkipTimesVotes( 316 | 'e93e3787-3071-4d1f-833f-a78755702f6b' 317 | ); 318 | 319 | expect(average).toBe(3.5); 320 | }); 321 | 322 | it('should return zero from a user with zero submitted skip times', async () => { 323 | const average = 324 | await skipTimesRepository.getAverageOfLastTenSkipTimesVotes( 325 | 'e93e3787-3071-4d1f-833f-a78755702f6b' 326 | ); 327 | 328 | expect(average).toBe(0); 329 | }); 330 | }); 331 | }); 332 | -------------------------------------------------------------------------------- /src/repositories/index.ts: -------------------------------------------------------------------------------- 1 | export * from './skip-times.repository'; 2 | -------------------------------------------------------------------------------- /src/repositories/repositories.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import { Pool } from 'pg'; 4 | import { SkipTimesRepository } from './skip-times.repository'; 5 | 6 | @Module({ 7 | imports: [ConfigModule], 8 | providers: [ 9 | { 10 | inject: [ConfigService], 11 | provide: Pool, 12 | useFactory: (configService: ConfigService): Pool => 13 | new Pool(configService.get('postgres')), 14 | }, 15 | SkipTimesRepository, 16 | ], 17 | exports: [SkipTimesRepository], 18 | }) 19 | export class RepositoriesModule {} 20 | -------------------------------------------------------------------------------- /src/repositories/skip-times.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Pool } from 'pg'; 3 | import { 4 | CreateSkipTimesQueryResponse, 5 | DatabaseSkipTime, 6 | GetAverageOfLastTenSkipTimesVotesQueryResponse, 7 | SkipTimeV2, 8 | SkipTypeV2, 9 | } from '../skip-times/skip-times.types'; 10 | 11 | @Injectable() 12 | export class SkipTimesRepository { 13 | constructor(private database: Pool) {} 14 | 15 | /** 16 | * Upvotes a skip time and returns if it was successful. 17 | * 18 | * @param skipId Skip ID of the skip time to upvote. 19 | */ 20 | async upvoteSkipTime(skipId: string): Promise { 21 | const { rowCount } = await this.database.query( 22 | ` 23 | UPDATE 24 | skip_times 25 | SET 26 | votes = votes + 1 27 | WHERE 28 | skip_id = $1::uuid 29 | `, 30 | [skipId] 31 | ); 32 | 33 | return rowCount > 0; 34 | } 35 | 36 | /** 37 | * Downvotes a skip time and returns if it was successful. 38 | * 39 | * @param skipId Skip ID of the skip time to downvote. 40 | */ 41 | async downvoteSkipTime(skipId: string): Promise { 42 | const { rowCount } = await this.database.query( 43 | ` 44 | UPDATE 45 | skip_times 46 | SET 47 | votes = votes - 1 48 | WHERE 49 | skip_id = $1::uuid 50 | `, 51 | [skipId] 52 | ); 53 | 54 | return rowCount > 0; 55 | } 56 | 57 | /** 58 | * Creates a new skip time entry. 59 | * 60 | * @param skipTime Skip time to create. 61 | */ 62 | async createSkipTime( 63 | skipTime: Omit 64 | ): Promise { 65 | const { rows } = await this.database.query( 66 | ` 67 | INSERT INTO skip_times 68 | VALUES (DEFAULT, $1, $2, $3, $4, $5, $6, $7, $8, DEFAULT, $9) 69 | RETURNING 70 | skip_id 71 | `, 72 | [ 73 | skipTime.anime_id, 74 | skipTime.episode_number, 75 | skipTime.provider_name, 76 | skipTime.skip_type, 77 | skipTime.votes, 78 | skipTime.start_time, 79 | skipTime.end_time, 80 | skipTime.episode_length, 81 | skipTime.submitter_id, 82 | ] 83 | ); 84 | 85 | return rows[0].skip_id; 86 | } 87 | 88 | /** 89 | * Finds the first 10 skip times with matching input parameters. 90 | * 91 | * @param animeId Anime id to filter with. 92 | * @param episodeNumber Episode number to filter with. 93 | * @param skipType Skip type to filter with. 94 | */ 95 | async findSkipTimes( 96 | animeId: number, 97 | episodeNumber: number, 98 | skipType: SkipTypeV2, 99 | episodeLength?: number 100 | ): Promise { 101 | const { rows } = await this.database.query( 102 | ` 103 | SELECT 104 | skip_id, 105 | start_time, 106 | skip_type, 107 | end_time, 108 | episode_length 109 | FROM 110 | skip_times 111 | WHERE 112 | anime_id = $1::integer 113 | AND episode_number = $2::real 114 | AND skip_type = $3::varchar 115 | AND votes > -2 116 | AND ($4::real = 0 OR ABS(episode_length - $4::real) <= 20) 117 | ORDER BY 118 | votes DESC 119 | LIMIT 10 120 | `, 121 | [animeId, episodeNumber, skipType, episodeLength ?? 0] 122 | ); 123 | 124 | const skipTimes: SkipTimeV2[] = rows.map((row) => ({ 125 | interval: { 126 | startTime: row.start_time, 127 | endTime: row.end_time, 128 | }, 129 | skipType: row.skip_type, 130 | skipId: row.skip_id, 131 | episodeLength: row.episode_length, 132 | })); 133 | 134 | return skipTimes; 135 | } 136 | 137 | /** 138 | * Returns the average of the votes of last ten skip times submitted. 139 | * 140 | * @param submitterId ID of the submitter to check. 141 | */ 142 | async getAverageOfLastTenSkipTimesVotes( 143 | submitterId: string 144 | ): Promise { 145 | const { rows } = 146 | await this.database.query( 147 | ` 148 | SELECT 149 | avg(t.votes) 150 | FROM ( 151 | SELECT 152 | votes 153 | FROM 154 | skip_times 155 | WHERE 156 | submitter_id = $1::uuid 157 | LIMIT 10) AS t 158 | `, 159 | [submitterId] 160 | ); 161 | 162 | return rows[0].avg ?? 0; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/skip-times/__tests__/skip-times.controller.v1.integration.test.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus, INestApplication, ValidationPipe } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import { ThrottlerModule } from '@nestjs/throttler'; 4 | import { Pool } from 'pg'; 5 | import { DataType, IBackup, IMemoryDb, newDb } from 'pg-mem'; 6 | import { v4 as uuidv4 } from 'uuid'; 7 | import request from 'supertest'; 8 | import { SkipTimesControllerV1 } from '../skip-times.controller.v1'; 9 | import { SkipTimesServiceV1 } from '../skip-times.service.v1'; 10 | import { SkipTimesRepository } from '../../repositories/skip-times.repository'; 11 | import { VoteService } from '../../vote'; 12 | 13 | describe('SkipTimesControllerV1', () => { 14 | let app: INestApplication; 15 | let database: IMemoryDb; 16 | let databaseBackup: IBackup; 17 | 18 | beforeAll(async () => { 19 | database = newDb(); 20 | 21 | database.public.registerFunction({ 22 | name: 'gen_random_uuid', 23 | returns: DataType.uuid, 24 | implementation: uuidv4, 25 | impure: true, 26 | }); 27 | 28 | database.public.registerFunction({ 29 | name: 'abs', 30 | args: [DataType.float], 31 | returns: DataType.float, 32 | implementation: Math.abs, 33 | }); 34 | 35 | database.public.none(` 36 | CREATE TABLE skip_times ( 37 | skip_id uuid UNIQUE NOT NULL DEFAULT gen_random_uuid (), 38 | anime_id integer NOT NULL, 39 | episode_number real NOT NULL, 40 | provider_name varchar(64) NOT NULL, 41 | skip_type varchar(32) NOT NULL, 42 | votes integer NOT NULL DEFAULT 0, 43 | start_time real NOT NULL, 44 | end_time real NOT NULL, 45 | episode_length real NOT NULL, 46 | submit_date timestamp NOT NULL DEFAULT NOW() ::timestamp, 47 | submitter_id uuid NOT NULL, 48 | PRIMARY KEY (skip_id), 49 | CONSTRAINT check_type CHECK (skip_type IN ('op', 'ed', 'mixed-op', 'mixed-ed', 'recap')), 50 | CONSTRAINT check_anime_length CHECK (episode_length >= 0), 51 | CONSTRAINT check_start_time CHECK (start_time >= 0), 52 | CONSTRAINT check_anime_id CHECK (anime_id >= 0), 53 | CONSTRAINT check_episode_number CHECK (episode_number >= 0), 54 | CONSTRAINT check_end_time CHECK (end_time >= 0 AND end_time > start_time AND end_time <= episode_length) 55 | ); 56 | `); 57 | 58 | databaseBackup = database.backup(); 59 | 60 | const MockPool = database.adapters.createPg().Pool; 61 | 62 | const mockPoolProvider = { 63 | provide: Pool, 64 | useValue: new MockPool(), 65 | }; 66 | 67 | const module = await Test.createTestingModule({ 68 | imports: [ThrottlerModule.forRoot()], 69 | controllers: [SkipTimesControllerV1], 70 | providers: [ 71 | mockPoolProvider, 72 | SkipTimesRepository, 73 | VoteService, 74 | SkipTimesServiceV1, 75 | ], 76 | }).compile(); 77 | 78 | app = module.createNestApplication(); 79 | app.useGlobalPipes(new ValidationPipe({ transform: true })); 80 | await app.init(); 81 | }); 82 | 83 | describe('POST /skip-times/vote/{skipId}', () => { 84 | beforeAll(() => { 85 | databaseBackup.restore(); 86 | 87 | database.public.none(` 88 | INSERT INTO skip_times 89 | VALUES ('c9dfd857-0351-4a90-b37e-582a44253910', 1, 1, 'ProviderName', 'op', 0, 208, 298.556, 1435.122, '2021-02-19 02:14:22.872759', 'e93e3787-3071-4d1f-833f-a78755702f6b'); 90 | `); 91 | }); 92 | 93 | it('should upvote a skip time', (done) => { 94 | request(app.getHttpServer()) 95 | .post('/skip-times/vote/c9dfd857-0351-4a90-b37e-582a44253910') 96 | .send({ vote_type: 'upvote' }) 97 | .expect({ message: 'success' }) 98 | .expect(HttpStatus.CREATED, done); 99 | }); 100 | }); 101 | 102 | describe('GET /skip-times/{animeId}/{episodeNumber}', () => { 103 | beforeAll(() => { 104 | databaseBackup.restore(); 105 | 106 | database.public.none(` 107 | INSERT INTO skip_times 108 | VALUES ('6d1c118e-0484-4b92-82df-896efdcba26e', 1, 1, 'ProviderName', 'op', 10000, 21.5, 112.25, 1445.17, '2021-02-19 01:48:41.338418', 'e93e3787-3071-4d1f-833f-a78755702f6b'); 109 | `); 110 | 111 | database.public.none(` 112 | INSERT INTO skip_times 113 | VALUES ('23ee993a-fdf5-44eb-b4f9-cb79c7935033', 1, 1, 'ProviderName', 'ed', 10000, 1349.5, 1440.485, 1445.1238, '2021-02-19 01:48:41.338418', 'e93e3787-3071-4d1f-833f-a78755702f6b'); 114 | `); 115 | }); 116 | 117 | it('should respond with an episode number bad request', (done) => { 118 | request(app.getHttpServer()) 119 | .get('/skip-times/1/0') 120 | .query({ types: 'op' }) 121 | .expect({ 122 | statusCode: HttpStatus.BAD_REQUEST, 123 | message: ['episode_number must not be less than 0.5'], 124 | error: 'Bad Request', 125 | }) 126 | .expect(HttpStatus.BAD_REQUEST, done); 127 | }); 128 | 129 | it('should respond with an opening and ending', (done) => { 130 | request(app.getHttpServer()) 131 | .get('/skip-times/1/1') 132 | .query({ types: ['op', 'ed'] }) 133 | .expect({ 134 | found: true, 135 | results: [ 136 | { 137 | interval: { 138 | start_time: 21.5, 139 | end_time: 112.25, 140 | }, 141 | skip_type: 'op', 142 | skip_id: '6d1c118e-0484-4b92-82df-896efdcba26e', 143 | episode_length: 1445.17, 144 | }, 145 | { 146 | interval: { 147 | start_time: 1349.5, 148 | end_time: 1440.485, 149 | }, 150 | skip_type: 'ed', 151 | skip_id: '23ee993a-fdf5-44eb-b4f9-cb79c7935033', 152 | episode_length: 1445.1238, 153 | }, 154 | ], 155 | }) 156 | .expect(HttpStatus.OK, done); 157 | }); 158 | }); 159 | 160 | describe('POST /v1/skip-times/{animeId}/{episodeNumber}', () => { 161 | beforeAll(() => { 162 | databaseBackup.restore(); 163 | }); 164 | 165 | it('should create a skip time', (done) => { 166 | request(app.getHttpServer()) 167 | .post('/skip-times/3/2') 168 | .send({ 169 | skip_type: 'op', 170 | provider_name: 'ProviderName', 171 | start_time: 37.75, 172 | end_time: 128.1, 173 | episode_length: 1440.05, 174 | submitter_id: 'efb943b4-6869-4179-b3a6-81c5d97cf98b', 175 | }) 176 | .expect((res) => { 177 | const { body } = res; 178 | expect(body.message).toBe('success'); 179 | expect(body.skip_id).toBeDefined(); 180 | }) 181 | .expect(HttpStatus.CREATED, done); 182 | }); 183 | }); 184 | 185 | afterAll(async () => { 186 | await app.close(); 187 | }); 188 | }); 189 | -------------------------------------------------------------------------------- /src/skip-times/__tests__/skip-times.controller.v1.unit.test.ts: -------------------------------------------------------------------------------- 1 | import { HttpException } from '@nestjs/common'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; 4 | import { SkipTimeV1, VoteType } from '../skip-times.types'; 5 | import { 6 | PostSkipTimesV1ThrottlerGuard, 7 | PostVoteSkipTimesV1ThrottlerGuard, 8 | } from '../../utils'; 9 | import { 10 | GetSkipTimesRequestParamsV1, 11 | GetSkipTimesRequestQueryV1, 12 | PostCreateSkipTimeRequestBodyV1, 13 | PostCreateSkipTimeRequestParamsV1, 14 | PostVoteRequestBodyV1, 15 | PostVoteRequestParamsV1, 16 | } from '../models'; 17 | import { SkipTimesControllerV1 } from '../skip-times.controller.v1'; 18 | import { SkipTimesServiceV1 } from '../skip-times.service.v1'; 19 | 20 | describe('SkipTimesControllerV1', () => { 21 | let skipTimesController: SkipTimesControllerV1; 22 | let skipTimesService: SkipTimesServiceV1; 23 | 24 | beforeEach(async () => { 25 | const mockSkipTimesServiceProvider = { 26 | provide: SkipTimesServiceV1, 27 | useValue: { 28 | voteSkipTime: jest.fn(), 29 | createSkipTime: jest.fn(), 30 | findSkipTimes: jest.fn(), 31 | }, 32 | }; 33 | 34 | const mockPostVoteSkipTimesThrottlerGuardProvider = { 35 | provide: PostVoteSkipTimesV1ThrottlerGuard, 36 | useValue: { canActivate: jest.fn(() => true) }, 37 | }; 38 | 39 | const mockThrottlerGuardProvider = { 40 | provide: ThrottlerGuard, 41 | useValue: { canActivate: jest.fn(() => true) }, 42 | }; 43 | 44 | const mockPostSkipTimesThrottlerGuardProvider = { 45 | provide: PostSkipTimesV1ThrottlerGuard, 46 | useValue: { canActivate: jest.fn(() => true) }, 47 | }; 48 | 49 | const module: TestingModule = await Test.createTestingModule({ 50 | imports: [ThrottlerModule.forRoot()], 51 | controllers: [SkipTimesControllerV1], 52 | providers: [ 53 | mockSkipTimesServiceProvider, 54 | mockPostVoteSkipTimesThrottlerGuardProvider, 55 | mockThrottlerGuardProvider, 56 | mockPostSkipTimesThrottlerGuardProvider, 57 | ], 58 | }).compile(); 59 | 60 | skipTimesController = module.get( 61 | SkipTimesControllerV1 62 | ); 63 | skipTimesService = module.get(SkipTimesServiceV1); 64 | }); 65 | 66 | it('should be defined', () => { 67 | expect(skipTimesController).toBeDefined(); 68 | }); 69 | 70 | describe('voteSkipTime', () => { 71 | it.each(['upvote', 'downvote'])( 72 | 'should $voteType a skip time', 73 | async (voteType) => { 74 | jest 75 | .spyOn(skipTimesService, 'voteSkipTime') 76 | .mockImplementation(() => Promise.resolve(true)); 77 | 78 | const params = new PostVoteRequestParamsV1(); 79 | params.skip_id = 'c9dfd857-0351-4a90-b37e-582a44253910'; 80 | 81 | const body = new PostVoteRequestBodyV1(); 82 | body.vote_type = voteType; 83 | 84 | const response = await skipTimesController.voteSkipTime(params, body); 85 | 86 | expect(response.message).toBeDefined(); 87 | } 88 | ); 89 | 90 | it.each(['upvote', 'downvote'])( 91 | 'should throw if $voteType fails', 92 | async (voteType) => { 93 | jest 94 | .spyOn(skipTimesService, 'voteSkipTime') 95 | .mockImplementation(() => Promise.resolve(false)); 96 | 97 | const params = new PostVoteRequestParamsV1(); 98 | params.skip_id = 'c9dfd857-0351-4a90-b37e-582a44253910'; 99 | 100 | const body = new PostVoteRequestBodyV1(); 101 | body.vote_type = voteType; 102 | 103 | await expect( 104 | skipTimesController.voteSkipTime(params, body) 105 | ).rejects.toThrow(HttpException); 106 | } 107 | ); 108 | }); 109 | 110 | describe('getSkipTimes', () => { 111 | it('should return skip times', async () => { 112 | const testSkipTimesV1: SkipTimeV1[] = [ 113 | { 114 | interval: { 115 | start_time: 21.5, 116 | end_time: 112.25, 117 | }, 118 | skip_type: 'op', 119 | skip_id: '6d1c118e-0484-4b92-82df-896efdcba26e', 120 | episode_length: 1445.17, 121 | }, 122 | { 123 | interval: { 124 | start_time: 1349.5, 125 | end_time: 1440.485, 126 | }, 127 | skip_type: 'ed', 128 | skip_id: '23ee993a-fdf5-44eb-b4f9-cb79c7935033', 129 | episode_length: 1445.1238, 130 | }, 131 | ]; 132 | 133 | jest 134 | .spyOn(skipTimesService, 'findSkipTimes') 135 | .mockImplementation(() => Promise.resolve(testSkipTimesV1)); 136 | 137 | const params = new GetSkipTimesRequestParamsV1(); 138 | params.anime_id = 40028; 139 | params.episode_number = 1; 140 | 141 | const query = new GetSkipTimesRequestQueryV1(); 142 | query.types = ['op', 'ed']; 143 | 144 | const response = await skipTimesController.getSkipTimes(params, query); 145 | 146 | expect(response.found).toBeTruthy(); 147 | expect(response.results).toEqual(testSkipTimesV1); 148 | }); 149 | 150 | it('should return success if no skip times found', async () => { 151 | jest 152 | .spyOn(skipTimesService, 'findSkipTimes') 153 | .mockImplementation(() => Promise.resolve([])); 154 | 155 | const params = new GetSkipTimesRequestParamsV1(); 156 | params.anime_id = 40028; 157 | params.episode_number = 1; 158 | 159 | const query = new GetSkipTimesRequestQueryV1(); 160 | query.types = ['op', 'ed']; 161 | 162 | const response = await skipTimesController.getSkipTimes(params, query); 163 | 164 | expect(response.found).toBeFalsy(); 165 | expect(response.results).toEqual([]); 166 | }); 167 | }); 168 | 169 | describe('createSkipTime', () => { 170 | it('should create a skip time', async () => { 171 | const testSkipId = ''; 172 | 173 | jest 174 | .spyOn(skipTimesService, 'createSkipTime') 175 | .mockImplementation(() => Promise.resolve(testSkipId)); 176 | 177 | const params = new PostCreateSkipTimeRequestParamsV1(); 178 | params.anime_id = 40028; 179 | params.episode_number = 1; 180 | 181 | const body = new PostCreateSkipTimeRequestBodyV1(); 182 | body.start_time = 21.5; 183 | body.end_time = 112.25; 184 | body.skip_type = 'op'; 185 | body.episode_length = 1445.17; 186 | body.submitter_id = 'efb943b4-6869-4179-b3a6-81c5d97cf98b'; 187 | 188 | const response = await skipTimesController.createSkipTime(params, body); 189 | 190 | expect(response.message).toBeDefined(); 191 | expect(response.skip_id).toBe(testSkipId); 192 | }); 193 | 194 | it('should throw when skip times are invalid', async () => { 195 | jest.spyOn(skipTimesService, 'createSkipTime').mockImplementation(() => { 196 | throw new Error(); 197 | }); 198 | 199 | const params = new PostCreateSkipTimeRequestParamsV1(); 200 | params.anime_id = 40028; 201 | params.episode_number = 1; 202 | 203 | const body = new PostCreateSkipTimeRequestBodyV1(); 204 | body.start_time = 21.5; 205 | body.end_time = 112.25; 206 | body.skip_type = 'op'; 207 | body.episode_length = -1; 208 | body.submitter_id = 'efb943b4-6869-4179-b3a6-81c5d97cf98b'; 209 | 210 | await expect( 211 | skipTimesController.createSkipTime(params, body) 212 | ).rejects.toThrow(HttpException); 213 | }); 214 | }); 215 | }); 216 | -------------------------------------------------------------------------------- /src/skip-times/__tests__/skip-times.controller.v2.integration.test.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus, INestApplication, ValidationPipe } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import { ThrottlerModule } from '@nestjs/throttler'; 4 | import { Pool } from 'pg'; 5 | import { DataType, IBackup, IMemoryDb, newDb } from 'pg-mem'; 6 | import { v4 as uuidv4 } from 'uuid'; 7 | import request from 'supertest'; 8 | import { SkipTimesControllerV2 } from '../skip-times.controller.v2'; 9 | import { SkipTimesRepository } from '../../repositories/skip-times.repository'; 10 | import { VoteService } from '../../vote'; 11 | import { SkipTimesServiceV2 } from '../skip-times.service.v2'; 12 | 13 | describe('SkipTimesControllerV2', () => { 14 | let app: INestApplication; 15 | let database: IMemoryDb; 16 | let databaseBackup: IBackup; 17 | 18 | beforeAll(async () => { 19 | database = newDb(); 20 | 21 | database.public.registerFunction({ 22 | name: 'gen_random_uuid', 23 | returns: DataType.uuid, 24 | implementation: uuidv4, 25 | impure: true, 26 | }); 27 | 28 | database.public.registerFunction({ 29 | name: 'abs', 30 | args: [DataType.float], 31 | returns: DataType.float, 32 | implementation: Math.abs, 33 | }); 34 | 35 | database.public.none(` 36 | CREATE TABLE skip_times ( 37 | skip_id uuid UNIQUE NOT NULL DEFAULT gen_random_uuid (), 38 | anime_id integer NOT NULL, 39 | episode_number real NOT NULL, 40 | provider_name varchar(64) NOT NULL, 41 | skip_type varchar(32) NOT NULL, 42 | votes integer NOT NULL DEFAULT 0, 43 | start_time real NOT NULL, 44 | end_time real NOT NULL, 45 | episode_length real NOT NULL, 46 | submit_date timestamp NOT NULL DEFAULT NOW() ::timestamp, 47 | submitter_id uuid NOT NULL, 48 | PRIMARY KEY (skip_id), 49 | CONSTRAINT check_type CHECK (skip_type IN ('op', 'ed', 'mixed-op', 'mixed-ed', 'recap')), 50 | CONSTRAINT check_anime_length CHECK (episode_length >= 0), 51 | CONSTRAINT check_start_time CHECK (start_time >= 0), 52 | CONSTRAINT check_anime_id CHECK (anime_id >= 0), 53 | CONSTRAINT check_episode_number CHECK (episode_number >= 0), 54 | CONSTRAINT check_end_time CHECK (end_time >= 0 AND end_time > start_time AND end_time <= episode_length) 55 | ); 56 | `); 57 | 58 | databaseBackup = database.backup(); 59 | 60 | const MockPool = database.adapters.createPg().Pool; 61 | 62 | const mockPoolProvider = { 63 | provide: Pool, 64 | useValue: new MockPool(), 65 | }; 66 | 67 | const module = await Test.createTestingModule({ 68 | imports: [ThrottlerModule.forRoot()], 69 | controllers: [SkipTimesControllerV2], 70 | providers: [ 71 | mockPoolProvider, 72 | SkipTimesRepository, 73 | VoteService, 74 | SkipTimesServiceV2, 75 | ], 76 | }).compile(); 77 | 78 | app = module.createNestApplication(); 79 | app.useGlobalPipes(new ValidationPipe({ transform: true })); 80 | await app.init(); 81 | }); 82 | 83 | describe('POST /skip-times/vote/{skipId}', () => { 84 | beforeAll(() => { 85 | databaseBackup.restore(); 86 | 87 | database.public.none(` 88 | INSERT INTO skip_times 89 | VALUES ('c9dfd857-0351-4a90-b37e-582a44253910', 1, 1, 'ProviderName', 'op', 0, 208, 298.556, 1435.122, '2021-02-19 02:14:22.872759', 'e93e3787-3071-4d1f-833f-a78755702f6b'); 90 | `); 91 | }); 92 | 93 | it('should upvote a skip time', (done) => { 94 | request(app.getHttpServer()) 95 | .post('/skip-times/vote/c9dfd857-0351-4a90-b37e-582a44253910') 96 | .send({ voteType: 'upvote' }) 97 | .expect({ 98 | statusCode: HttpStatus.CREATED, 99 | message: 'Successfully upvote the skip time', 100 | }) 101 | .expect(HttpStatus.CREATED, done); 102 | }); 103 | }); 104 | 105 | describe('GET /skip-times/{animeId}/{episodeNumber}', () => { 106 | beforeAll(() => { 107 | databaseBackup.restore(); 108 | 109 | database.public.none(` 110 | INSERT INTO skip_times 111 | VALUES ('6d1c118e-0484-4b92-82df-896efdcba26e', 1, 1, 'ProviderName', 'op', 10000, 21.5, 112.25, 1445.17, '2021-02-19 01:48:41.338418', 'e93e3787-3071-4d1f-833f-a78755702f6b'); 112 | `); 113 | 114 | database.public.none(` 115 | INSERT INTO skip_times 116 | VALUES ('23ee993a-fdf5-44eb-b4f9-cb79c7935033', 1, 1, 'ProviderName', 'ed', 10000, 1349.5, 1440.485, 1445.1238, '2021-02-19 01:48:41.338418', 'e93e3787-3071-4d1f-833f-a78755702f6b'); 117 | `); 118 | 119 | database.public.none(` 120 | INSERT INTO skip_times 121 | VALUES ('6b7753de-3636-4cc6-8254-a370c87637e9', 1, 1, 'ProviderName', 'mixed-op', 0, 133.481, 221.531, 1445.1238, '2021-12-17 00:51:27.211617', '73496141-2098-456c-b10e-b86524c80762'); 122 | `); 123 | 124 | database.public.none(` 125 | INSERT INTO skip_times 126 | VALUES ('ae1399ee-998a-4aeb-9789-4f5d62868aff', 1, 1, 'ProviderName', 'mixed-ed', 0, 1327.88, 1419.13, 1445.1238, '2021-12-17 01:22:30.821168', '73496141-2098-456c-b10e-b86524c80762'); 127 | `); 128 | 129 | database.public.none(` 130 | INSERT INTO skip_times 131 | VALUES ('14abd949-ad03-4e2a-9a98-4a7dee59f8ab', 1, 1, 'ProviderName', 'recap', 0, 130.857, 251.607, 1445.1238, '2021-12-17 01:24:57.285574', '73496141-2098-456c-b10e-b86524c80762'); 132 | `); 133 | 134 | database.public.none(` 135 | INSERT INTO skip_times 136 | VALUES ('14abd949-ad03-4e2a-9a98-3a7dee59f8ab', 1, 1, 'ProviderName', 'op', 0, 130.857, 251.607, 1000, '2021-12-17 01:24:57.285574', '73496141-2098-456c-b10e-b86524c80762'); 137 | `); 138 | }); 139 | 140 | it('should respond with an episode number bad request', (done) => { 141 | request(app.getHttpServer()) 142 | .get('/skip-times/1/-1') 143 | .query({ types: 'op', episodeLength: 0 }) 144 | .expect({ 145 | statusCode: HttpStatus.BAD_REQUEST, 146 | message: ['episodeNumber must not be less than 0'], 147 | error: 'Bad Request', 148 | }) 149 | .expect(HttpStatus.BAD_REQUEST, done); 150 | }); 151 | 152 | it('should respond with an opening, ending, mixed opening, mixed ending and recap', (done) => { 153 | request(app.getHttpServer()) 154 | .get('/skip-times/1/1') 155 | .query({ 156 | types: ['op', 'ed', 'mixed-op', 'mixed-ed', 'recap'], 157 | episodeLength: 1445.17, 158 | }) 159 | .expect({ 160 | found: true, 161 | results: [ 162 | { 163 | interval: { 164 | startTime: 21.5, 165 | endTime: 112.25, 166 | }, 167 | skipType: 'op', 168 | skipId: '6d1c118e-0484-4b92-82df-896efdcba26e', 169 | episodeLength: 1445.17, 170 | }, 171 | { 172 | interval: { 173 | startTime: 1349.5, 174 | endTime: 1440.485, 175 | }, 176 | skipType: 'ed', 177 | skipId: '23ee993a-fdf5-44eb-b4f9-cb79c7935033', 178 | episodeLength: 1445.1238, 179 | }, 180 | { 181 | interval: { 182 | startTime: 133.481, 183 | endTime: 221.531, 184 | }, 185 | skipType: 'mixed-op', 186 | skipId: '6b7753de-3636-4cc6-8254-a370c87637e9', 187 | episodeLength: 1445.1238, 188 | }, 189 | { 190 | interval: { 191 | startTime: 1327.88, 192 | endTime: 1419.13, 193 | }, 194 | skipType: 'mixed-ed', 195 | skipId: 'ae1399ee-998a-4aeb-9789-4f5d62868aff', 196 | episodeLength: 1445.1238, 197 | }, 198 | { 199 | interval: { 200 | startTime: 130.857, 201 | endTime: 251.607, 202 | }, 203 | skipType: 'recap', 204 | skipId: '14abd949-ad03-4e2a-9a98-4a7dee59f8ab', 205 | episodeLength: 1445.1238, 206 | }, 207 | ], 208 | message: 'Successfully found skip times', 209 | statusCode: HttpStatus.OK, 210 | }) 211 | .expect(HttpStatus.OK, done); 212 | }); 213 | 214 | it('should respond with an opening filtered by episode length', (done) => { 215 | request(app.getHttpServer()) 216 | .get('/skip-times/1/1') 217 | .query({ 218 | types: ['op', 'ed', 'mixed-op', 'mixed-ed', 'recap'], 219 | episodeLength: 1004, 220 | }) 221 | .expect({ 222 | found: true, 223 | results: [ 224 | { 225 | interval: { 226 | startTime: 130.857, 227 | endTime: 251.607, 228 | }, 229 | skipType: 'op', 230 | skipId: '14abd949-ad03-4e2a-9a98-3a7dee59f8ab', 231 | episodeLength: 1000, 232 | }, 233 | ], 234 | message: 'Successfully found skip times', 235 | statusCode: HttpStatus.OK, 236 | }) 237 | .expect(HttpStatus.OK, done); 238 | }); 239 | 240 | it('should respond with no skip times due to the episode length filter', (done) => { 241 | request(app.getHttpServer()) 242 | .get('/skip-times/1/1') 243 | .query({ 244 | types: ['op', 'ed', 'mixed-op', 'mixed-ed', 'recap'], 245 | episodeLength: 100, 246 | }) 247 | .expect({ 248 | found: false, 249 | results: [], 250 | message: 'No skip times found', 251 | statusCode: HttpStatus.NOT_FOUND, 252 | }) 253 | .expect(HttpStatus.NOT_FOUND, done); 254 | }); 255 | 256 | it('should respond with mixed-op and mixed-ed with no episode length filter', (done) => { 257 | request(app.getHttpServer()) 258 | .get('/skip-times/1/1') 259 | .query({ 260 | types: ['mixed-op', 'mixed-ed'], 261 | episodeLength: 0, 262 | }) 263 | .expect({ 264 | found: true, 265 | results: [ 266 | { 267 | interval: { 268 | startTime: 133.481, 269 | endTime: 221.531, 270 | }, 271 | skipType: 'mixed-op', 272 | skipId: '6b7753de-3636-4cc6-8254-a370c87637e9', 273 | episodeLength: 1445.1238, 274 | }, 275 | { 276 | interval: { 277 | startTime: 1327.88, 278 | endTime: 1419.13, 279 | }, 280 | skipType: 'mixed-ed', 281 | skipId: 'ae1399ee-998a-4aeb-9789-4f5d62868aff', 282 | episodeLength: 1445.1238, 283 | }, 284 | ], 285 | message: 'Successfully found skip times', 286 | statusCode: HttpStatus.OK, 287 | }) 288 | .expect(HttpStatus.OK, done); 289 | }); 290 | }); 291 | 292 | describe('POST /v1/skip-times/{animeId}/{episodeNumber}', () => { 293 | beforeAll(() => { 294 | databaseBackup.restore(); 295 | }); 296 | 297 | it.each([ 298 | { 299 | skipType: 'op', 300 | providerName: 'ProviderName', 301 | startTime: 37.75, 302 | endTime: 128.1, 303 | episodeLength: 1440.05, 304 | submitterId: 'efb943b4-6869-4179-b3a6-81c5d97cf98b', 305 | }, 306 | { 307 | skipType: 'ed', 308 | providerName: 'ProviderName', 309 | startTime: 37.75, 310 | endTime: 128.1, 311 | episodeLength: 1440.05, 312 | submitterId: 'efb943b4-6869-4179-b3a6-81c5d97cf98b', 313 | }, 314 | { 315 | skipType: 'mixed-op', 316 | providerName: 'ProviderName', 317 | startTime: 37.75, 318 | endTime: 128.1, 319 | episodeLength: 1440.05, 320 | submitterId: 'efb943b4-6869-4179-b3a6-81c5d97cf98b', 321 | }, 322 | { 323 | skipType: 'mixed-ed', 324 | providerName: 'ProviderName', 325 | startTime: 37.75, 326 | endTime: 128.1, 327 | episodeLength: 1440.05, 328 | submitterId: 'efb943b4-6869-4179-b3a6-81c5d97cf98b', 329 | }, 330 | { 331 | skipType: 'recap', 332 | providerName: 'ProviderName', 333 | startTime: 37.75, 334 | endTime: 128.1, 335 | episodeLength: 1440.05, 336 | submitterId: 'efb943b4-6869-4179-b3a6-81c5d97cf98b', 337 | }, 338 | ])('should create a skip time', (data, done) => { 339 | request(app.getHttpServer()) 340 | .post('/skip-times/3/2') 341 | .send(data) 342 | .expect((res) => { 343 | const { body } = res; 344 | expect(body.message).toBe('Successfully created a skip time'); 345 | expect(body.skipId).toBeDefined(); 346 | expect(body.statusCode).toBe(HttpStatus.CREATED); 347 | }) 348 | .expect(HttpStatus.CREATED, done); 349 | }); 350 | 351 | it.each([ 352 | { 353 | data: { 354 | skipType: 'wrong', 355 | providerName: 'ProviderName', 356 | startTime: 37.75, 357 | endTime: 128.1, 358 | episodeLength: 1440.05, 359 | submitterId: 'efb943b4-6869-4179-b3a6-81c5d97cf98b', 360 | }, 361 | errorMessage: [ 362 | 'skipType must be one of the following values: op, ed, mixed-op, mixed-ed, recap', 363 | ], 364 | }, 365 | { 366 | data: { 367 | skipType: 'op', 368 | providerName: 'ProviderName', 369 | startTime: 37.75, 370 | endTime: 128.1, 371 | episodeLength: -1, 372 | submitterId: 'efb943b4-6869-4179-b3a6-81c5d97cf98b', 373 | }, 374 | errorMessage: ['episodeLength must not be less than 0'], 375 | }, 376 | ])('should not create a skip time', ({ data, errorMessage }, done) => { 377 | request(app.getHttpServer()) 378 | .post('/skip-times/3/2') 379 | .send(data) 380 | .expect((res) => { 381 | const { body } = res; 382 | expect(body.statusCode).toBe(HttpStatus.BAD_REQUEST); 383 | expect(body.message).toEqual(errorMessage); 384 | expect(body.error).toBe('Bad Request'); 385 | expect(body.skipId).toBeUndefined(); 386 | }) 387 | .expect(HttpStatus.BAD_REQUEST, done); 388 | }); 389 | }); 390 | 391 | afterAll(async () => { 392 | await app.close(); 393 | }); 394 | }); 395 | -------------------------------------------------------------------------------- /src/skip-times/__tests__/skip-times.controller.v2.unit.test.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus } from '@nestjs/common'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; 4 | import { 5 | PostSkipTimesV2ThrottlerGuard, 6 | PostVoteSkipTimesV2ThrottlerGuard, 7 | } from '../../utils'; 8 | import { 9 | GetSkipTimesRequestParamsV2, 10 | GetSkipTimesRequestQueryV2, 11 | PostCreateSkipTimeRequestBodyV2, 12 | PostCreateSkipTimeRequestParamsV2, 13 | PostVoteRequestBodyV2, 14 | PostVoteRequestParamsV2, 15 | } from '../models'; 16 | import { SkipTimesControllerV2 } from '../skip-times.controller.v2'; 17 | import { SkipTimesServiceV2 } from '../skip-times.service.v2'; 18 | import { SkipTimeV2, VoteType } from '../skip-times.types'; 19 | 20 | describe('SkipTimesControllerV2', () => { 21 | let skipTimesController: SkipTimesControllerV2; 22 | let skipTimesService: SkipTimesServiceV2; 23 | 24 | beforeEach(async () => { 25 | const mockSkipTimesServiceProvider = { 26 | provide: SkipTimesServiceV2, 27 | useValue: { 28 | voteSkipTime: jest.fn(), 29 | createSkipTime: jest.fn(), 30 | findSkipTimes: jest.fn(), 31 | }, 32 | }; 33 | 34 | const mockPostVoteSkipTimesThrottlerGuardProvider = { 35 | provide: PostVoteSkipTimesV2ThrottlerGuard, 36 | useValue: { canActivate: jest.fn(() => true) }, 37 | }; 38 | 39 | const mockThrottlerGuardProvider = { 40 | provide: ThrottlerGuard, 41 | useValue: { canActivate: jest.fn(() => true) }, 42 | }; 43 | 44 | const mockPostSkipTimesThrottlerGuardProvider = { 45 | provide: PostSkipTimesV2ThrottlerGuard, 46 | useValue: { canActivate: jest.fn(() => true) }, 47 | }; 48 | 49 | const module: TestingModule = await Test.createTestingModule({ 50 | imports: [ThrottlerModule.forRoot()], 51 | controllers: [SkipTimesControllerV2], 52 | providers: [ 53 | mockSkipTimesServiceProvider, 54 | mockPostVoteSkipTimesThrottlerGuardProvider, 55 | mockThrottlerGuardProvider, 56 | mockPostSkipTimesThrottlerGuardProvider, 57 | ], 58 | }).compile(); 59 | 60 | skipTimesController = module.get( 61 | SkipTimesControllerV2 62 | ); 63 | skipTimesService = module.get(SkipTimesServiceV2); 64 | }); 65 | 66 | it('should be defined', () => { 67 | expect(skipTimesController).toBeDefined(); 68 | }); 69 | 70 | describe('voteSkipTime', () => { 71 | it.each(['upvote', 'downvote'])( 72 | 'should $voteType a skip time', 73 | async (voteType) => { 74 | jest 75 | .spyOn(skipTimesService, 'voteSkipTime') 76 | .mockImplementation(() => Promise.resolve(true)); 77 | 78 | const params = new PostVoteRequestParamsV2(); 79 | params.skipId = 'c9dfd857-0351-4a90-b37e-582a44253910'; 80 | 81 | const body = new PostVoteRequestBodyV2(); 82 | body.voteType = voteType; 83 | 84 | const response = await skipTimesController.voteSkipTime(params, body); 85 | 86 | expect(response.message).toBeDefined(); 87 | expect(response.statusCode).toBe(HttpStatus.CREATED); 88 | } 89 | ); 90 | 91 | it.each(['upvote', 'downvote'])( 92 | 'should throw if $voteType fails', 93 | async (voteType) => { 94 | jest 95 | .spyOn(skipTimesService, 'voteSkipTime') 96 | .mockImplementation(() => Promise.resolve(false)); 97 | 98 | const params = new PostVoteRequestParamsV2(); 99 | params.skipId = 'c9dfd857-0351-4a90-b37e-582a44253910'; 100 | 101 | const body = new PostVoteRequestBodyV2(); 102 | body.voteType = voteType; 103 | 104 | await expect( 105 | skipTimesController.voteSkipTime(params, body) 106 | ).rejects.toThrow(HttpException); 107 | } 108 | ); 109 | }); 110 | 111 | describe('getSkipTimes', () => { 112 | it('should return skip times', async () => { 113 | const testSkipTimes: SkipTimeV2[] = [ 114 | { 115 | interval: { 116 | startTime: 21.5, 117 | endTime: 112.25, 118 | }, 119 | skipType: 'op', 120 | skipId: '6d1c118e-0484-4b92-82df-896efdcba26e', 121 | episodeLength: 1445.17, 122 | }, 123 | { 124 | interval: { 125 | startTime: 1349.5, 126 | endTime: 1440.485, 127 | }, 128 | skipType: 'ed', 129 | skipId: '23ee993a-fdf5-44eb-b4f9-cb79c7935033', 130 | episodeLength: 1445.1238, 131 | }, 132 | { 133 | interval: { 134 | startTime: 133.481, 135 | endTime: 221.531, 136 | }, 137 | skipType: 'mixed-op', 138 | skipId: '6b7753de-3636-4cc6-8254-a370c87637e9', 139 | episodeLength: 1426.9255, 140 | }, 141 | { 142 | interval: { 143 | startTime: 1327.88, 144 | endTime: 1419.13, 145 | }, 146 | skipType: 'mixed-ed', 147 | skipId: 'ae1399ee-998a-4aeb-9789-4f5d62868aff', 148 | episodeLength: 1427.1191, 149 | }, 150 | { 151 | interval: { 152 | startTime: 130.857, 153 | endTime: 251.607, 154 | }, 155 | skipType: 'recap', 156 | skipId: '14abd949-ad03-4e2a-9a98-4a7dee59f8ab', 157 | episodeLength: 1430.721, 158 | }, 159 | ]; 160 | 161 | jest 162 | .spyOn(skipTimesService, 'findSkipTimes') 163 | .mockImplementation(() => Promise.resolve(testSkipTimes)); 164 | 165 | const params = new GetSkipTimesRequestParamsV2(); 166 | params.animeId = 40028; 167 | params.episodeNumber = 1; 168 | 169 | const query = new GetSkipTimesRequestQueryV2(); 170 | query.types = ['op', 'ed', 'mixed-op', 'mixed-ed', 'recap']; 171 | 172 | const response = await skipTimesController.getSkipTimes(params, query); 173 | 174 | expect(response.found).toBeTruthy(); 175 | expect(response.message).toBeDefined(); 176 | expect(response.results).toEqual(testSkipTimes); 177 | expect(response.statusCode).toBe(HttpStatus.OK); 178 | }); 179 | 180 | it('should throw if no skip times found', async () => { 181 | jest 182 | .spyOn(skipTimesService, 'findSkipTimes') 183 | .mockImplementation(() => Promise.resolve([])); 184 | 185 | const params = new GetSkipTimesRequestParamsV2(); 186 | params.animeId = 40028; 187 | params.episodeNumber = 1; 188 | 189 | const query = new GetSkipTimesRequestQueryV2(); 190 | query.types = ['op', 'ed']; 191 | 192 | await expect( 193 | skipTimesController.getSkipTimes(params, query) 194 | ).rejects.toThrow(HttpException); 195 | }); 196 | }); 197 | 198 | describe('createSkipTime', () => { 199 | it('should create a skip time', async () => { 200 | const testSkipId = ''; 201 | 202 | jest 203 | .spyOn(skipTimesService, 'createSkipTime') 204 | .mockImplementation(() => Promise.resolve(testSkipId)); 205 | 206 | const params = new PostCreateSkipTimeRequestParamsV2(); 207 | params.animeId = 40028; 208 | params.episodeNumber = 1; 209 | 210 | const body = new PostCreateSkipTimeRequestBodyV2(); 211 | body.startTime = 21.5; 212 | body.endTime = 112.25; 213 | body.skipType = 'op'; 214 | body.episodeLength = 1445.17; 215 | body.submitterId = 'efb943b4-6869-4179-b3a6-81c5d97cf98b'; 216 | 217 | const response = await skipTimesController.createSkipTime(params, body); 218 | 219 | expect(response.message).toBeDefined(); 220 | expect(response.skipId).toBe(testSkipId); 221 | expect(response.statusCode).toBe(HttpStatus.CREATED); 222 | }); 223 | 224 | it('should throw when skip times are invalid', async () => { 225 | jest.spyOn(skipTimesService, 'createSkipTime').mockImplementation(() => { 226 | throw new Error(); 227 | }); 228 | 229 | const params = new PostCreateSkipTimeRequestParamsV2(); 230 | params.animeId = 40028; 231 | params.episodeNumber = 1; 232 | 233 | const body = new PostCreateSkipTimeRequestBodyV2(); 234 | body.startTime = 21.5; 235 | body.endTime = 112.25; 236 | body.skipType = 'op'; 237 | body.episodeLength = -1; 238 | body.submitterId = 'efb943b4-6869-4179-b3a6-81c5d97cf98b'; 239 | 240 | await expect( 241 | skipTimesController.createSkipTime(params, body) 242 | ).rejects.toThrow(HttpException); 243 | }); 244 | }); 245 | }); 246 | -------------------------------------------------------------------------------- /src/skip-times/__tests__/skip-times.service.v1.unit.test.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { SkipTypeV1 } from '../skip-times.types'; 3 | import { SkipTimesRepository } from '../../repositories'; 4 | import { VoteService } from '../../vote'; 5 | import { SkipTimesServiceV1 } from '../skip-times.service.v1'; 6 | 7 | describe('SkipTimesService', () => { 8 | let skipTimesService: SkipTimesServiceV1; 9 | let skipTimesRepository: SkipTimesRepository; 10 | let voteService: VoteService; 11 | 12 | beforeEach(async () => { 13 | const mockSkipTimesRepositoryProvider = { 14 | provide: SkipTimesRepository, 15 | useValue: { 16 | upvoteSkipTime: jest.fn(), 17 | downvoteSkipTime: jest.fn(), 18 | createSkipTime: jest.fn(), 19 | findSkipTimes: jest.fn(), 20 | getAverageOfLastTenSkipTimesVotes: jest.fn(), 21 | }, 22 | }; 23 | 24 | const mockVoteServiceProvider = { 25 | provide: VoteService, 26 | useValue: { autoVote: jest.fn() }, 27 | }; 28 | 29 | const module: TestingModule = await Test.createTestingModule({ 30 | providers: [ 31 | mockSkipTimesRepositoryProvider, 32 | mockVoteServiceProvider, 33 | SkipTimesServiceV1, 34 | ], 35 | }).compile(); 36 | 37 | skipTimesService = module.get(SkipTimesServiceV1); 38 | skipTimesRepository = module.get(SkipTimesRepository); 39 | voteService = module.get(VoteService); 40 | }); 41 | 42 | it('should be defined', () => { 43 | expect(skipTimesService).toBeDefined(); 44 | }); 45 | 46 | describe('voteSkipTime', () => { 47 | it('should upvote a skip time', async () => { 48 | jest 49 | .spyOn(skipTimesRepository, 'upvoteSkipTime') 50 | .mockReturnValue(Promise.resolve(true)); 51 | 52 | const isSuccessful = await skipTimesService.voteSkipTime( 53 | 'upvote', 54 | 'e93e3787-3071-4d1f-833f-a78755702f6b' 55 | ); 56 | 57 | expect(isSuccessful).toBeTruthy(); 58 | }); 59 | 60 | it('should downvote a skip time', async () => { 61 | jest 62 | .spyOn(skipTimesRepository, 'downvoteSkipTime') 63 | .mockReturnValue(Promise.resolve(true)); 64 | 65 | const isSuccessful = await skipTimesService.voteSkipTime( 66 | 'downvote', 67 | 'e93e3787-3071-4d1f-833f-a78755702f6b' 68 | ); 69 | 70 | expect(isSuccessful).toBeTruthy(); 71 | }); 72 | }); 73 | 74 | describe('createSkipTime', () => { 75 | it('should create a skip time', async () => { 76 | const testSkipId = 'e93e3787-3071-4d1f-833f-a78755702f6b'; 77 | 78 | jest 79 | .spyOn(skipTimesRepository, 'createSkipTime') 80 | .mockReturnValue(Promise.resolve(testSkipId)); 81 | 82 | jest.spyOn(voteService, 'autoVote').mockReturnValue(Promise.resolve(0)); 83 | 84 | const skipTime = { 85 | anime_id: 3, 86 | episode_number: 2, 87 | skip_type: 'op' as SkipTypeV1, 88 | provider_name: 'ProviderName', 89 | start_time: 37.75, 90 | end_time: 128.1, 91 | episode_length: 1440.05, 92 | submitter_id: 'efb943b4-6869-4179-b3a6-81c5d97cf98b', 93 | }; 94 | 95 | const skipId = await skipTimesService.createSkipTime(skipTime); 96 | 97 | expect(skipId).toEqual(testSkipId); 98 | }); 99 | }); 100 | 101 | describe('findSkipTimes', () => { 102 | it('should return only an opening', async () => { 103 | const testSkipTimesCamelCase = [ 104 | { 105 | interval: { 106 | startTime: 208, 107 | endTime: 298.556, 108 | }, 109 | skipType: 'op' as SkipTypeV1, 110 | skipId: 'c9dfd857-0351-4a90-b37e-582a44253910', 111 | episodeLength: 1435.122, 112 | }, 113 | { 114 | interval: { 115 | startTime: 173.75, 116 | endTime: 269.39, 117 | }, 118 | skipType: 'op' as SkipTypeV1, 119 | skipId: 'd468dafe-3e00-4a9c-8da3-e294141526eb', 120 | episodeLength: 1435.122, 121 | }, 122 | ]; 123 | 124 | const testSkipTimesSnakeCase = testSkipTimesCamelCase.map( 125 | (testSkipTime) => ({ 126 | interval: { 127 | start_time: testSkipTime.interval.startTime, 128 | end_time: testSkipTime.interval.endTime, 129 | }, 130 | skip_type: testSkipTime.skipType, 131 | skip_id: testSkipTime.skipId, 132 | episode_length: testSkipTime.episodeLength, 133 | }) 134 | ); 135 | 136 | jest 137 | .spyOn(skipTimesRepository, 'findSkipTimes') 138 | .mockReturnValueOnce(Promise.resolve([testSkipTimesCamelCase[0]])) 139 | .mockReturnValueOnce(Promise.resolve([testSkipTimesCamelCase[1]])); 140 | 141 | const skipTimes = await skipTimesService.findSkipTimes(40028, 1, ['op']); 142 | 143 | expect(skipTimes[0]).toEqual(testSkipTimesSnakeCase[0]); 144 | }); 145 | 146 | it('should return only an ending', async () => { 147 | const testSkipTimesCamelCase = [ 148 | { 149 | interval: { 150 | startTime: 208, 151 | endTime: 298.556, 152 | }, 153 | skipType: 'ed' as SkipTypeV1, 154 | skipId: 'c9dfd857-0351-4a90-b37e-582a44253910', 155 | episodeLength: 1435.122, 156 | }, 157 | { 158 | interval: { 159 | startTime: 173.75, 160 | endTime: 269.39, 161 | }, 162 | skipType: 'op' as SkipTypeV1, 163 | skipId: 'd468dafe-3e00-4a9c-8da3-e294141526eb', 164 | episodeLength: 1435.122, 165 | }, 166 | ]; 167 | 168 | const testSkipTimesSnakeCase = testSkipTimesCamelCase.map( 169 | (testSkipTime) => ({ 170 | interval: { 171 | start_time: testSkipTime.interval.startTime, 172 | end_time: testSkipTime.interval.endTime, 173 | }, 174 | skip_type: testSkipTime.skipType, 175 | skip_id: testSkipTime.skipId, 176 | episode_length: testSkipTime.episodeLength, 177 | }) 178 | ); 179 | 180 | jest 181 | .spyOn(skipTimesRepository, 'findSkipTimes') 182 | .mockReturnValueOnce(Promise.resolve([testSkipTimesCamelCase[0]])) 183 | .mockReturnValueOnce(Promise.resolve([testSkipTimesCamelCase[1]])); 184 | 185 | const skipTimes = await skipTimesService.findSkipTimes(40028, 1, ['ed']); 186 | 187 | expect(skipTimes[0]).toEqual(testSkipTimesSnakeCase[0]); 188 | }); 189 | 190 | it('should return an opening and an ending', async () => { 191 | const testSkipTimesCamelCase = [ 192 | { 193 | interval: { 194 | startTime: 208, 195 | endTime: 298.556, 196 | }, 197 | skipType: 'op' as SkipTypeV1, 198 | skipId: 'c9dfd857-0351-4a90-b37e-582a44253910', 199 | episodeLength: 1435.122, 200 | }, 201 | { 202 | interval: { 203 | startTime: 173.75, 204 | endTime: 269.39, 205 | }, 206 | skipType: 'ed' as SkipTypeV1, 207 | skipId: 'd468dafe-3e00-4a9c-8da3-e294141526eb', 208 | episodeLength: 1435.122, 209 | }, 210 | ]; 211 | 212 | const testSkipTimesSnakeCase = testSkipTimesCamelCase.map( 213 | (testSkipTime) => ({ 214 | interval: { 215 | start_time: testSkipTime.interval.startTime, 216 | end_time: testSkipTime.interval.endTime, 217 | }, 218 | skip_type: testSkipTime.skipType, 219 | skip_id: testSkipTime.skipId, 220 | episode_length: testSkipTime.episodeLength, 221 | }) 222 | ); 223 | 224 | jest 225 | .spyOn(skipTimesRepository, 'findSkipTimes') 226 | .mockReturnValueOnce(Promise.resolve([testSkipTimesCamelCase[0]])) 227 | .mockReturnValueOnce(Promise.resolve([testSkipTimesCamelCase[1]])); 228 | 229 | const skipTimes = await skipTimesService.findSkipTimes(40028, 1, [ 230 | 'op', 231 | 'ed', 232 | ]); 233 | 234 | expect(skipTimes[0]).toEqual(testSkipTimesSnakeCase[0]); 235 | }); 236 | 237 | it('should return nothing', async () => { 238 | jest 239 | .spyOn(skipTimesRepository, 'findSkipTimes') 240 | .mockReturnValue(Promise.resolve([])); 241 | 242 | const skipTimes = await skipTimesService.findSkipTimes(40028, 1, [ 243 | 'op', 244 | 'ed', 245 | ]); 246 | 247 | expect(skipTimes).toEqual([]); 248 | }); 249 | }); 250 | }); 251 | -------------------------------------------------------------------------------- /src/skip-times/__tests__/skip-times.service.v2.unit.test.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { SkipTypeV1 } from '../skip-times.types'; 3 | import { SkipTimesRepository } from '../../repositories'; 4 | import { VoteService } from '../../vote'; 5 | import { SkipTimesServiceV2 } from '../skip-times.service.v2'; 6 | 7 | describe('SkipTimesService', () => { 8 | let skipTimesService: SkipTimesServiceV2; 9 | let skipTimesRepository: SkipTimesRepository; 10 | let voteService: VoteService; 11 | 12 | beforeEach(async () => { 13 | const mockSkipTimesRepositoryProvider = { 14 | provide: SkipTimesRepository, 15 | useValue: { 16 | upvoteSkipTime: jest.fn(), 17 | downvoteSkipTime: jest.fn(), 18 | createSkipTime: jest.fn(), 19 | findSkipTimes: jest.fn(), 20 | getAverageOfLastTenSkipTimesVotes: jest.fn(), 21 | }, 22 | }; 23 | 24 | const mockVoteServiceProvider = { 25 | provide: VoteService, 26 | useValue: { autoVote: jest.fn() }, 27 | }; 28 | 29 | const module: TestingModule = await Test.createTestingModule({ 30 | providers: [ 31 | mockSkipTimesRepositoryProvider, 32 | mockVoteServiceProvider, 33 | SkipTimesServiceV2, 34 | ], 35 | }).compile(); 36 | 37 | skipTimesService = module.get(SkipTimesServiceV2); 38 | skipTimesRepository = module.get(SkipTimesRepository); 39 | voteService = module.get(VoteService); 40 | }); 41 | 42 | it('should be defined', () => { 43 | expect(skipTimesService).toBeDefined(); 44 | }); 45 | 46 | describe('voteSkipTime', () => { 47 | it('should upvote a skip time', async () => { 48 | jest 49 | .spyOn(skipTimesRepository, 'upvoteSkipTime') 50 | .mockReturnValue(Promise.resolve(true)); 51 | 52 | const isSuccessful = await skipTimesService.voteSkipTime( 53 | 'upvote', 54 | 'e93e3787-3071-4d1f-833f-a78755702f6b' 55 | ); 56 | 57 | expect(isSuccessful).toBeTruthy(); 58 | }); 59 | 60 | it('should downvote a skip time', async () => { 61 | jest 62 | .spyOn(skipTimesRepository, 'downvoteSkipTime') 63 | .mockReturnValue(Promise.resolve(true)); 64 | 65 | const isSuccessful = await skipTimesService.voteSkipTime( 66 | 'downvote', 67 | 'e93e3787-3071-4d1f-833f-a78755702f6b' 68 | ); 69 | 70 | expect(isSuccessful).toBeTruthy(); 71 | }); 72 | }); 73 | 74 | describe('createSkipTime', () => { 75 | it('should create a skip time', async () => { 76 | const testSkipId = 'e93e3787-3071-4d1f-833f-a78755702f6b'; 77 | 78 | jest 79 | .spyOn(skipTimesRepository, 'createSkipTime') 80 | .mockReturnValue(Promise.resolve(testSkipId)); 81 | 82 | jest.spyOn(voteService, 'autoVote').mockReturnValue(Promise.resolve(0)); 83 | 84 | const skipTime = { 85 | anime_id: 3, 86 | episode_number: 2, 87 | skip_type: 'op' as SkipTypeV1, 88 | provider_name: 'ProviderName', 89 | start_time: 37.75, 90 | end_time: 128.1, 91 | episode_length: 1440.05, 92 | submitter_id: 'efb943b4-6869-4179-b3a6-81c5d97cf98b', 93 | }; 94 | 95 | const skipId = await skipTimesService.createSkipTime(skipTime); 96 | 97 | expect(skipId).toEqual(testSkipId); 98 | }); 99 | }); 100 | 101 | describe('findSkipTimes', () => { 102 | it('should return only an opening', async () => { 103 | const testSkipTimes = [ 104 | { 105 | interval: { 106 | startTime: 208, 107 | endTime: 298.556, 108 | }, 109 | skipType: 'op' as SkipTypeV1, 110 | skipId: 'c9dfd857-0351-4a90-b37e-582a44253910', 111 | episodeLength: 1435.122, 112 | }, 113 | { 114 | interval: { 115 | startTime: 173.75, 116 | endTime: 269.39, 117 | }, 118 | skipType: 'op' as SkipTypeV1, 119 | skipId: 'd468dafe-3e00-4a9c-8da3-e294141526eb', 120 | episodeLength: 1435.122, 121 | }, 122 | ]; 123 | 124 | jest 125 | .spyOn(skipTimesRepository, 'findSkipTimes') 126 | .mockReturnValueOnce(Promise.resolve([testSkipTimes[0]])) 127 | .mockReturnValueOnce(Promise.resolve([testSkipTimes[1]])); 128 | 129 | const skipTimes = await skipTimesService.findSkipTimes( 130 | 40028, 131 | 1, 132 | ['op'], 133 | 1435.122 134 | ); 135 | 136 | expect(skipTimes[0]).toEqual(testSkipTimes[0]); 137 | }); 138 | 139 | it('should return only an ending', async () => { 140 | const testSkipTimes = [ 141 | { 142 | interval: { 143 | startTime: 208, 144 | endTime: 298.556, 145 | }, 146 | skipType: 'ed' as SkipTypeV1, 147 | skipId: 'c9dfd857-0351-4a90-b37e-582a44253910', 148 | episodeLength: 1435.122, 149 | }, 150 | { 151 | interval: { 152 | startTime: 173.75, 153 | endTime: 269.39, 154 | }, 155 | skipType: 'op' as SkipTypeV1, 156 | skipId: 'd468dafe-3e00-4a9c-8da3-e294141526eb', 157 | episodeLength: 1435.122, 158 | }, 159 | ]; 160 | 161 | jest 162 | .spyOn(skipTimesRepository, 'findSkipTimes') 163 | .mockReturnValueOnce(Promise.resolve([testSkipTimes[0]])) 164 | .mockReturnValueOnce(Promise.resolve([testSkipTimes[1]])); 165 | 166 | const skipTimes = await skipTimesService.findSkipTimes( 167 | 40028, 168 | 1, 169 | ['ed'], 170 | 1435.122 171 | ); 172 | 173 | expect(skipTimes[0]).toEqual(testSkipTimes[0]); 174 | }); 175 | 176 | it('should return an opening and an ending', async () => { 177 | const testSkipTimes = [ 178 | { 179 | interval: { 180 | startTime: 208, 181 | endTime: 298.556, 182 | }, 183 | skipType: 'op' as SkipTypeV1, 184 | skipId: 'c9dfd857-0351-4a90-b37e-582a44253910', 185 | episodeLength: 1435.122, 186 | }, 187 | { 188 | interval: { 189 | startTime: 173.75, 190 | endTime: 269.39, 191 | }, 192 | skipType: 'ed' as SkipTypeV1, 193 | skipId: 'd468dafe-3e00-4a9c-8da3-e294141526eb', 194 | episodeLength: 1435.122, 195 | }, 196 | ]; 197 | 198 | jest 199 | .spyOn(skipTimesRepository, 'findSkipTimes') 200 | .mockReturnValueOnce(Promise.resolve([testSkipTimes[0]])) 201 | .mockReturnValueOnce(Promise.resolve([testSkipTimes[1]])); 202 | 203 | const skipTimes = await skipTimesService.findSkipTimes( 204 | 40028, 205 | 1, 206 | ['op', 'ed'], 207 | 1435.122 208 | ); 209 | 210 | expect(skipTimes[0]).toEqual(testSkipTimes[0]); 211 | }); 212 | 213 | it('should return nothing', async () => { 214 | jest 215 | .spyOn(skipTimesRepository, 'findSkipTimes') 216 | .mockReturnValue(Promise.resolve([])); 217 | 218 | const skipTimes = await skipTimesService.findSkipTimes( 219 | 40028, 220 | 1, 221 | ['op', 'ed'], 222 | 1435.122 223 | ); 224 | 225 | expect(skipTimes).toEqual([]); 226 | }); 227 | }); 228 | }); 229 | -------------------------------------------------------------------------------- /src/skip-times/index.ts: -------------------------------------------------------------------------------- 1 | export * from './skip-times.controller.v1'; 2 | export * from './skip-times.controller.v2'; 3 | export * from './skip-times.service.v1'; 4 | export * from './skip-times.types'; 5 | -------------------------------------------------------------------------------- /src/skip-times/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './v1'; 2 | export * from './v2'; 3 | -------------------------------------------------------------------------------- /src/skip-times/models/v1/get-skip-times/get-skip-times.request-params.v1.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Type } from 'class-transformer'; 3 | import { IsInt, IsNumber, Min } from 'class-validator'; 4 | 5 | export class GetSkipTimesRequestParamsV1 { 6 | @IsInt() 7 | @Min(1) 8 | @Type(() => Number) 9 | @ApiProperty({ 10 | type: 'integer', 11 | format: 'int64', 12 | minimum: 1, 13 | description: 'MAL id of the anime to get', 14 | }) 15 | anime_id!: number; 16 | 17 | @IsNumber() 18 | @Min(0.5) 19 | @Type(() => Number) 20 | @ApiProperty({ 21 | type: Number, 22 | format: 'double', 23 | minimum: 0.5, 24 | description: 'Episode number to get', 25 | }) 26 | episode_number!: number; 27 | } 28 | -------------------------------------------------------------------------------- /src/skip-times/models/v1/get-skip-times/get-skip-times.request-query.v1.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsArray, IsIn } from 'class-validator'; 3 | import { Transform } from 'class-transformer'; 4 | import { SkipTypeV1, SKIP_TYPES_V1 } from '../../../skip-times.types'; 5 | import { IsUnique } from '../../../../utils'; 6 | 7 | export class GetSkipTimesRequestQueryV1 { 8 | @IsUnique() 9 | @IsArray() 10 | @IsIn(SKIP_TYPES_V1, { each: true }) 11 | @Transform(({ value }) => (!Array.isArray(value) ? [value] : value)) 12 | @ApiProperty({ 13 | type: [String], 14 | enum: SKIP_TYPES_V1, 15 | description: 'Type of skip time to get', 16 | }) 17 | types!: SkipTypeV1[]; 18 | } 19 | -------------------------------------------------------------------------------- /src/skip-times/models/v1/get-skip-times/get-skip-times.response.v1.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { SkipTimeV1, SKIP_TYPES_V1 } from '../../../skip-times.types'; 3 | 4 | export class GetSkipTimesResponseV1 { 5 | @ApiProperty() 6 | found!: boolean; 7 | 8 | @ApiProperty({ 9 | type: 'array', 10 | items: { 11 | type: 'object', 12 | properties: { 13 | interval: { 14 | type: 'object', 15 | properties: { 16 | start_time: { 17 | type: 'number', 18 | format: 'double', 19 | minimum: 0, 20 | }, 21 | end_time: { 22 | type: 'number', 23 | format: 'double', 24 | minimum: 0, 25 | }, 26 | }, 27 | }, 28 | skip_type: { 29 | type: 'string', 30 | enum: [...SKIP_TYPES_V1], 31 | }, 32 | skip_id: { 33 | type: 'string', 34 | format: 'uuid', 35 | }, 36 | episode_length: { 37 | type: 'number', 38 | format: 'double', 39 | minimum: 0, 40 | }, 41 | }, 42 | }, 43 | }) 44 | results!: SkipTimeV1[]; 45 | } 46 | -------------------------------------------------------------------------------- /src/skip-times/models/v1/get-skip-times/index.ts: -------------------------------------------------------------------------------- 1 | export * from './get-skip-times.request-params.v1'; 2 | export * from './get-skip-times.request-query.v1'; 3 | export * from './get-skip-times.response.v1'; 4 | -------------------------------------------------------------------------------- /src/skip-times/models/v1/index.ts: -------------------------------------------------------------------------------- 1 | export * from './get-skip-times'; 2 | export * from './post-create-skip-time'; 3 | export * from './post-vote'; 4 | -------------------------------------------------------------------------------- /src/skip-times/models/v1/post-create-skip-time/index.ts: -------------------------------------------------------------------------------- 1 | export * from './post-create-skip-time.request-body.v1'; 2 | export * from './post-create-skip-time.request-params.v1'; 3 | export * from './post-create-skip-time.response.v1'; 4 | -------------------------------------------------------------------------------- /src/skip-times/models/v1/post-create-skip-time/post-create-skip-time.request-body.v1.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Type } from 'class-transformer'; 3 | import { IsIn, IsNumber, IsString, IsUUID, Min } from 'class-validator'; 4 | import { SkipTypeV1, SKIP_TYPES_V1 } from '../../../skip-times.types'; 5 | 6 | export class PostCreateSkipTimeRequestBodyV1 { 7 | @IsIn(SKIP_TYPES_V1) 8 | @ApiProperty({ type: String, enum: SKIP_TYPES_V1 }) 9 | skip_type!: SkipTypeV1; 10 | 11 | @IsString() 12 | @ApiProperty() 13 | provider_name!: string; 14 | 15 | @IsNumber() 16 | @Min(0) 17 | @Type(() => Number) 18 | @ApiProperty({ 19 | type: Number, 20 | format: 'double', 21 | minimum: 0, 22 | }) 23 | start_time!: number; 24 | 25 | @IsNumber() 26 | @Min(0) 27 | @Type(() => Number) 28 | @ApiProperty({ 29 | type: Number, 30 | format: 'double', 31 | minimum: 0, 32 | }) 33 | end_time!: number; 34 | 35 | @IsNumber() 36 | @Min(0) 37 | @Type(() => Number) 38 | @ApiProperty({ 39 | type: Number, 40 | format: 'double', 41 | minimum: 0, 42 | }) 43 | episode_length!: number; 44 | 45 | @IsUUID() 46 | @ApiProperty({ 47 | type: String, 48 | format: 'uuid', 49 | }) 50 | submitter_id!: string; 51 | } 52 | -------------------------------------------------------------------------------- /src/skip-times/models/v1/post-create-skip-time/post-create-skip-time.request-params.v1.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Type } from 'class-transformer'; 3 | import { IsInt, IsNumber, Min } from 'class-validator'; 4 | 5 | export class PostCreateSkipTimeRequestParamsV1 { 6 | @IsInt() 7 | @Min(1) 8 | @Type(() => Number) 9 | @ApiProperty({ 10 | type: 'integer', 11 | format: 'int64', 12 | minimum: 1, 13 | description: 'MAL id of the anime to create a new skip time for', 14 | }) 15 | anime_id!: number; 16 | 17 | @IsNumber() 18 | @Min(0.5) 19 | @Type(() => Number) 20 | @ApiProperty({ 21 | type: 'number', 22 | format: 'double', 23 | minimum: 0.5, 24 | description: 'Episode number of the anime to to create a new skip time for', 25 | }) 26 | episode_number!: number; 27 | } 28 | -------------------------------------------------------------------------------- /src/skip-times/models/v1/post-create-skip-time/post-create-skip-time.response.v1.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class PostCreateSkipTimeResponseV1 { 4 | @ApiProperty() 5 | message!: string; 6 | 7 | @ApiProperty({ 8 | type: String, 9 | format: 'uuid', 10 | }) 11 | skip_id!: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/skip-times/models/v1/post-vote/index.ts: -------------------------------------------------------------------------------- 1 | export * from './post-vote.request-body.v1'; 2 | export * from './post-vote.request-params.v1'; 3 | export * from './post-vote.response.v1'; 4 | -------------------------------------------------------------------------------- /src/skip-times/models/v1/post-vote/post-vote.request-body.v1.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsIn } from 'class-validator'; 3 | import { VoteType } from '../../../skip-times.types'; 4 | 5 | export class PostVoteRequestBodyV1 { 6 | @IsIn(['upvote', 'downvote']) 7 | @ApiProperty({ enum: ['upvote', 'downvote'] }) 8 | vote_type!: VoteType; 9 | } 10 | -------------------------------------------------------------------------------- /src/skip-times/models/v1/post-vote/post-vote.request-params.v1.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsUUID } from 'class-validator'; 3 | 4 | export class PostVoteRequestParamsV1 { 5 | @IsUUID() 6 | @ApiProperty({ 7 | type: String, 8 | format: 'uuid', 9 | description: 'Skip time UUID', 10 | }) 11 | skip_id!: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/skip-times/models/v1/post-vote/post-vote.response.v1.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class PostVoteResponseV1 { 4 | @ApiProperty() 5 | message!: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/skip-times/models/v2/get-skip-times/get-skip-times.request-params.v2.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Type } from 'class-transformer'; 3 | import { IsInt, IsNumber, Min } from 'class-validator'; 4 | 5 | export class GetSkipTimesRequestParamsV2 { 6 | @IsInt() 7 | @Min(1) 8 | @Type(() => Number) 9 | @ApiProperty({ 10 | type: 'integer', 11 | format: 'int64', 12 | minimum: 1, 13 | description: 'MAL id of the anime to get', 14 | }) 15 | animeId!: number; 16 | 17 | @IsNumber() 18 | @Min(0) 19 | @Type(() => Number) 20 | @ApiProperty({ 21 | type: Number, 22 | format: 'double', 23 | minimum: 0, 24 | description: 'Episode number to get', 25 | }) 26 | episodeNumber!: number; 27 | } 28 | -------------------------------------------------------------------------------- /src/skip-times/models/v2/get-skip-times/get-skip-times.request-query.v2.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsArray, IsIn, IsNumber, Min } from 'class-validator'; 3 | import { Transform, Type } from 'class-transformer'; 4 | import { SkipTypeV2, SKIP_TYPES_V2 } from '../../../skip-times.types'; 5 | import { IsUnique } from '../../../../utils'; 6 | 7 | export class GetSkipTimesRequestQueryV2 { 8 | @IsUnique() 9 | @IsArray() 10 | @IsIn(SKIP_TYPES_V2, { each: true }) 11 | @Transform(({ value }) => (!Array.isArray(value) ? [value] : value)) 12 | @ApiProperty({ 13 | type: [String], 14 | enum: SKIP_TYPES_V2, 15 | description: 'Type of skip time to get', 16 | }) 17 | types!: SkipTypeV2[]; 18 | 19 | @IsNumber() 20 | @Min(0) 21 | @Type(() => Number) 22 | @ApiProperty({ 23 | type: Number, 24 | format: 'double', 25 | minimum: 0, 26 | description: 27 | 'Approximate episode length to search for. If the input is 0, it will return all episodes', 28 | }) 29 | episodeLength!: number; 30 | } 31 | -------------------------------------------------------------------------------- /src/skip-times/models/v2/get-skip-times/get-skip-times.response.v2.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { SkipTimeV2, SKIP_TYPES_V2 } from '../../../skip-times.types'; 3 | 4 | export class GetSkipTimesResponseV2 { 5 | @ApiProperty() 6 | statusCode!: number; 7 | 8 | @ApiProperty() 9 | message!: string; 10 | 11 | @ApiProperty() 12 | found!: boolean; 13 | 14 | @ApiProperty({ 15 | type: 'array', 16 | items: { 17 | type: 'object', 18 | properties: { 19 | interval: { 20 | type: 'object', 21 | properties: { 22 | startTime: { 23 | type: 'number', 24 | format: 'double', 25 | minimum: 0, 26 | }, 27 | endTime: { 28 | type: 'number', 29 | format: 'double', 30 | minimum: 0, 31 | }, 32 | }, 33 | }, 34 | skipType: { 35 | type: 'string', 36 | enum: [...SKIP_TYPES_V2], 37 | }, 38 | skipId: { 39 | type: 'string', 40 | format: 'uuid', 41 | }, 42 | episodeLength: { 43 | type: 'number', 44 | format: 'double', 45 | minimum: 0, 46 | }, 47 | }, 48 | }, 49 | }) 50 | results!: SkipTimeV2[]; 51 | } 52 | -------------------------------------------------------------------------------- /src/skip-times/models/v2/get-skip-times/index.ts: -------------------------------------------------------------------------------- 1 | export * from './get-skip-times.request-params.v2'; 2 | export * from './get-skip-times.request-query.v2'; 3 | export * from './get-skip-times.response.v2'; 4 | -------------------------------------------------------------------------------- /src/skip-times/models/v2/index.ts: -------------------------------------------------------------------------------- 1 | export * from './get-skip-times'; 2 | export * from './post-create-skip-time'; 3 | export * from './post-vote'; 4 | -------------------------------------------------------------------------------- /src/skip-times/models/v2/post-create-skip-time/index.ts: -------------------------------------------------------------------------------- 1 | export * from './post-create-skip-time.request-body.v2'; 2 | export * from './post-create-skip-time.request-params.v2'; 3 | export * from './post-create-skip-time.response.v2'; 4 | -------------------------------------------------------------------------------- /src/skip-times/models/v2/post-create-skip-time/post-create-skip-time.request-body.v2.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Type } from 'class-transformer'; 3 | import { IsIn, IsNumber, IsString, IsUUID, Min } from 'class-validator'; 4 | import { SkipTypeV1, SKIP_TYPES_V2 } from '../../../skip-times.types'; 5 | 6 | export class PostCreateSkipTimeRequestBodyV2 { 7 | @IsIn(SKIP_TYPES_V2) 8 | @ApiProperty({ type: String, enum: SKIP_TYPES_V2 }) 9 | skipType!: SkipTypeV1; 10 | 11 | @IsString() 12 | @ApiProperty() 13 | providerName!: string; 14 | 15 | @IsNumber() 16 | @Min(0) 17 | @Type(() => Number) 18 | @ApiProperty({ 19 | type: Number, 20 | format: 'double', 21 | minimum: 0, 22 | }) 23 | startTime!: number; 24 | 25 | @IsNumber() 26 | @Min(0) 27 | @Type(() => Number) 28 | @ApiProperty({ 29 | type: Number, 30 | format: 'double', 31 | minimum: 0, 32 | }) 33 | endTime!: number; 34 | 35 | @IsNumber() 36 | @Min(0) 37 | @Type(() => Number) 38 | @ApiProperty({ 39 | type: Number, 40 | format: 'double', 41 | minimum: 0, 42 | }) 43 | episodeLength!: number; 44 | 45 | @IsUUID() 46 | @ApiProperty({ 47 | type: String, 48 | format: 'uuid', 49 | }) 50 | submitterId!: string; 51 | } 52 | -------------------------------------------------------------------------------- /src/skip-times/models/v2/post-create-skip-time/post-create-skip-time.request-params.v2.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Type } from 'class-transformer'; 3 | import { IsInt, IsNumber, Min } from 'class-validator'; 4 | 5 | export class PostCreateSkipTimeRequestParamsV2 { 6 | @IsInt() 7 | @Min(1) 8 | @Type(() => Number) 9 | @ApiProperty({ 10 | type: 'integer', 11 | format: 'int64', 12 | minimum: 1, 13 | description: 'MAL id of the anime to create a new skip time for', 14 | }) 15 | animeId!: number; 16 | 17 | @IsNumber() 18 | @Min(0) 19 | @Type(() => Number) 20 | @ApiProperty({ 21 | type: Number, 22 | format: 'double', 23 | minimum: 0, 24 | description: 'Episode number of the anime to to create a new skip time for', 25 | }) 26 | episodeNumber!: number; 27 | } 28 | -------------------------------------------------------------------------------- /src/skip-times/models/v2/post-create-skip-time/post-create-skip-time.response.v2.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class PostCreateSkipTimeResponseV2 { 4 | @ApiProperty() 5 | statusCode!: number; 6 | 7 | @ApiProperty() 8 | message!: string; 9 | 10 | @ApiProperty({ 11 | type: String, 12 | format: 'uuid', 13 | }) 14 | skipId!: string; 15 | } 16 | -------------------------------------------------------------------------------- /src/skip-times/models/v2/post-vote/index.ts: -------------------------------------------------------------------------------- 1 | export * from './post-vote.request-body.v2'; 2 | export * from './post-vote.request-params.v2'; 3 | export * from './post-vote.response.v2'; 4 | -------------------------------------------------------------------------------- /src/skip-times/models/v2/post-vote/post-vote.request-body.v2.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsIn } from 'class-validator'; 3 | import { VoteType } from '../../../skip-times.types'; 4 | 5 | export class PostVoteRequestBodyV2 { 6 | @IsIn(['upvote', 'downvote']) 7 | @ApiProperty({ enum: ['upvote', 'downvote'] }) 8 | voteType!: VoteType; 9 | } 10 | -------------------------------------------------------------------------------- /src/skip-times/models/v2/post-vote/post-vote.request-params.v2.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsUUID } from 'class-validator'; 3 | 4 | export class PostVoteRequestParamsV2 { 5 | @IsUUID() 6 | @ApiProperty({ 7 | type: String, 8 | format: 'uuid', 9 | description: 'Skip time UUID', 10 | }) 11 | skipId!: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/skip-times/models/v2/post-vote/post-vote.response.v2.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class PostVoteResponseV2 { 4 | @ApiProperty() 5 | statusCode!: number; 6 | 7 | @ApiProperty() 8 | message!: string; 9 | } 10 | -------------------------------------------------------------------------------- /src/skip-times/skip-times.controller.v1.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | HttpException, 6 | HttpStatus, 7 | Param, 8 | Post, 9 | Query, 10 | UseGuards, 11 | } from '@nestjs/common'; 12 | import { 13 | ApiCreatedResponse, 14 | ApiOkResponse, 15 | ApiOperation, 16 | ApiTags, 17 | } from '@nestjs/swagger'; 18 | import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; 19 | import { 20 | PostSkipTimesV1ThrottlerGuard, 21 | PostVoteSkipTimesV1ThrottlerGuard, 22 | } from '../utils'; 23 | import { 24 | GetSkipTimesRequestParamsV1, 25 | GetSkipTimesRequestQueryV1, 26 | GetSkipTimesResponseV1, 27 | PostCreateSkipTimeRequestBodyV1, 28 | PostCreateSkipTimeRequestParamsV1, 29 | PostCreateSkipTimeResponseV1, 30 | PostVoteRequestBodyV1, 31 | PostVoteRequestParamsV1, 32 | PostVoteResponseV1, 33 | } from './models'; 34 | import { SkipTimesServiceV1 } from './skip-times.service.v1'; 35 | 36 | @Controller({ 37 | path: 'skip-times', 38 | version: '1', 39 | }) 40 | @ApiTags('skip-times') 41 | export class SkipTimesControllerV1 { 42 | constructor(private skipTimesService: SkipTimesServiceV1) {} 43 | 44 | @UseGuards(PostVoteSkipTimesV1ThrottlerGuard) 45 | // Maximum 4 times in 1 hour. 46 | @Throttle(4, 60 * 60) 47 | @Post('/vote/:skip_id') 48 | @ApiOperation({ description: 'Upvotes or downvotes the skip time' }) 49 | @ApiCreatedResponse({ 50 | type: PostVoteResponseV1, 51 | description: 'Success message', 52 | }) 53 | async voteSkipTime( 54 | @Param() params: PostVoteRequestParamsV1, 55 | @Body() body: PostVoteRequestBodyV1 56 | ): Promise { 57 | const isSuccess = await this.skipTimesService.voteSkipTime( 58 | body.vote_type, 59 | params.skip_id 60 | ); 61 | 62 | if (!isSuccess) { 63 | const response = new PostVoteResponseV1(); 64 | response.message = 'Skip time not found'; 65 | 66 | throw new HttpException(response, HttpStatus.NOT_FOUND); 67 | } 68 | 69 | const response = new PostVoteResponseV1(); 70 | response.message = 'success'; 71 | 72 | return response; 73 | } 74 | 75 | @UseGuards(ThrottlerGuard) 76 | // Maximum 120 times in 1 minute. 77 | @Throttle(120, 60) 78 | @Get('/:anime_id/:episode_number') 79 | @ApiOperation({ 80 | description: 81 | 'Retrieves the opening or ending skip times for a specific anime episode', 82 | }) 83 | @ApiOkResponse({ 84 | type: GetSkipTimesResponseV1, 85 | description: 'Skip times object(s)', 86 | }) 87 | async getSkipTimes( 88 | @Param() params: GetSkipTimesRequestParamsV1, 89 | @Query() query: GetSkipTimesRequestQueryV1 90 | ): Promise { 91 | const skipTimes = await this.skipTimesService.findSkipTimes( 92 | params.anime_id, 93 | params.episode_number, 94 | query.types 95 | ); 96 | 97 | const response = new GetSkipTimesResponseV1(); 98 | response.found = skipTimes.length !== 0; 99 | response.results = skipTimes; 100 | 101 | return response; 102 | } 103 | 104 | @UseGuards(PostSkipTimesV1ThrottlerGuard) 105 | // Maximum 10 times in 1 day. 106 | @Throttle(10, 60 * 60 * 24) 107 | @Post('/:anime_id/:episode_number') 108 | @ApiOperation({ 109 | description: 110 | 'Creates the opening or ending skip times for a specific anime episode', 111 | }) 112 | @ApiOkResponse({ 113 | type: PostCreateSkipTimeResponseV1, 114 | description: 'An object containing the skip time parameters', 115 | }) 116 | async createSkipTime( 117 | @Param() params: PostCreateSkipTimeRequestParamsV1, 118 | @Body() body: PostCreateSkipTimeRequestBodyV1 119 | ): Promise { 120 | const skipTime = { 121 | anime_id: params.anime_id, 122 | end_time: body.end_time, 123 | episode_length: body.episode_length, 124 | episode_number: params.episode_number, 125 | provider_name: body.provider_name, 126 | skip_type: body.skip_type, 127 | start_time: body.start_time, 128 | submitter_id: body.submitter_id, 129 | }; 130 | 131 | let skipId; 132 | try { 133 | skipId = await this.skipTimesService.createSkipTime(skipTime); 134 | } catch (error: any) { 135 | throw new HttpException(error.message, HttpStatus.BAD_REQUEST); 136 | } 137 | 138 | const response = new PostCreateSkipTimeResponseV1(); 139 | response.message = 'success'; 140 | response.skip_id = skipId; 141 | 142 | return response; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/skip-times/skip-times.controller.v2.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | HttpException, 6 | HttpStatus, 7 | Param, 8 | Post, 9 | Query, 10 | UseGuards, 11 | } from '@nestjs/common'; 12 | import { 13 | ApiCreatedResponse, 14 | ApiOkResponse, 15 | ApiOperation, 16 | ApiTags, 17 | } from '@nestjs/swagger'; 18 | import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; 19 | import { 20 | PostSkipTimesV2ThrottlerGuard, 21 | PostVoteSkipTimesV2ThrottlerGuard, 22 | } from '../utils'; 23 | import { 24 | GetSkipTimesRequestParamsV2, 25 | GetSkipTimesRequestQueryV2, 26 | GetSkipTimesResponseV2, 27 | PostCreateSkipTimeRequestBodyV2, 28 | PostCreateSkipTimeRequestParamsV2, 29 | PostCreateSkipTimeResponseV2, 30 | PostVoteRequestBodyV2, 31 | PostVoteRequestParamsV2, 32 | PostVoteResponseV2, 33 | } from './models'; 34 | import { SkipTimesServiceV2 } from './skip-times.service.v2'; 35 | 36 | @Controller({ 37 | path: 'skip-times', 38 | version: '2', 39 | }) 40 | @ApiTags('skip-times') 41 | export class SkipTimesControllerV2 { 42 | constructor(private skipTimesService: SkipTimesServiceV2) {} 43 | 44 | @UseGuards(PostVoteSkipTimesV2ThrottlerGuard) 45 | // Maximum 4 times in 1 hour. 46 | @Throttle(4, 60 * 60) 47 | @Post('/vote/:skipId') 48 | @ApiOperation({ description: 'Upvotes or downvotes the skip time' }) 49 | @ApiCreatedResponse({ 50 | type: PostVoteResponseV2, 51 | description: 'Success message', 52 | }) 53 | async voteSkipTime( 54 | @Param() params: PostVoteRequestParamsV2, 55 | @Body() body: PostVoteRequestBodyV2 56 | ): Promise { 57 | const isSuccess = await this.skipTimesService.voteSkipTime( 58 | body.voteType, 59 | params.skipId 60 | ); 61 | 62 | if (!isSuccess) { 63 | const response = new PostVoteResponseV2(); 64 | response.message = 'Skip time not found'; 65 | response.statusCode = HttpStatus.NOT_FOUND; 66 | 67 | throw new HttpException(response, response.statusCode); 68 | } 69 | 70 | const response = new PostVoteResponseV2(); 71 | response.message = `Successfully ${body.voteType} the skip time`; 72 | response.statusCode = HttpStatus.CREATED; 73 | 74 | return response; 75 | } 76 | 77 | @UseGuards(ThrottlerGuard) 78 | // Maximum 120 times in 1 minute. 79 | @Throttle(120, 60) 80 | @Get('/:animeId/:episodeNumber') 81 | @ApiOperation({ 82 | description: 83 | 'Retrieves the opening or ending skip times for a specific anime episode', 84 | }) 85 | @ApiOkResponse({ 86 | type: GetSkipTimesResponseV2, 87 | description: 'Skip times object(s)', 88 | }) 89 | async getSkipTimes( 90 | @Param() params: GetSkipTimesRequestParamsV2, 91 | @Query() query: GetSkipTimesRequestQueryV2 92 | ): Promise { 93 | const skipTimes = await this.skipTimesService.findSkipTimes( 94 | params.animeId, 95 | params.episodeNumber, 96 | query.types, 97 | query.episodeLength 98 | ); 99 | 100 | const response = new GetSkipTimesResponseV2(); 101 | response.found = skipTimes.length !== 0; 102 | response.results = skipTimes; 103 | response.message = response.found 104 | ? 'Successfully found skip times' 105 | : 'No skip times found'; 106 | response.statusCode = response.found ? HttpStatus.OK : HttpStatus.NOT_FOUND; 107 | 108 | if (!response.found) { 109 | throw new HttpException(response, response.statusCode); 110 | } 111 | 112 | return response; 113 | } 114 | 115 | @UseGuards(PostSkipTimesV2ThrottlerGuard) 116 | // Maximum 10 times in 1 day. 117 | @Throttle(10, 60 * 60 * 24) 118 | @Post('/:animeId/:episodeNumber') 119 | @ApiOperation({ 120 | description: 121 | 'Creates the opening or ending skip times for a specific anime episode', 122 | }) 123 | @ApiOkResponse({ 124 | type: PostCreateSkipTimeResponseV2, 125 | description: 'An object containing the skip time parameters', 126 | }) 127 | async createSkipTime( 128 | @Param() params: PostCreateSkipTimeRequestParamsV2, 129 | @Body() body: PostCreateSkipTimeRequestBodyV2 130 | ): Promise { 131 | const skipTime = { 132 | anime_id: params.animeId, 133 | end_time: body.endTime, 134 | episode_length: body.episodeLength, 135 | episode_number: params.episodeNumber, 136 | provider_name: body.providerName, 137 | skip_type: body.skipType, 138 | start_time: body.startTime, 139 | submitter_id: body.submitterId, 140 | }; 141 | 142 | let skipId; 143 | try { 144 | skipId = await this.skipTimesService.createSkipTime(skipTime); 145 | } catch (error: any) { 146 | throw new HttpException(error.message, HttpStatus.BAD_REQUEST); 147 | } 148 | 149 | const response = new PostCreateSkipTimeResponseV2(); 150 | response.message = 'Successfully created a skip time'; 151 | response.skipId = skipId; 152 | response.statusCode = HttpStatus.CREATED; 153 | 154 | return response; 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/skip-times/skip-times.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { SkipTimesServiceV1 } from './skip-times.service.v1'; 3 | import { SkipTimesServiceV2 } from './skip-times.service.v2'; 4 | import { SkipTimesControllerV1 } from './skip-times.controller.v1'; 5 | import { SkipTimesControllerV2 } from './skip-times.controller.v2'; 6 | import { RepositoriesModule } from '../repositories/repositories.module'; 7 | import { VoteModule } from '../vote/vote.module'; 8 | 9 | @Module({ 10 | imports: [RepositoriesModule, VoteModule], 11 | providers: [SkipTimesServiceV1, SkipTimesServiceV2], 12 | controllers: [SkipTimesControllerV1, SkipTimesControllerV2], 13 | }) 14 | export class SkipTimesModule {} 15 | -------------------------------------------------------------------------------- /src/skip-times/skip-times.service.v1.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { VoteService } from '../vote'; 3 | import { SkipTimesRepository } from '../repositories'; 4 | import { 5 | DatabaseSkipTime, 6 | SkipTimeV1, 7 | SkipTypeV1, 8 | VoteType, 9 | } from './skip-times.types'; 10 | 11 | @Injectable() 12 | export class SkipTimesServiceV1 { 13 | constructor( 14 | private skipTimesRepository: SkipTimesRepository, 15 | private voteService: VoteService 16 | ) {} 17 | 18 | /** 19 | * Vote on a skip time. 20 | * 21 | * @param voteType Voting type, can be upvote or downvote. 22 | * @param skipId Skip Id to upvote or downvote. 23 | */ 24 | async voteSkipTime(voteType: VoteType, skipId: string): Promise { 25 | let voteSuccessful = false; 26 | 27 | switch (voteType) { 28 | case 'upvote': 29 | voteSuccessful = await this.skipTimesRepository.upvoteSkipTime(skipId); 30 | break; 31 | case 'downvote': 32 | voteSuccessful = 33 | await this.skipTimesRepository.downvoteSkipTime(skipId); 34 | break; 35 | // no default 36 | } 37 | 38 | return voteSuccessful; 39 | } 40 | 41 | /** 42 | * Create a new skip time entry. 43 | * 44 | * @param skipTime Skip time to create. 45 | */ 46 | async createSkipTime( 47 | skipTime: Omit 48 | ): Promise { 49 | const votes = await this.voteService.autoVote( 50 | skipTime.start_time, 51 | skipTime.end_time, 52 | skipTime.episode_length, 53 | skipTime.submitter_id 54 | ); 55 | 56 | const skipTimeWithVotes = { 57 | ...skipTime, 58 | votes, 59 | }; 60 | 61 | return this.skipTimesRepository.createSkipTime(skipTimeWithVotes); 62 | } 63 | 64 | /** 65 | * Finds one skip time of each skip type passed. 66 | * 67 | * @param animeId MAL id filter. 68 | * @param episodeNumber Episode number filter. 69 | * @param skipTypes Skip types to filter, should be unique. 70 | */ 71 | async findSkipTimes( 72 | animeId: number, 73 | episodeNumber: number, 74 | skipTypes: SkipTypeV1[] 75 | ): Promise { 76 | const result: SkipTimeV1[] = ( 77 | await Promise.all( 78 | skipTypes.map(async (skipType) => { 79 | const skipTimes = await this.skipTimesRepository.findSkipTimes( 80 | animeId, 81 | episodeNumber, 82 | skipType 83 | ); 84 | 85 | if (skipTimes.length === 0) { 86 | return null; 87 | } 88 | 89 | const [skipTimeV2] = skipTimes; 90 | const skipTimeV1: SkipTimeV1 = { 91 | interval: { 92 | start_time: skipTimeV2.interval.startTime, 93 | end_time: skipTimeV2.interval.endTime, 94 | }, 95 | skip_type: skipTimeV2.skipType as SkipTypeV1, 96 | skip_id: skipTimeV2.skipId, 97 | episode_length: skipTimeV2.episodeLength, 98 | }; 99 | 100 | return skipTimeV1; 101 | }) 102 | ) 103 | ).filter((skipTime): skipTime is SkipTimeV1 => skipTime !== null); 104 | 105 | return result; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/skip-times/skip-times.service.v2.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { SkipTimesRepository } from '../repositories'; 3 | import { VoteService } from '../vote'; 4 | import { 5 | DatabaseSkipTime, 6 | SkipTimeV2, 7 | SkipTypeV2, 8 | VoteType, 9 | } from './skip-times.types'; 10 | 11 | @Injectable() 12 | export class SkipTimesServiceV2 { 13 | constructor( 14 | private skipTimesRepository: SkipTimesRepository, 15 | private voteService: VoteService 16 | ) {} 17 | 18 | /** 19 | * Vote on a skip time. 20 | * 21 | * @param voteType Voting type, can be upvote or downvote. 22 | * @param skipId Skip Id to upvote or downvote. 23 | */ 24 | async voteSkipTime(voteType: VoteType, skipId: string): Promise { 25 | let voteSuccessful = false; 26 | 27 | switch (voteType) { 28 | case 'upvote': 29 | voteSuccessful = await this.skipTimesRepository.upvoteSkipTime(skipId); 30 | break; 31 | case 'downvote': 32 | voteSuccessful = 33 | await this.skipTimesRepository.downvoteSkipTime(skipId); 34 | break; 35 | // no default 36 | } 37 | 38 | return voteSuccessful; 39 | } 40 | 41 | /** 42 | * Create a new skip time entry. 43 | * 44 | * @param skipTime Skip time to create. 45 | */ 46 | async createSkipTime( 47 | skipTime: Omit 48 | ): Promise { 49 | const votes = await this.voteService.autoVote( 50 | skipTime.start_time, 51 | skipTime.end_time, 52 | skipTime.episode_length, 53 | skipTime.submitter_id, 54 | skipTime.skip_type 55 | ); 56 | 57 | const skipTimeWithVotes = { 58 | ...skipTime, 59 | votes, 60 | }; 61 | 62 | return this.skipTimesRepository.createSkipTime(skipTimeWithVotes); 63 | } 64 | 65 | /** 66 | * Finds one skip time of each skip type passed. 67 | * 68 | * @param animeId MAL id filter. 69 | * @param episodeNumber Episode number filter. 70 | * @param skipTypes Skip types to filter, should be unique. 71 | * @param episodeLength Approximate episode length to search for. If the input is 0, it will return all episodes. 72 | */ 73 | async findSkipTimes( 74 | animeId: number, 75 | episodeNumber: number, 76 | skipTypes: SkipTypeV2[], 77 | episodeLength: number 78 | ): Promise { 79 | const result: SkipTimeV2[] = ( 80 | await Promise.all( 81 | skipTypes.map(async (skipType) => { 82 | const skipTimes = await this.skipTimesRepository.findSkipTimes( 83 | animeId, 84 | episodeNumber, 85 | skipType, 86 | episodeLength 87 | ); 88 | 89 | if (skipTimes.length === 0) { 90 | return null; 91 | } 92 | 93 | return skipTimes[0]; 94 | }) 95 | ) 96 | ).filter((skipTime): skipTime is SkipTimeV2 => skipTime !== null); 97 | 98 | return result; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/skip-times/skip-times.types.ts: -------------------------------------------------------------------------------- 1 | export const SKIP_TYPES_V1 = ['op', 'ed'] as const; 2 | 3 | export type SkipTypeV1 = (typeof SKIP_TYPES_V1)[number]; 4 | 5 | export type SkipTimeV1 = { 6 | interval: { 7 | start_time: number; 8 | end_time: number; 9 | }; 10 | skip_type: SkipTypeV1; 11 | skip_id: string; 12 | episode_length: number; 13 | }; 14 | 15 | export const SKIP_TYPES_V2 = [ 16 | ...SKIP_TYPES_V1, 17 | 'mixed-op', 18 | 'mixed-ed', 19 | 'recap', 20 | ] as const; 21 | 22 | export type SkipTypeV2 = (typeof SKIP_TYPES_V2)[number]; 23 | 24 | export type SkipTimeV2 = { 25 | interval: { 26 | startTime: number; 27 | endTime: number; 28 | }; 29 | skipType: SkipTypeV2; 30 | skipId: string; 31 | episodeLength: number; 32 | }; 33 | 34 | export type DatabaseSkipTime = { 35 | start_time: number; 36 | end_time: number; 37 | skip_type: SkipTypeV2; 38 | skip_id: string; 39 | episode_length: number; 40 | anime_id: number; 41 | episode_number: number; 42 | provider_name: string; 43 | votes: number; 44 | submit_date: Date; 45 | submitter_id: string; 46 | }; 47 | 48 | export type CreateSkipTimesQueryResponse = { 49 | skip_id: string; 50 | }; 51 | 52 | export type VoteType = 'upvote' | 'downvote'; 53 | 54 | export type GetAverageOfLastTenSkipTimesVotesQueryResponse = { avg: number }; 55 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './logging'; 2 | export * from './throttling'; 3 | export * from './validation'; 4 | -------------------------------------------------------------------------------- /src/utils/logging/__tests__/morgan.middleware.integration.test.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus, INestApplication } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import request from 'supertest'; 4 | import { MorganTestModule } from '../../testing/morgan-test.module'; 5 | 6 | describe('MorganMiddleware', () => { 7 | let app: INestApplication; 8 | 9 | beforeAll(async () => { 10 | const module = await Test.createTestingModule({ 11 | imports: [MorganTestModule], 12 | }).compile(); 13 | 14 | app = module.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | describe('GET /test', () => { 19 | it('should respond with hello world', (done) => { 20 | request(app.getHttpServer()) 21 | .get('/test') 22 | .expect('hello world') 23 | .expect(HttpStatus.OK, done); 24 | }); 25 | }); 26 | 27 | afterAll(async () => { 28 | await app.close(); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/utils/logging/index.ts: -------------------------------------------------------------------------------- 1 | export * from './morgan.middleware'; 2 | -------------------------------------------------------------------------------- /src/utils/logging/morgan.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger, NestMiddleware } from '@nestjs/common'; 2 | import { Request, Response, NextFunction } from 'express'; 3 | import morgan from 'morgan'; 4 | 5 | @Injectable() 6 | export class MorganMiddleware implements NestMiddleware { 7 | private logger = new Logger(MorganMiddleware.name); 8 | 9 | use(req: Request, res: Response, next: NextFunction): void { 10 | morgan( 11 | ':remote-addr ":method :url HTTP/:http-version" :status :res[content-length]', 12 | { 13 | stream: { 14 | write: (str) => this.logger.log(str.trimEnd()), 15 | }, 16 | } 17 | )(req, res, next); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/utils/testing/index.ts: -------------------------------------------------------------------------------- 1 | export * from './morgan-test.controller'; 2 | export * from './post-skip-times-throttler-guard-test.controller'; 3 | -------------------------------------------------------------------------------- /src/utils/testing/morgan-test.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | 3 | @Controller() 4 | export class MorganTestController { 5 | @Get('test') 6 | getTest(): string { 7 | return 'hello world'; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/testing/morgan-test.module.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; 2 | import { MorganMiddleware } from '../logging'; 3 | import { MorganTestController } from './morgan-test.controller'; 4 | 5 | @Module({ 6 | controllers: [MorganTestController], 7 | }) 8 | export class MorganTestModule implements NestModule { 9 | configure(consumer: MiddlewareConsumer): void { 10 | consumer.apply(MorganMiddleware).forRoutes('*'); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/testing/post-skip-times-throttler-guard-test.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Post, UseGuards, Version } from '@nestjs/common'; 2 | import { Throttle } from '@nestjs/throttler'; 3 | import { 4 | PostSkipTimesV1ThrottlerGuard, 5 | PostSkipTimesV2ThrottlerGuard, 6 | } from '../throttling'; 7 | 8 | @Controller() 9 | export class PostSkipTimesThrottlerGuardTestController { 10 | @Version('1') 11 | @UseGuards(PostSkipTimesV1ThrottlerGuard) 12 | @Throttle(1, 120) 13 | @Post('test/:anime_id/:episode_number') 14 | getTestV1(): string { 15 | return 'hello world'; 16 | } 17 | 18 | @Version('2') 19 | @UseGuards(PostSkipTimesV2ThrottlerGuard) 20 | @Throttle(1, 120) 21 | @Post('test/:animeId/:episodeNumber') 22 | getTestV2(): string { 23 | return 'hello world'; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/testing/post-skip-times-throttler-guard-test.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ThrottlerModule } from '@nestjs/throttler'; 3 | import { PostSkipTimesThrottlerGuardTestController } from './post-skip-times-throttler-guard-test.controller'; 4 | 5 | @Module({ 6 | imports: [ThrottlerModule.forRoot()], 7 | controllers: [PostSkipTimesThrottlerGuardTestController], 8 | }) 9 | export class PostSkipTimesThrottlerGuardTestModule {} 10 | -------------------------------------------------------------------------------- /src/utils/testing/post-vote-skip-times-throttler-guard-test.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Post, UseGuards, Version } from '@nestjs/common'; 2 | import { Throttle } from '@nestjs/throttler'; 3 | import { 4 | PostVoteSkipTimesV1ThrottlerGuard, 5 | PostVoteSkipTimesV2ThrottlerGuard, 6 | } from '../throttling'; 7 | 8 | @Controller() 9 | export class PostVoteSkipTimesThrottlerGuardTestController { 10 | @Version('1') 11 | @UseGuards(PostVoteSkipTimesV1ThrottlerGuard) 12 | @Throttle(1, 120) 13 | @Post('test/:skip_id') 14 | getTestV1(): string { 15 | return 'hello world'; 16 | } 17 | 18 | @Version('2') 19 | @UseGuards(PostVoteSkipTimesV2ThrottlerGuard) 20 | @Throttle(1, 120) 21 | @Post('test/:skipId') 22 | getTestV2(): string { 23 | return 'hello world'; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/testing/post-vote-skip-times-throttler-guard-test.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ThrottlerModule } from '@nestjs/throttler'; 3 | import { PostVoteSkipTimesThrottlerGuardTestController } from './post-vote-skip-times-throttler-guard-test.controller'; 4 | 5 | @Module({ 6 | imports: [ThrottlerModule.forRoot()], 7 | controllers: [PostVoteSkipTimesThrottlerGuardTestController], 8 | }) 9 | export class PostVoteSkipTimesThrottlerGuardTestModule {} 10 | -------------------------------------------------------------------------------- /src/utils/throttling/__tests__/post-skip-times-throttler.v1.guard.integration.test.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import request from 'supertest'; 4 | import { PostSkipTimesThrottlerGuardTestModule } from '../../testing/post-skip-times-throttler-guard-test.module'; 5 | 6 | describe('PostSkipTimesV1ThrottlerGuard', () => { 7 | let app: INestApplication; 8 | 9 | beforeAll(async () => { 10 | const module = await Test.createTestingModule({ 11 | imports: [PostSkipTimesThrottlerGuardTestModule], 12 | }).compile(); 13 | 14 | app = module.createNestApplication(); 15 | app.enableVersioning(); 16 | await app.init(); 17 | }); 18 | 19 | describe('GET /v1/test/{anime_id}/{episode_number}', () => { 20 | it('should respond with hello world', (done) => { 21 | request(app.getHttpServer()) 22 | .post('/v1/test/1/1') 23 | .expect('hello world') 24 | .expect(201, done); 25 | }); 26 | 27 | it('should respond with rate limited', (done) => { 28 | request(app.getHttpServer()).post('/v1/test/1/1').expect(429, done); 29 | }); 30 | 31 | it('should respond with hello world', (done) => { 32 | request(app.getHttpServer()) 33 | .post('/v1/test/2/1') 34 | .expect('hello world') 35 | .expect(201, done); 36 | }); 37 | }); 38 | 39 | afterAll(async () => { 40 | await app.close(); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/utils/throttling/__tests__/post-skip-times-throttler.v2.guard.integration.test.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import request from 'supertest'; 4 | import { PostSkipTimesThrottlerGuardTestModule } from '../../testing/post-skip-times-throttler-guard-test.module'; 5 | 6 | describe('PostSkipTimesV2ThrottlerGuard', () => { 7 | let app: INestApplication; 8 | 9 | beforeAll(async () => { 10 | const module = await Test.createTestingModule({ 11 | imports: [PostSkipTimesThrottlerGuardTestModule], 12 | }).compile(); 13 | 14 | app = module.createNestApplication(); 15 | app.enableVersioning(); 16 | await app.init(); 17 | }); 18 | 19 | describe('GET /v2/test/{animeId}/{episodeNumber}', () => { 20 | it('should respond with hello world', (done) => { 21 | request(app.getHttpServer()) 22 | .post('/v2/test/1/1') 23 | .expect('hello world') 24 | .expect(201, done); 25 | }); 26 | 27 | it('should respond with rate limited', (done) => { 28 | request(app.getHttpServer()).post('/v2/test/1/1').expect(429, done); 29 | }); 30 | 31 | it('should respond with hello world', (done) => { 32 | request(app.getHttpServer()) 33 | .post('/v2/test/2/1') 34 | .expect('hello world') 35 | .expect(201, done); 36 | }); 37 | }); 38 | 39 | afterAll(async () => { 40 | await app.close(); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/utils/throttling/__tests__/post-vote-skip-times-throttler.v1.guard.integration.test.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import request from 'supertest'; 4 | import { PostVoteSkipTimesThrottlerGuardTestModule } from '../../testing/post-vote-skip-times-throttler-guard-test.module'; 5 | 6 | describe('PostVoteSkipTimesV1ThrottlerGuard', () => { 7 | let app: INestApplication; 8 | 9 | beforeAll(async () => { 10 | const module = await Test.createTestingModule({ 11 | imports: [PostVoteSkipTimesThrottlerGuardTestModule], 12 | }).compile(); 13 | 14 | app = module.createNestApplication(); 15 | app.enableVersioning(); 16 | await app.init(); 17 | }); 18 | 19 | describe('GET /v1/test/{skip_id}', () => { 20 | it('should respond with hello world', (done) => { 21 | request(app.getHttpServer()) 22 | .post('/v1/test/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee') 23 | .expect('hello world') 24 | .expect(201, done); 25 | }); 26 | 27 | it('should respond with rate limited', (done) => { 28 | request(app.getHttpServer()) 29 | .post('/v1/test/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee') 30 | .expect(429, done); 31 | }); 32 | 33 | it('should respond with hello world', (done) => { 34 | request(app.getHttpServer()) 35 | .post('/v1/test/aaaaaaaa-bbbb-cccc-dddd-ffffffffffff') 36 | .expect('hello world') 37 | .expect(201, done); 38 | }); 39 | }); 40 | 41 | afterAll(async () => { 42 | await app.close(); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/utils/throttling/__tests__/post-vote-skip-times-throttler.v2.guard.integration.test.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import request from 'supertest'; 4 | import { PostVoteSkipTimesThrottlerGuardTestModule } from '../../testing/post-vote-skip-times-throttler-guard-test.module'; 5 | 6 | describe('PostVoteSkipTimesV2ThrottlerGuard', () => { 7 | let app: INestApplication; 8 | 9 | beforeAll(async () => { 10 | const module = await Test.createTestingModule({ 11 | imports: [PostVoteSkipTimesThrottlerGuardTestModule], 12 | }).compile(); 13 | 14 | app = module.createNestApplication(); 15 | app.enableVersioning(); 16 | await app.init(); 17 | }); 18 | 19 | describe('GET /v2/test/{skipId}', () => { 20 | it('should respond with hello world', (done) => { 21 | request(app.getHttpServer()) 22 | .post('/v2/test/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee') 23 | .expect('hello world') 24 | .expect(201, done); 25 | }); 26 | 27 | it('should respond with rate limited', (done) => { 28 | request(app.getHttpServer()) 29 | .post('/v2/test/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee') 30 | .expect(429, done); 31 | }); 32 | 33 | it('should respond with hello world', (done) => { 34 | request(app.getHttpServer()) 35 | .post('/v2/test/aaaaaaaa-bbbb-cccc-dddd-ffffffffffff') 36 | .expect('hello world') 37 | .expect(201, done); 38 | }); 39 | }); 40 | 41 | afterAll(async () => { 42 | await app.close(); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/utils/throttling/index.ts: -------------------------------------------------------------------------------- 1 | export * from './post-skip-times-throttler.v1.guard'; 2 | export * from './post-skip-times-throttler.v2.guard'; 3 | export * from './post-vote-skip-times-throttler.v1.guard'; 4 | export * from './post-vote-skip-times-throttler.v2.guard'; 5 | -------------------------------------------------------------------------------- /src/utils/throttling/post-skip-times-throttler.v1.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext } from '@nestjs/common'; 2 | import { ThrottlerGuard } from '@nestjs/throttler'; 3 | import md5 from 'md5'; 4 | 5 | export class PostSkipTimesV1ThrottlerGuard extends ThrottlerGuard { 6 | generateKey(context: ExecutionContext, suffix: string): string { 7 | const { req } = this.getRequestResponse(context); 8 | const prefix = `${context.getClass().name}-${context.getHandler().name}-${ 9 | req.params.anime_id 10 | }-${req.params.episode_number}`; 11 | 12 | return md5(`${prefix}-${suffix}`); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/throttling/post-skip-times-throttler.v2.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext } from '@nestjs/common'; 2 | import { ThrottlerGuard } from '@nestjs/throttler'; 3 | import md5 from 'md5'; 4 | 5 | export class PostSkipTimesV2ThrottlerGuard extends ThrottlerGuard { 6 | generateKey(context: ExecutionContext, suffix: string): string { 7 | const { req } = this.getRequestResponse(context); 8 | const prefix = `${context.getClass().name}-${context.getHandler().name}-${ 9 | req.params.animeId 10 | }-${req.params.episodeNumber}`; 11 | 12 | return md5(`${prefix}-${suffix}`); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/throttling/post-vote-skip-times-throttler.v1.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext } from '@nestjs/common'; 2 | import { ThrottlerGuard } from '@nestjs/throttler'; 3 | import md5 from 'md5'; 4 | 5 | export class PostVoteSkipTimesV1ThrottlerGuard extends ThrottlerGuard { 6 | generateKey(context: ExecutionContext, suffix: string): string { 7 | const { req } = this.getRequestResponse(context); 8 | const prefix = `${context.getClass().name}-${context.getHandler().name}-${ 9 | req.params.skip_id 10 | }`; 11 | 12 | return md5(`${prefix}-${suffix}`); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/throttling/post-vote-skip-times-throttler.v2.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext } from '@nestjs/common'; 2 | import { ThrottlerGuard } from '@nestjs/throttler'; 3 | import md5 from 'md5'; 4 | 5 | export class PostVoteSkipTimesV2ThrottlerGuard extends ThrottlerGuard { 6 | generateKey(context: ExecutionContext, suffix: string): string { 7 | const { req } = this.getRequestResponse(context); 8 | const prefix = `${context.getClass().name}-${context.getHandler().name}-${ 9 | req.params.skipId 10 | }`; 11 | 12 | return md5(`${prefix}-${suffix}`); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/validation/__tests__/validators.unit.test.ts: -------------------------------------------------------------------------------- 1 | import { Validator } from 'class-validator'; 2 | import { IsUnique } from '../validators'; 3 | 4 | const validator = new Validator(); 5 | 6 | describe('IsUnique', () => { 7 | it('should return false for a null array', async () => { 8 | class MyClass { 9 | @IsUnique() 10 | arr: any[] = null!; 11 | } 12 | 13 | const model = new MyClass(); 14 | const errors = await validator.validate(model); 15 | 16 | expect(errors.length).toBe(1); 17 | expect(errors[0].target).toBe(model); 18 | expect(errors[0].property).toBe('arr'); 19 | expect(errors[0].constraints).toEqual({ 20 | IsUnique: 'each value in the array must be unique', 21 | }); 22 | expect(errors[0].value).toBeNull(); 23 | }); 24 | 25 | it('should return false for an undefined array', async () => { 26 | class MyClass { 27 | @IsUnique() 28 | arr!: any[]; 29 | } 30 | 31 | const model = new MyClass(); 32 | const errors = await validator.validate(model); 33 | 34 | expect(errors.length).toBe(1); 35 | expect(errors[0].target).toBe(model); 36 | expect(errors[0].property).toBe('arr'); 37 | expect(errors[0].constraints).toEqual({ 38 | IsUnique: 'each value in the array must be unique', 39 | }); 40 | expect(errors[0].value).toBeUndefined(); 41 | }); 42 | 43 | it('should return true for an empty array', async () => { 44 | class MyClass { 45 | @IsUnique() 46 | arr: any[] = []; 47 | } 48 | 49 | const model = new MyClass(); 50 | const errors = await validator.validate(model); 51 | 52 | expect(errors.length).toBe(0); 53 | }); 54 | 55 | it('should return false for a non unique array', async () => { 56 | const arr = [1, 1]; 57 | class MyClass { 58 | @IsUnique() 59 | arr: any[] = arr; 60 | } 61 | 62 | const model = new MyClass(); 63 | const errors = await validator.validate(model); 64 | 65 | expect(errors.length).toBe(1); 66 | expect(errors[0].target).toBe(model); 67 | expect(errors[0].property).toBe('arr'); 68 | expect(errors[0].constraints).toEqual({ 69 | IsUnique: 'each value in the array must be unique', 70 | }); 71 | expect(errors[0].value).toEqual(arr); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/utils/validation/index.ts: -------------------------------------------------------------------------------- 1 | export * from './validators'; 2 | -------------------------------------------------------------------------------- /src/utils/validation/validators.ts: -------------------------------------------------------------------------------- 1 | import { registerDecorator, ValidationOptions } from 'class-validator'; 2 | 3 | /** 4 | * Checks if each value in an array is unique. 5 | * 6 | * @param validationOptions Validation options. 7 | */ 8 | export const IsUnique = 9 | (validationOptions?: ValidationOptions) => 10 | (object: Object, propertyName: string): void => 11 | registerDecorator({ 12 | name: 'IsUnique', 13 | target: object.constructor, 14 | propertyName, 15 | options: { 16 | message: 'each value in the array must be unique', 17 | ...validationOptions, 18 | }, 19 | validator: { 20 | validate: (items: any[]): boolean => { 21 | if (!items) { 22 | return false; 23 | } 24 | 25 | if (new Set(items).size !== items.length) { 26 | return false; 27 | } 28 | 29 | return true; 30 | }, 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /src/vote/__tests__/vote.service.unit.test.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { SkipTimesRepository } from '../../repositories'; 3 | import { VoteService } from '../vote.service'; 4 | 5 | describe('VoteService', () => { 6 | let votingService: VoteService; 7 | 8 | beforeEach(async () => { 9 | const mockSkipTimesRepositoryProvider = { 10 | provide: SkipTimesRepository, 11 | useValue: { 12 | getAverageOfLastTenSkipTimesVotes: jest.fn((submitterId) => { 13 | switch (submitterId) { 14 | case 'e93e3787-3071-4d1f-833f-a78755702f6b': 15 | return Promise.resolve(3.5); 16 | default: 17 | return Promise.resolve(0); 18 | } 19 | }), 20 | }, 21 | }; 22 | 23 | const module: TestingModule = await Test.createTestingModule({ 24 | providers: [mockSkipTimesRepositoryProvider, VoteService], 25 | }).compile(); 26 | 27 | votingService = module.get(VoteService); 28 | }); 29 | 30 | it('should be defined', () => { 31 | expect(votingService).toBeDefined(); 32 | }); 33 | 34 | describe('autoVote', () => { 35 | it('should detect a user with good reputation', async () => { 36 | const votes = await votingService.autoVote( 37 | 130, 38 | 210, 39 | 1440, 40 | 'e93e3787-3071-4d1f-833f-a78755702f6b' 41 | ); 42 | expect(votes).toBe(1); 43 | }); 44 | 45 | it("should detect a bad skip time in a 'full' episode", async () => { 46 | const votes = await votingService.autoVote( 47 | 0, 48 | 0, 49 | 1440, 50 | '17ca2744-c222-4856-bc75-450498b1699e' 51 | ); 52 | expect(votes).toBe(-10); 53 | }); 54 | 55 | it("should detect a bad skip time in a 'full' episode", async () => { 56 | const votes = await votingService.autoVote( 57 | 1, 58 | 1.5, 59 | 1440, 60 | '17ca2744-c222-4856-bc75-450498b1699e' 61 | ); 62 | expect(votes).toBe(-10); 63 | }); 64 | 65 | it("should detect a bad skip time in a 'full' episode", async () => { 66 | const votes = await votingService.autoVote( 67 | 0, 68 | 1000, 69 | 1440, 70 | '17ca2744-c222-4856-bc75-450498b1699e' 71 | ); 72 | expect(votes).toBe(-10); 73 | }); 74 | 75 | it("should detect a bad skip time in a 'full' episode", async () => { 76 | const votes = await votingService.autoVote( 77 | 0, 78 | 1440, 79 | 1440, 80 | '17ca2744-c222-4856-bc75-450498b1699e' 81 | ); 82 | expect(votes).toBe(-10); 83 | }); 84 | 85 | it("should detect a normal skip time in a 'full' episode", async () => { 86 | const votes = await votingService.autoVote( 87 | 130, 88 | 210, 89 | 1440, 90 | '17ca2744-c222-4856-bc75-450498b1699e' 91 | ); 92 | expect(votes).toBe(0); 93 | }); 94 | 95 | it("should detect a bad skip time in a 'short' episode", async () => { 96 | const votes = await votingService.autoVote( 97 | 0, 98 | 55, 99 | 118, 100 | '17ca2744-c222-4856-bc75-450498b1699e' 101 | ); 102 | expect(votes).toBe(-10); 103 | }); 104 | 105 | it("should detect a normal skip time in a 'short' episode", async () => { 106 | const votes = await votingService.autoVote( 107 | 30, 108 | 55, 109 | 119, 110 | '17ca2744-c222-4856-bc75-450498b1699e' 111 | ); 112 | expect(votes).toBe(0); 113 | }); 114 | 115 | it("should detect a bad skip time in a 'half' episode", async () => { 116 | const votes = await votingService.autoVote( 117 | 0, 118 | 240, 119 | 719, 120 | '17ca2744-c222-4856-bc75-450498b1699e' 121 | ); 122 | expect(votes).toBe(-10); 123 | }); 124 | 125 | it("should detect a normal skip time in a 'half' episode", async () => { 126 | const votes = await votingService.autoVote( 127 | 100, 128 | 160, 129 | 719, 130 | '17ca2744-c222-4856-bc75-450498b1699e' 131 | ); 132 | expect(votes).toBe(0); 133 | }); 134 | 135 | it("should detect a bad skip time in a 'movie' episode", async () => { 136 | const votes = await votingService.autoVote( 137 | 0, 138 | 960, 139 | 5400, 140 | '17ca2744-c222-4856-bc75-450498b1699e' 141 | ); 142 | expect(votes).toBe(-10); 143 | }); 144 | 145 | it("should detect a normal skip time in a 'movie' episode", async () => { 146 | const votes = await votingService.autoVote( 147 | 0, 148 | 420, 149 | 5400, 150 | '17ca2744-c222-4856-bc75-450498b1699e' 151 | ); 152 | expect(votes).toBe(0); 153 | }); 154 | 155 | it("should detect a bad 'recap' skip time in a 'full' episode", async () => { 156 | const votes = await votingService.autoVote( 157 | 0, 158 | 0, 159 | 1440, 160 | '17ca2744-c222-4856-bc75-450498b1699e', 161 | 'recap' 162 | ); 163 | expect(votes).toBe(-10); 164 | }); 165 | 166 | it("should detect a bad 'recap' skip time in a 'full' episode", async () => { 167 | const votes = await votingService.autoVote( 168 | 1, 169 | 1.5, 170 | 1440, 171 | '17ca2744-c222-4856-bc75-450498b1699e', 172 | 'recap' 173 | ); 174 | expect(votes).toBe(-10); 175 | }); 176 | 177 | it("should detect a bad 'recap' skip time in a 'full' episode", async () => { 178 | const votes = await votingService.autoVote( 179 | 0, 180 | 1000, 181 | 1440, 182 | '17ca2744-c222-4856-bc75-450498b1699e', 183 | 'recap' 184 | ); 185 | expect(votes).toBe(-10); 186 | }); 187 | 188 | it("should detect a bad 'recap' skip time in a 'full' episode", async () => { 189 | const votes = await votingService.autoVote( 190 | 0, 191 | 1440, 192 | 1440, 193 | '17ca2744-c222-4856-bc75-450498b1699e', 194 | 'recap' 195 | ); 196 | expect(votes).toBe(-10); 197 | }); 198 | 199 | it("should detect a normal 'recap' skip time in a 'full' episode", async () => { 200 | const votes = await votingService.autoVote( 201 | 130, 202 | 370, 203 | 1440, 204 | '17ca2744-c222-4856-bc75-450498b1699e', 205 | 'recap' 206 | ); 207 | expect(votes).toBe(0); 208 | }); 209 | 210 | it("should detect a bad 'recap' skip time in a 'short' episode", async () => { 211 | const votes = await votingService.autoVote( 212 | 0, 213 | 55, 214 | 118, 215 | '17ca2744-c222-4856-bc75-450498b1699e', 216 | 'recap' 217 | ); 218 | expect(votes).toBe(-10); 219 | }); 220 | 221 | it("should detect a normal 'recap' skip time in a 'short' episode", async () => { 222 | const votes = await votingService.autoVote( 223 | 30, 224 | 55, 225 | 119, 226 | '17ca2744-c222-4856-bc75-450498b1699e', 227 | 'recap' 228 | ); 229 | expect(votes).toBe(0); 230 | }); 231 | 232 | it("should detect a bad 'recap' skip time in a 'half' episode", async () => { 233 | const votes = await votingService.autoVote( 234 | 0, 235 | 240, 236 | 719, 237 | '17ca2744-c222-4856-bc75-450498b1699e', 238 | 'recap' 239 | ); 240 | expect(votes).toBe(-10); 241 | }); 242 | 243 | it("should detect a normal 'recap' skip time in a 'half' episode", async () => { 244 | const votes = await votingService.autoVote( 245 | 100, 246 | 220, 247 | 719, 248 | '17ca2744-c222-4856-bc75-450498b1699e', 249 | 'recap' 250 | ); 251 | expect(votes).toBe(0); 252 | }); 253 | 254 | it("should detect a bad 'recap' skip time in a 'movie' episode", async () => { 255 | const votes = await votingService.autoVote( 256 | 0, 257 | 960, 258 | 5400, 259 | '17ca2744-c222-4856-bc75-450498b1699e', 260 | 'recap' 261 | ); 262 | expect(votes).toBe(-10); 263 | }); 264 | 265 | it("should detect a normal 'recap' skip time in a 'movie' episode", async () => { 266 | const votes = await votingService.autoVote( 267 | 0, 268 | 720, 269 | 5400, 270 | '17ca2744-c222-4856-bc75-450498b1699e', 271 | 'recap' 272 | ); 273 | expect(votes).toBe(0); 274 | }); 275 | }); 276 | }); 277 | -------------------------------------------------------------------------------- /src/vote/index.ts: -------------------------------------------------------------------------------- 1 | export * from './vote.service'; 2 | export * from './vote.types'; 3 | -------------------------------------------------------------------------------- /src/vote/vote.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { RepositoriesModule } from '../repositories/repositories.module'; 3 | import { VoteService } from './vote.service'; 4 | 5 | @Module({ 6 | imports: [RepositoriesModule], 7 | providers: [VoteService], 8 | exports: [VoteService], 9 | }) 10 | export class VoteModule {} 11 | -------------------------------------------------------------------------------- /src/vote/vote.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { SkipTimesRepository } from '../repositories'; 3 | import { EpisodeType } from './vote.types'; 4 | import { SkipTypeV2 } from '../skip-times/skip-times.types'; 5 | 6 | @Injectable() 7 | export class VoteService { 8 | constructor(private skipTimesRepository: SkipTimesRepository) {} 9 | 10 | /** 11 | * Returns the type of episode according to specified intervals. 12 | * 13 | * @param episodeLength Length of episode. 14 | */ 15 | getEpisodeType(episodeLength: number): EpisodeType { 16 | // 2 minutes. 17 | if (episodeLength < 60 * 2) { 18 | return 'short'; 19 | } 20 | 21 | // 12 minutes. 22 | if (episodeLength < 60 * 12) { 23 | return 'half'; 24 | } 25 | 26 | // 35 minutes. 27 | if (episodeLength < 60 * 35) { 28 | return 'full'; 29 | } 30 | 31 | return 'movie'; 32 | } 33 | 34 | /** 35 | * Determine starting vote of a skip time. 36 | * 37 | * @param startTime Start time of the skip interval. 38 | * @param endTime End time of the skip interval. 39 | * @param episodeLength Length of the episode. 40 | */ 41 | async autoVote( 42 | startTime: number, 43 | endTime: number, 44 | episodeLength: number, 45 | submitterId: string, 46 | skipType?: SkipTypeV2 47 | ): Promise { 48 | const episodeType = this.getEpisodeType(episodeLength); 49 | const intervalLength = endTime - startTime; 50 | 51 | if (intervalLength < 5) { 52 | return -10; 53 | } 54 | 55 | switch (skipType) { 56 | case 'recap': { 57 | switch (episodeType) { 58 | case 'short': { 59 | // 40 seconds. 60 | if (intervalLength > 40) { 61 | return -10; 62 | } 63 | break; 64 | } 65 | case 'half': { 66 | // 3 minutes. 67 | if (intervalLength > 60 * 3) { 68 | return -10; 69 | } 70 | break; 71 | } 72 | case 'full': { 73 | // 6 minutes. 74 | if (intervalLength > 60 * 6) { 75 | return -10; 76 | } 77 | break; 78 | } 79 | case 'movie': { 80 | // 12 minutes. 81 | if (intervalLength > 60 * 12) { 82 | return -10; 83 | } 84 | break; 85 | } 86 | // no default 87 | } 88 | break; 89 | } 90 | default: { 91 | switch (episodeType) { 92 | case 'short': { 93 | // 40 seconds. 94 | if (intervalLength > 40) { 95 | return -10; 96 | } 97 | break; 98 | } 99 | case 'half': { 100 | // 2 minutes. 101 | if (intervalLength > 60 * 2) { 102 | return -10; 103 | } 104 | break; 105 | } 106 | case 'full': { 107 | // 3 minutes. 108 | if (intervalLength > 60 * 3) { 109 | return -10; 110 | } 111 | break; 112 | } 113 | case 'movie': { 114 | // 8 minutes. 115 | if (intervalLength > 60 * 8) { 116 | return -10; 117 | } 118 | break; 119 | } 120 | // no default 121 | } 122 | } 123 | } 124 | 125 | const averageVotes = 126 | await this.skipTimesRepository.getAverageOfLastTenSkipTimesVotes( 127 | submitterId 128 | ); 129 | 130 | // User has good reputation. 131 | if (averageVotes > 2) { 132 | return 1; 133 | } 134 | 135 | return 0; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/vote/vote.types.ts: -------------------------------------------------------------------------------- 1 | export type EpisodeType = 'short' | 'half' | 'full' | 'movie'; 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | /* Basic Options */ 5 | "incremental": true, /* Enable incremental compilation */ 6 | "target": "es2017", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 7 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 8 | // "lib": [], /* Specify library files to be included in the compilation. */ 9 | // "allowJs": true, /* Allow javascript files to be compiled. */ 10 | // "checkJs": true, /* Report errors in .js files. */ 11 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 12 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 13 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 14 | "sourceMap": true, /* Generates corresponding '.map' file. */ 15 | // "outFile": "./", /* Concatenate and emit output to single file. */ 16 | "outDir": "./dist", /* Redirect output structure to the directory. */ 17 | // "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 18 | // "composite": true, /* Enable project compilation */ 19 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 20 | "removeComments": true, /* Do not emit comments to output. */ 21 | // "noEmit": true, /* Do not emit outputs. */ 22 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 23 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 24 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 25 | "resolveJsonModule": true, 26 | /* Strict Type-Checking Options */ 27 | "strict": true, /* Enable all strict type-checking options. */ 28 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 29 | // "strictNullChecks": true, /* Enable strict null checks. */ 30 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 31 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 32 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 33 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 34 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 35 | /* Additional Checks */ 36 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 37 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 38 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 39 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 40 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 41 | /* Module Resolution Options */ 42 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 43 | "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 44 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 45 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 46 | // "typeRoots": [], /* List of folders to include type definitions from. */ 47 | // "types": [], /* Type declaration files to be included in compilation. */ 48 | "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 49 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 50 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 51 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 52 | /* Source Map Options */ 53 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 54 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 55 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 56 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 57 | /* Experimental Options */ 58 | "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 59 | "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 60 | /* Advanced Options */ 61 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 62 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 63 | } 64 | } --------------------------------------------------------------------------------