diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..40b6499 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +DATABASE_URL="mysql://root:root@localhost:3306/bunapi" +APP_VERSION=0.1.0 +APP_ENV=production +APP_DEBUG=true +API_PORT=3030 +LOG_LEVEL=info +EXAMPLE_EMAIL=test@idscript.my.id diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5cad2cb..3c71725 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -13,11 +13,11 @@ name: "CodeQL Advanced" on: push: - branches: [ "main" ] + branches: ["main"] pull_request: - branches: [ "main" ] + branches: ["main"] schedule: - - cron: '40 22 * * 5' + - cron: "40 22 * * 5" jobs: analyze: @@ -43,8 +43,8 @@ jobs: fail-fast: false matrix: include: - - language: javascript-typescript - build-mode: none + - language: javascript-typescript + build-mode: none # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' # Use `c-cpp` to analyze code written in C, C++ or both # Use 'java-kotlin' to analyze code written in Java, Kotlin or both @@ -54,39 +54,39 @@ jobs: # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - - name: Checkout repository - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v4 - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v3 - with: - languages: ${{ matrix.language }} - build-mode: ${{ matrix.build-mode }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality - # If the analyze step fails for one of the languages you are analyzing with - # "We were unable to automatically build your code", modify the matrix above - # to set the build mode to "manual" for that language. Then modify this step - # to build your code. - # โ„น๏ธ Command-line programs to run using the OS shell. - # ๐Ÿ“š See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - if: matrix.build-mode == 'manual' - shell: bash - run: | - echo 'If you are using a "manual" build mode for one or more of the' \ - 'languages you are analyzing, replace this with the commands to build' \ - 'your code, for example:' - echo ' make bootstrap' - echo ' make release' - exit 1 + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # โ„น๏ธ Command-line programs to run using the OS shell. + # ๐Ÿ“š See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 - with: - category: "/language:${{matrix.language}}" + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index d19e21b..e71af6d 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -7,10 +7,10 @@ # # Source repository: https://github.com/actions/dependency-review-action # Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement -name: 'Dependency review' +name: "Dependency review" on: pull_request: - branches: [ "main" ] + branches: ["main"] # If using a dependency submission action in this workflow this permission will need to be set to: # @@ -27,9 +27,9 @@ jobs: dependency-review: runs-on: ubuntu-latest steps: - - name: 'Checkout repository' + - name: "Checkout repository" uses: actions/checkout@v4 - - name: 'Dependency Review' + - name: "Dependency Review" uses: actions/dependency-review-action@v4 # Commonly enabled options, see https://github.com/actions/dependency-review-action#configuration-options for all available options. with: diff --git a/.github/workflows/github-ci.yaml b/.github/workflows/github-ci.yaml new file mode 100644 index 0000000..0c05f5c --- /dev/null +++ b/.github/workflows/github-ci.yaml @@ -0,0 +1,74 @@ +name: Unit Test ๐Ÿงช + +on: + push: + branches: ["main", "stg"] + pull_request: + branches: ["main", "stg"] + +jobs: + Format_and_Check: + runs-on: ubuntu-latest + strategy: + matrix: + bun-version: [canary, 1.0.0, 1.0.36, 1.1.0, 1.1.16, 1.1.42] + name: Bun ${{ matrix.bun-version }} sample + steps: + - uses: actions/checkout@v4.2.2 + - name: Use Bun ${{ matrix.bun-version }} + uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ matrix.bun-version }} + - name: Install dependencies + run: bun i + - name: Format and Check code + run: bun run fc + Jest_Test: + runs-on: ubuntu-latest + needs: Format_and_Check + strategy: + matrix: + bun-version: [canary, 1.0.0, 1.0.36, 1.1.0, 1.1.16, 1.1.42] + name: Test with Jest Bun ${{ matrix.bun-version }} + steps: + - name: Start MySQL + run: | + sudo /etc/init.d/mysql start + mysql -e "CREATE DATABASE IF NOT EXISTS bunapi;" -uroot -proot + - uses: actions/checkout@v4.2.2 + - name: Use Bun ${{ matrix.bun-version }} + uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ matrix.bun-version }} + - name: create .env + run: mv .env.example .env + - name: Install dependencies + run: bun i + - name: Migration + run: bun run mig + - name: Test + run: bun test + Create_Test_Coverage_Badges: + runs-on: ubuntu-latest + needs: Format_and_Check + name: Test with Jest Bun Latest + steps: + - name: Start MySQL + run: | + sudo /etc/init.d/mysql start + mysql -e "CREATE DATABASE IF NOT EXISTS bunapi;" -uroot -proot + - uses: actions/checkout@v4.2.2 + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + - name: create .env + run: mv .env.example .env + - name: Install dependencies + run: bun i + - name: Migration + run: bun run mig + - name: Test + run: bun test + - name: Generating coverage badges + uses: jpb06/jest-badges-action@v1.9.18 + with: + branches: main, stg diff --git a/.gitignore b/.gitignore index 506e4c3..6a7d6d8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,130 @@ -# deps +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/BunAPI.iml b/.idea/BunAPI.iml new file mode 100644 index 0000000..24643cc --- /dev/null +++ b/.idea/BunAPI.iml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/bun.xml b/.idea/bun.xml new file mode 100644 index 0000000..56b40f0 --- /dev/null +++ b/.idea/bun.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..32d1432 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,62 @@ + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..03d9549 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/jsLinters/eslint.xml b/.idea/jsLinters/eslint.xml new file mode 100644 index 0000000..541945b --- /dev/null +++ b/.idea/jsLinters/eslint.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml new file mode 100644 index 0000000..bf2d968 --- /dev/null +++ b/.idea/material_theme_project_new.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..08ab724 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/prettier.xml b/.idea/prettier.xml new file mode 100644 index 0000000..0c83ac4 --- /dev/null +++ b/.idea/prettier.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..2a7164d --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +pnpm-lock.yaml +package-lock.json \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..a120c29 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,9 @@ +{ + "semi": true, + "tabWidth": 2, + "useTabs": true, + "endOfLine": "lf", + "singleQuote": false, + "trailingComma": "es5", + "singleAttributePerLine": true +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1 @@ +{} diff --git a/README.md b/README.md index 6dd13e7..d950ba6 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@ To install dependencies: + ```sh bun install ``` To run: + ```sh bun run dev ``` diff --git a/bun.lockb b/bun.lockb index bd8f250..88026bb 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..a1dca93 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,27 @@ +import globals from "globals"; +import pluginJs from "@eslint/js"; +import tseslint from "typescript-eslint"; + +/** @type {import('eslint').Linter.Config[]} */ +export default [ + { ignores: ["**/modules/index.js", "**/src-old/*.{js,mjs,cjs,ts}"] }, + { files: ["**/src/*.{js,mjs,cjs,ts}"] }, + { + languageOptions: { globals: globals.node }, + rules: { + "no-unused-vars": "warn", + "@typescript-eslint/no-unused-vars": "warn", + "no-console": [ + "error", + { + allow: ["warn", "error"], + }, + ], + semi: [2, "always"], + indent: ["warn", "tab", { SwitchCase: 1 }], + quotes: ["warn", "double"], + }, + }, + pluginJs.configs.recommended, + ...tseslint.configs.recommended, +]; diff --git a/package.json b/package.json index aa4378f..21f18e5 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,29 @@ { - "name": "BunAPI", - "scripts": { - "dev": "bun run --hot src/index.ts" - }, - "dependencies": { - "hono": "^4.6.15" - }, - "devDependencies": { - "@types/bun": "latest" - } -} \ No newline at end of file + "name": "bunapi", + "description": "Restful API by IDScript", + "version": "0.1.0", + "type": "module", + "main": "src/index.ts", + "module": "src/index.ts", + "scripts": { + "dev": "bun run --hot src/index.ts", + "fc": "bunx prettier --write . && bunx eslint --fix . && bunx prettier --check . && bunx eslint .", + "mig": "rm -rf prisma/migrations && bunx prisma generate && bunx prisma migrate dev --name init" + }, + "dependencies": { + "@hono/node-server": "^1.13.7", + "@prisma/client": "^6.1.0", + "hono": "^4.6.15", + "winston": "^3.17.0", + "zod": "^3.24.1" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@types/bun": "latest", + "eslint": "^9.17.0", + "globals": "^15.14.0", + "prettier": "^3.4.2", + "prisma": "^6.1.0", + "typescript-eslint": "^8.19.0" + } +} diff --git a/prisma/migrations/20250104234059_init/migration.sql b/prisma/migrations/20250104234059_init/migration.sql new file mode 100644 index 0000000..b09df59 --- /dev/null +++ b/prisma/migrations/20250104234059_init/migration.sql @@ -0,0 +1,42 @@ +-- CreateTable +CREATE TABLE `addresses` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `street` VARCHAR(255) NULL, + `city` VARCHAR(100) NULL, + `province` VARCHAR(100) NULL, + `country` VARCHAR(100) NOT NULL, + `postal_code` VARCHAR(10) NOT NULL, + `contact_id` INTEGER NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `contacts` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `first_name` VARCHAR(100) NOT NULL, + `last_name` VARCHAR(100) NULL, + `email` VARCHAR(100) NULL, + `phone` VARCHAR(20) NULL, + `username` VARCHAR(100) NOT NULL, + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `users` ( + `email` VARCHAR(191) NOT NULL, + `username` VARCHAR(100) NOT NULL, + `password` VARCHAR(100) NOT NULL, + `name` VARCHAR(100) NOT NULL, + `token` VARCHAR(100) NULL, + + UNIQUE INDEX `users_email_key`(`email`), + PRIMARY KEY (`username`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `addresses` ADD CONSTRAINT `addresses_contact_id_fkey` FOREIGN KEY (`contact_id`) REFERENCES `contacts`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `contacts` ADD CONSTRAINT `contacts_username_fkey` FOREIGN KEY (`username`) REFERENCES `users`(`username`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..8a21669 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "mysql" \ No newline at end of file diff --git a/prisma/schema/address.prisma b/prisma/schema/address.prisma new file mode 100644 index 0000000..1a7b35e --- /dev/null +++ b/prisma/schema/address.prisma @@ -0,0 +1,14 @@ +model Address { + id Int @id @default(autoincrement()) + street String? @db.VarChar(255) + city String? @db.VarChar(100) + province String? @db.VarChar(100) + country String @db.VarChar(100) + postal_code String @db.VarChar(10) + + contact_id Int + + contact Contact @relation(fields: [contact_id], references: [id]) + + @@map("addresses") +} diff --git a/prisma/schema/contact.prisma b/prisma/schema/contact.prisma new file mode 100644 index 0000000..39dfb12 --- /dev/null +++ b/prisma/schema/contact.prisma @@ -0,0 +1,14 @@ +model Contact { + id Int @id @default(autoincrement()) + first_name String @db.VarChar(100) + last_name String? @db.VarChar(100) + email String? @db.VarChar(100) + phone String? @db.VarChar(20) + + username String @db.VarChar(100) + + user User @relation(fields: [username], references: [username]) + addresses Address[] + + @@map("contacts") +} diff --git a/prisma/schema/schema.prisma b/prisma/schema/schema.prisma new file mode 100644 index 0000000..f888783 --- /dev/null +++ b/prisma/schema/schema.prisma @@ -0,0 +1,9 @@ +datasource db { + provider = "mysql" + url = env("DATABASE_URL") +} + +generator client { + provider = "prisma-client-js" + previewFeatures = ["prismaSchemaFolder"] +} diff --git a/prisma/schema/user.prisma b/prisma/schema/user.prisma new file mode 100644 index 0000000..431f1a2 --- /dev/null +++ b/prisma/schema/user.prisma @@ -0,0 +1,11 @@ +model User { + email String @unique + username String @id @db.VarChar(100) + password String @db.VarChar(100) + name String @db.VarChar(100) + token String? @db.VarChar(100) + + contacts Contact[] + + @@map("users") +} diff --git a/src/config/database.ts b/src/config/database.ts new file mode 100644 index 0000000..72bebe3 --- /dev/null +++ b/src/config/database.ts @@ -0,0 +1,37 @@ +import { log } from "./logger"; +import { Prisma, PrismaClient } from "@prisma/client"; + +export const prismaClient = new PrismaClient({ + log: [ + { + emit: "event", + level: "query", + }, + { + emit: "event", + level: "error", + }, + { + emit: "event", + level: "info", + }, + { + emit: "event", + level: "warn", + }, + ], +}); +prismaClient.$on("error", (e: Prisma.LogEvent): void => { + log.error(e); +}); +prismaClient.$on("warn", (e: Prisma.LogEvent): void => { + log.warn(e); +}); +prismaClient.$on("info", (e: Prisma.LogEvent): void => { + log.info(e); +}); +prismaClient.$on("query", (e: Prisma.QueryEvent): void => { + log.verbose(`Query: ${e.query}`); + log.verbose(`Params: ${e.params}`); + log.verbose(`Duration: ${e.duration} ms`); +}); diff --git a/src/config/logger.ts b/src/config/logger.ts new file mode 100644 index 0000000..1bc8b55 --- /dev/null +++ b/src/config/logger.ts @@ -0,0 +1,57 @@ +import { createLogger, format, transports } from "winston"; + +const { combine, timestamp, printf, colorize } = format; + +const logsFormat = printf(({ level, message, timestamp }) => { + return `${timestamp}|[${level.toUpperCase()}]|${message}|`; +}); + +const logLevel: string = Bun.env.LOG_LEVEL ?? "warn"; +const todayDate: string = new Date().toISOString().slice(0, 10); +const logFolder: string = `./logs/${todayDate.replace(/-/g, "")}/${todayDate}`; + +const myTransports: ( + | transports.ConsoleTransportInstance + | transports.FileTransportInstance +)[] = [ + new transports.File({ + filename: `${logFolder}-error.log`, + level: "error", + }), + new transports.File({ + filename: `${logFolder}-warn.log`, + level: "warn", + }), +]; + +if (logLevel == "warn") { + myTransports.push( + new transports.Console({ + level: "warn", + }) + ); +} else if (logLevel == "info") { + myTransports.push( + new transports.Console({ + level: "info", + }) + ); +} else { + myTransports.push( + new transports.Console({ + level: "error", + }) + ); +} + +export const log = createLogger({ + format: combine( + timestamp({ format: "YYYY-MM-DD HH:mm:ss.SSS" }), + logsFormat, + colorize() + ), + transports: myTransports, + rejectionHandlers: [ + new transports.File({ filename: `${logFolder}-rejections.log` }), + ], +}); diff --git a/src/controller/address-controller.ts b/src/controller/address-controller.ts new file mode 100644 index 0000000..25a7e64 --- /dev/null +++ b/src/controller/address-controller.ts @@ -0,0 +1,85 @@ +import { Hono } from "hono"; +import type { ApplicationVariables } from "../model/app-model"; +import { authMiddleware } from "../middleware/auth-middleware"; +import type { User } from "@prisma/client"; +import type { + CreateAddressRequest, + GetAddressRequest, + ListAddressRequest, + RemoveAddressRequest, + UpdateAddressRequest, +} from "../model/address-model"; +import { AddressService } from "../service/address-service"; + +export const addressController = new Hono<{ + Variables: ApplicationVariables; +}>(); +addressController.use(authMiddleware); + +addressController.post("/contacts/:id/addresses", async (c) => { + const user = c.get("user") as User; + const contactId = Number(c.req.param("id")); + const request = (await c.req.json()) as CreateAddressRequest; + request.contact_id = contactId; + const response = await AddressService.create(user, request); + return c.json({ + data: response, + }); +}); + +addressController.get( + "/contacts/:contact_id/addresses/:address_id", + async (c) => { + const user = c.get("user") as User; + const request: GetAddressRequest = { + contact_id: Number(c.req.param("contact_id")), + id: Number(c.req.param("address_id")), + }; + const response = await AddressService.get(user, request); + return c.json({ + data: response, + }); + } +); + +addressController.put( + "/contacts/:contact_id/addresses/:address_id", + async (c) => { + const user = c.get("user") as User; + const contactId = Number(c.req.param("contact_id")); + const addressId = Number(c.req.param("address_id")); + const request = (await c.req.json()) as UpdateAddressRequest; + request.contact_id = contactId; + request.id = addressId; + const response = await AddressService.update(user, request); + return c.json({ + data: response, + }); + } +); + +addressController.delete( + "/contacts/:contact_id/addresses/:address_id", + async (c) => { + const user = c.get("user") as User; + const request: RemoveAddressRequest = { + id: Number(c.req.param("address_id")), + contact_id: Number(c.req.param("contact_id")), + }; + const response = await AddressService.remove(user, request); + return c.json({ + data: response, + }); + } +); + +addressController.get("/contacts/:contact_id/addresses", async (c) => { + const user = c.get("user") as User; + const request: ListAddressRequest = { + contact_id: Number(c.req.param("contact_id")), + }; + const response = await AddressService.list(user, request); + return c.json({ + data: response, + }); +}); diff --git a/src/controller/contact-controller.ts b/src/controller/contact-controller.ts new file mode 100644 index 0000000..f4cf2e5 --- /dev/null +++ b/src/controller/contact-controller.ts @@ -0,0 +1,70 @@ +import { Hono } from "hono"; +import type { ApplicationVariables } from "../model/app-model"; +import { authMiddleware } from "../middleware/auth-middleware"; +import type { User } from "@prisma/client"; +import { ContactService } from "../service/contact-service"; +import type { + CreateContactRequest, + SearchContactRequest, + UpdateContactRequest, +} from "../model/contact-model"; + +export const contactController = new Hono<{ + Variables: ApplicationVariables; +}>(); +contactController.use(authMiddleware); + +contactController.post("/contacts", async (c) => { + const user = c.get("user") as User; + const request = (await c.req.json()) as CreateContactRequest; + const response = await ContactService.create(user, request); + + return c.json({ + data: response, + }); +}); + +contactController.get("/contacts/:id", async (c) => { + const user = c.get("user") as User; + const contactId = Number(c.req.param("id")); + const response = await ContactService.get(user, contactId); + + return c.json({ + data: response, + }); +}); + +contactController.put("/contacts/:id", async (c) => { + const user = c.get("user") as User; + const contactId = Number(c.req.param("id")); + const request = (await c.req.json()) as UpdateContactRequest; + request.id = contactId; + const response = await ContactService.update(user, request); + + return c.json({ + data: response, + }); +}); + +contactController.delete("/contacts/:id", async (c) => { + const user = c.get("user") as User; + const contactId = Number(c.req.param("id")); + const response = await ContactService.delete(user, contactId); + + return c.json({ + data: response, + }); +}); + +contactController.get("/contacts", async (c) => { + const user = c.get("user") as User; + const request: SearchContactRequest = { + name: c.req.query("name"), + email: c.req.query("email"), + phone: c.req.query("phone"), + page: c.req.query("page") ? Number(c.req.query("page")) : 1, + size: c.req.query("size") ? Number(c.req.query("size")) : 10, + }; + const response = await ContactService.search(user, request); + return c.json(response); +}); diff --git a/src/controller/user-controller.ts b/src/controller/user-controller.ts new file mode 100644 index 0000000..6d93d23 --- /dev/null +++ b/src/controller/user-controller.ts @@ -0,0 +1,77 @@ +import { Hono } from "hono"; +import { + type LoginUserRequest, + type RegisterUserRequest, + toUserResponse, + type UpdateUserRequest, +} from "../model/user-model"; +import { UserService } from "../service/user-service"; +import type { ApplicationVariables } from "../model/app-model"; +import type { User } from "@prisma/client"; +import { authMiddleware } from "../middleware/auth-middleware"; +import { log } from "../config/logger"; +import { HTTPException } from "hono/http-exception"; + +export const userController = new Hono<{ Variables: ApplicationVariables }>(); + +userController.post("/users", async (c) => { + const text = await c.req.text(); + let request: RegisterUserRequest; + + try { + request = JSON.parse(text) as RegisterUserRequest; + } catch { + log.error("Invalid json request " + text); + throw new HTTPException(400, { + message: "invalid json", + }); + } + + const response = await UserService.register(request); + log.info("Registering user", response); + + return c.json({ + data: response, + }); +}); + +userController.post("/users/login", async (c) => { + const request = (await c.req.json()) as LoginUserRequest; + + const response = await UserService.login(request); + + return c.json({ + data: response, + }); +}); + +userController.use(authMiddleware); + +userController.get("/users/current", async (c) => { + const user = c.get("user") as User; + + return c.json({ + data: toUserResponse(user), + }); +}); + +userController.patch("/users/current", async (c) => { + const user = c.get("user") as User; + const request = (await c.req.json()) as UpdateUserRequest; + + const response = await UserService.update(user, request); + + return c.json({ + data: response, + }); +}); + +userController.delete("/users/current", async (c) => { + const user = c.get("user") as User; + + const response = await UserService.logout(user); + + return c.json({ + data: response, + }); +}); diff --git a/src/index.ts b/src/index.ts index 3191383..1b30acb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,80 @@ -import { Hono } from 'hono' +import { Hono } from "hono"; +import { ZodError } from "zod"; +import pkg from "../package.json"; +import { log } from "./config/logger"; +import { prismaClient } from "./config/database"; +import { HTTPException } from "hono/http-exception"; +import { serve, type ServerType } from "@hono/node-server"; +import { userController } from "./controller/user-controller"; +import { contactController } from "./controller/contact-controller"; +import { addressController } from "./controller/address-controller"; -const app = new Hono() +const port: number = Number(Bun.env.API_PORT ?? 3030); -app.get('/', (c) => { - return c.text('Hello Hono!') -}) +export const app = new Hono().basePath("/api"); -export default app +app.get("/", (c) => { + return c.json( + { + message: pkg.description ?? "KAnggara Web APP", + version: Bun.env.APP_VERSION ?? pkg.version ?? "0.0.1", + stability: Bun.env.APP_STAB ?? "Developer-Preview", + }, + 200 + ); +}); + +app.route("/", userController); +app.route("/", contactController); +app.route("/", addressController); + +app.notFound((c) => { + log.info(`Not Found: ${c.req.url}`); + return c.json({ errors: "Not Found" }, 404); +}); + +app.onError(async (err, c) => { + if (err instanceof HTTPException) { + return c.json({ errors: err.message }, err.status); + } else if (err instanceof ZodError) { + return c.json({ errors: JSON.parse(err.message) }, 400); + } else { + return c.json({ errors: err.message }, 500); + } +}); + +const server: ServerType = serve({ + port: port, + fetch: app.fetch, +}); + +const events = ["uncaughtException", "SIGINT", "SIGTERM"]; + +events.forEach((eventName) => { + process.on(eventName, (...args) => { + gracefulShutdown(); + log.info(`${eventName} was called with args : ${args.join(",")}`); + log.info(`${eventName} signal received: closing HTTP server`); + }); +}); + +async function gracefulShutdown(): Promise { + log.info("Shutting down gracefully..."); + await prismaClient.$disconnect(); + + server.close(() => { + log.info("HTTP server closed"); + // Close any other connections or resources here + process.exit(0); + }); + + // Force close the server after 5 seconds + setTimeout(() => { + console.error( + "Could not close connections in time, forcefully shutting down" + ); + process.exit(1); + }, 5000); +} + +export default server; diff --git a/src/middleware/auth-middleware.ts b/src/middleware/auth-middleware.ts new file mode 100644 index 0000000..f878296 --- /dev/null +++ b/src/middleware/auth-middleware.ts @@ -0,0 +1,11 @@ +import type { MiddlewareHandler } from "hono"; +import { UserService } from "../service/user-service"; + +export const authMiddleware: MiddlewareHandler = async (c, next) => { + const token = c.req.header("Authorization"); + const user = await UserService.get(token); + + c.set("user", user); + + await next(); +}; diff --git a/src/model/address-model.ts b/src/model/address-model.ts new file mode 100644 index 0000000..5c43636 --- /dev/null +++ b/src/model/address-model.ts @@ -0,0 +1,54 @@ +import type { Address } from "@prisma/client"; + +export type CreateAddressRequest = { + contact_id: number; + street?: string; + city?: string; + province?: string; + country: string; + postal_code: string; +}; + +export type AddressResponse = { + id: number; + street?: string | null; + city?: string | null; + province?: string | null; + country: string; + postal_code: string; +}; + +export type GetAddressRequest = { + contact_id: number; + id: number; +}; + +export type UpdateAddressRequest = { + id: number; + contact_id: number; + street?: string; + city?: string; + province?: string; + country: string; + postal_code: string; +}; + +export type RemoveAddressRequest = { + contact_id: number; + id: number; +}; + +export type ListAddressRequest = { + contact_id: number; +}; + +export function toAddressResponse(address: Address): AddressResponse { + return { + id: address.id, + street: address.street, + city: address.city, + province: address.province, + country: address.country, + postal_code: address.postal_code, + }; +} diff --git a/src/model/app-model.ts b/src/model/app-model.ts new file mode 100644 index 0000000..ae0f110 --- /dev/null +++ b/src/model/app-model.ts @@ -0,0 +1,5 @@ +import type { User } from "@prisma/client"; + +export type ApplicationVariables = { + user: User; +}; diff --git a/src/model/contact-model.ts b/src/model/contact-model.ts new file mode 100644 index 0000000..8ca6973 --- /dev/null +++ b/src/model/contact-model.ts @@ -0,0 +1,42 @@ +import type { Contact } from "@prisma/client"; + +export type CreateContactRequest = { + first_name: string; + last_name?: string; + email?: string; + phone?: string; +}; + +export type ContactResponse = { + id: number; + first_name: string; + last_name?: string | null; + email?: string | null; + phone?: string | null; +}; + +export type UpdateContactRequest = { + id: number; + first_name: string; + last_name?: string; + email?: string; + phone?: string; +}; + +export type SearchContactRequest = { + name?: string; + phone?: string; + email?: string; + page: number; + size: number; +}; + +export function toContactResponse(contact: Contact): ContactResponse { + return { + id: contact.id, + first_name: contact.first_name, + last_name: contact.last_name, + email: contact.email, + phone: contact.phone, + }; +} diff --git a/src/model/page-model.ts b/src/model/page-model.ts new file mode 100644 index 0000000..133d95b --- /dev/null +++ b/src/model/page-model.ts @@ -0,0 +1,10 @@ +export type Paging = { + current_page: number; + total_page: number; + size: number; +}; + +export type Pageable = { + data: Array; + paging: Paging; +}; diff --git a/src/model/user-model.ts b/src/model/user-model.ts new file mode 100644 index 0000000..2b8e170 --- /dev/null +++ b/src/model/user-model.ts @@ -0,0 +1,33 @@ +import type { User } from "@prisma/client"; + +export type RegisterUserRequest = { + email: string; + username: string; + password: string; + name: string; +}; + +export type LoginUserRequest = { + username: string; + password: string; +}; + +export type UpdateUserRequest = { + password?: string; + name?: string; +}; + +export type UserResponse = { + email: string; + username: string; + name: string; + token?: string; +}; + +export function toUserResponse(user: User): UserResponse { + return { + email: user.email, + name: user.name, + username: user.username, + }; +} diff --git a/src/service/address-service.ts b/src/service/address-service.ts new file mode 100644 index 0000000..da9bb18 --- /dev/null +++ b/src/service/address-service.ts @@ -0,0 +1,116 @@ +import type { Address, User } from "@prisma/client"; +import { + toAddressResponse, + type AddressResponse, + type CreateAddressRequest, + type GetAddressRequest, + type ListAddressRequest, + type RemoveAddressRequest, + type UpdateAddressRequest, +} from "../model/address-model"; +import { AddressValidation } from "../validation/address-validation"; +import { ContactService } from "./contact-service"; +import { prismaClient } from "../config/database"; +import { HTTPException } from "hono/http-exception"; + +export class AddressService { + static async create( + user: User, + request: CreateAddressRequest + ): Promise { + request = AddressValidation.CREATE.parse(request); + await ContactService.contactMustExists(user, request.contact_id); + + const address = await prismaClient.address.create({ + data: request, + }); + + return toAddressResponse(address); + } + + static async get( + user: User, + request: GetAddressRequest + ): Promise { + request = AddressValidation.GET.parse(request); + await ContactService.contactMustExists(user, request.contact_id); + const address = await this.addressMustExists( + request.contact_id, + request.id + ); + + return toAddressResponse(address); + } + + static async addressMustExists( + contactId: number, + addressId: number + ): Promise
{ + const address = await prismaClient.address.findFirst({ + where: { + contact_id: contactId, + id: addressId, + }, + }); + + if (!address) { + throw new HTTPException(404, { + message: "Address is not found", + }); + } + return address; + } + + static async update( + user: User, + request: UpdateAddressRequest + ): Promise { + request = AddressValidation.UPDATE.parse(request); + await ContactService.contactMustExists(user, request.contact_id); + await this.addressMustExists(request.contact_id, request.id); + + const address = await prismaClient.address.update({ + where: { + id: request.id, + contact_id: request.contact_id, + }, + data: request, + }); + + return toAddressResponse(address); + } + + static async remove( + user: User, + request: RemoveAddressRequest + ): Promise { + request = AddressValidation.REMOVE.parse(request); + await ContactService.contactMustExists(user, request.contact_id); + await this.addressMustExists(request.contact_id, request.id); + + await prismaClient.address.delete({ + where: { + id: request.id, + contact_id: request.contact_id, + }, + }); + + return true; + } + + static async list( + user: User, + request: ListAddressRequest + ): Promise> { + request = AddressValidation.LIST.parse(request); + await ContactService.contactMustExists(user, request.contact_id); + + const addresses = await prismaClient.address.findMany({ + where: { + contact_id: request.contact_id, + }, + }); + + return addresses.map((address) => toAddressResponse(address)); + } +} diff --git a/src/service/contact-service.ts b/src/service/contact-service.ts new file mode 100644 index 0000000..30a13c7 --- /dev/null +++ b/src/service/contact-service.ts @@ -0,0 +1,158 @@ +import type { Contact, User } from "@prisma/client"; +import type { + ContactResponse, + CreateContactRequest, + SearchContactRequest, + UpdateContactRequest, +} from "../model/contact-model"; +import { toContactResponse } from "../model/contact-model"; +import { ContactValidation } from "../validation/contact-validation"; +import { prismaClient } from "../config/database"; +import { HTTPException } from "hono/http-exception"; +import type { Pageable } from "../model/page-model"; + +export class ContactService { + static async create( + user: User, + request: CreateContactRequest + ): Promise { + request = ContactValidation.CREATE.parse(request); + + const data = { + ...request, + ...{ username: user.username }, + }; + + const contact = await prismaClient.contact.create({ + data: data, + }); + + return toContactResponse(contact); + } + + static async get(user: User, contactId: number): Promise { + contactId = ContactValidation.GET.parse(contactId); + const contact = await this.contactMustExists(user, contactId); + return toContactResponse(contact); + } + + static async contactMustExists( + user: User, + contactId: number + ): Promise { + const contact = await prismaClient.contact.findFirst({ + where: { + id: contactId, + username: user.username, + }, + }); + + if (!contact) { + throw new HTTPException(404, { + message: "Contact is not found", + }); + } + + return contact; + } + + static async update( + user: User, + request: UpdateContactRequest + ): Promise { + request = ContactValidation.UPDATE.parse(request); + await this.contactMustExists(user, request.id); + + const contact = await prismaClient.contact.update({ + where: { + username: user.username, + id: request.id, + }, + data: request, + }); + + return toContactResponse(contact); + } + + static async delete(user: User, contactId: number): Promise { + contactId = ContactValidation.DELETE.parse(contactId); + await this.contactMustExists(user, contactId); + + await prismaClient.contact.delete({ + where: { + username: user.username, + id: contactId, + }, + }); + + return true; + } + + static async search( + user: User, + request: SearchContactRequest + ): Promise> { + request = ContactValidation.SEARCH.parse(request); + + const filters = []; + if (request.name) { + filters.push({ + OR: [ + { + first_name: { + contains: request.name, + }, + }, + { + last_name: { + contains: request.name, + }, + }, + ], + }); + } + + if (request.email) { + filters.push({ + email: { + contains: request.email, + }, + }); + } + + if (request.phone) { + filters.push({ + phone: { + contains: request.phone, + }, + }); + } + + const skip = (request.page - 1) * request.size; + + const contacts = await prismaClient.contact.findMany({ + where: { + username: user.username, + AND: filters, + }, + take: request.size, + skip: skip, + }); + + const total = await prismaClient.contact.count({ + where: { + username: user.username, + AND: filters, + }, + }); + + return { + data: contacts.map((contact) => toContactResponse(contact)), + paging: { + current_page: request.page, + size: request.size, + total_page: Math.ceil(total / request.size), + }, + }; + } +} diff --git a/src/service/user-service.ts b/src/service/user-service.ts new file mode 100644 index 0000000..fd3021d --- /dev/null +++ b/src/service/user-service.ts @@ -0,0 +1,149 @@ +import { + type LoginUserRequest, + type RegisterUserRequest, + toUserResponse, + type UpdateUserRequest, + type UserResponse, +} from "../model/user-model"; +import { UserValidation } from "../validation/user-validation"; +import { prismaClient } from "../config/database"; +import { HTTPException } from "hono/http-exception"; +import type { User } from "@prisma/client"; +import { log } from "../config/logger"; + +export class UserService { + static async register(request: RegisterUserRequest): Promise { + log.info("Registering user " + JSON.stringify(request)); + + request = UserValidation.REGISTER.parse(request); + + const totalUserWithSameUsername = await prismaClient.user.count({ + where: { + username: request.username, + }, + }); + + if (totalUserWithSameUsername != 0) { + throw new HTTPException(400, { + message: "Username already exists", + }); + } + + request.password = await Bun.password.hash(request.password, { + algorithm: "bcrypt", + cost: 10, + }); + + const user = await prismaClient.user.create({ + data: request, + }); + + return toUserResponse(user); + } + + static async login(request: LoginUserRequest): Promise { + request = UserValidation.LOGIN.parse(request); + + let user = await prismaClient.user.findUnique({ + where: { + username: request.username, + }, + }); + + if (!user) { + throw new HTTPException(401, { + message: "Username or Password is wrong", + }); + } + + const isPasswordValid = await Bun.password.verify( + request.password, + user.password, + "bcrypt" + ); + if (!isPasswordValid) { + throw new HTTPException(401, { + message: "Username or password is wrong", + }); + } + + user = await prismaClient.user.update({ + where: { + username: request.username, + }, + data: { + token: crypto.randomUUID(), + }, + }); + + const response = toUserResponse(user); + response.token = user.token!; + return response; + } + + static async get(token: string | undefined | null): Promise { + const result = UserValidation.TOKEN.safeParse(token); + + if (result.error) { + throw new HTTPException(401, { + message: "Unauthorized", + }); + } + + token = result.data; + + const user = await prismaClient.user.findFirst({ + where: { + token: token, + }, + }); + + if (!user) { + throw new HTTPException(401, { + message: "Unauthorized", + }); + } + + return user; + } + + static async update( + user: User, + request: UpdateUserRequest + ): Promise { + request = UserValidation.UPDATE.parse(request); + + if (request.name) { + user.name = request.name; + } + + if (request.password) { + user.password = await Bun.password.hash(request.password, { + algorithm: "bcrypt", + cost: 10, + }); + } + + user = await prismaClient.user.update({ + where: { + username: user.username, + }, + data: user, + }); + + return toUserResponse(user); + } + + static async logout(user: User): Promise { + await prismaClient.user.update({ + where: { + username: user.username, + }, + data: { + token: null, + }, + }); + + return true; + } +} diff --git a/src/validation/address-validation.ts b/src/validation/address-validation.ts new file mode 100644 index 0000000..13fd325 --- /dev/null +++ b/src/validation/address-validation.ts @@ -0,0 +1,36 @@ +import { z, ZodType } from "zod"; + +export class AddressValidation { + static readonly CREATE: ZodType = z.object({ + contact_id: z.number().positive(), + street: z.string().min(1).max(255).optional(), + city: z.string().min(1).max(100).optional(), + province: z.string().min(1).max(100).optional(), + country: z.string().min(1).max(100), + postal_code: z.string().min(1).max(10), + }); + + static readonly GET: ZodType = z.object({ + contact_id: z.number().positive(), + id: z.number().positive(), + }); + + static readonly UPDATE: ZodType = z.object({ + id: z.number().positive(), + contact_id: z.number().positive(), + street: z.string().min(1).max(255).optional(), + city: z.string().min(1).max(100).optional(), + province: z.string().min(1).max(100).optional(), + country: z.string().min(1).max(100), + postal_code: z.string().min(1).max(10), + }); + + static readonly REMOVE: ZodType = z.object({ + contact_id: z.number().positive(), + id: z.number().positive(), + }); + + static readonly LIST: ZodType = z.object({ + contact_id: z.number().positive(), + }); +} diff --git a/src/validation/contact-validation.ts b/src/validation/contact-validation.ts new file mode 100644 index 0000000..6a7ede8 --- /dev/null +++ b/src/validation/contact-validation.ts @@ -0,0 +1,30 @@ +import { z, ZodType } from "zod"; + +export class ContactValidation { + static readonly CREATE: ZodType = z.object({ + first_name: z.string().min(1).max(100), + last_name: z.string().min(1).max(100).optional(), + email: z.string().min(1).max(100).email().optional(), + phone: z.string().min(1).max(20).optional(), + }); + + static readonly GET: ZodType = z.number().positive(); + + static readonly UPDATE: ZodType = z.object({ + id: z.number().positive(), + first_name: z.string().min(1).max(100), + last_name: z.string().min(1).max(100).optional(), + email: z.string().min(1).max(100).email().optional(), + phone: z.string().min(1).max(20).optional(), + }); + + static readonly DELETE: ZodType = z.number().positive(); + + static readonly SEARCH: ZodType = z.object({ + name: z.string().min(1).optional(), + email: z.string().min(1).optional(), + phone: z.string().min(1).optional(), + page: z.number().min(1).positive(), + size: z.number().min(1).max(100).positive(), + }); +} diff --git a/src/validation/user-validation.ts b/src/validation/user-validation.ts new file mode 100644 index 0000000..bf464e6 --- /dev/null +++ b/src/validation/user-validation.ts @@ -0,0 +1,22 @@ +import { z, ZodType } from "zod"; + +export class UserValidation { + static readonly REGISTER: ZodType = z.object({ + username: z.string().min(1).max(100), + password: z.string().min(1).max(100), + email: z.string().email(), + name: z.string().min(1).max(100), + }); + + static readonly LOGIN: ZodType = z.object({ + username: z.string().min(1).max(100), + password: z.string().min(1).max(100), + }); + + static readonly TOKEN: ZodType = z.string().min(1); + + static readonly UPDATE: ZodType = z.object({ + password: z.string().min(1).max(100).optional(), + name: z.string().min(1).max(100).optional(), + }); +} diff --git a/test/address.test.ts b/test/address.test.ts new file mode 100644 index 0000000..7e9e7d3 --- /dev/null +++ b/test/address.test.ts @@ -0,0 +1,362 @@ +import { expect, describe, it, beforeEach, afterEach } from "bun:test"; +import { AddressTest, ContactTest, UserTest } from "./test-util"; +import { app } from "../src"; + +describe("POST /api/contacts/{id}/addresses", () => { + beforeEach(async () => { + await AddressTest.deleteAll(); + await ContactTest.deleteAll(); + await UserTest.delete(); + + await UserTest.create(); + await ContactTest.create(); + }); + + afterEach(async () => { + await AddressTest.deleteAll(); + await ContactTest.deleteAll(); + await UserTest.delete(); + }); + + it("should rejected if request is not valid", async () => { + const contact = await ContactTest.get(); + const response = await app.request( + "/api/contacts/" + contact.id + "/addresses", + { + method: "post", + headers: { + Authorization: "test", + }, + body: JSON.stringify({ + country: "", + postal_code: "", + }), + } + ); + + expect(response.status).toBe(400); + + const body = await response.json(); + expect(body.errors).toBeDefined(); + }); + + it("should rejected if contact is not found", async () => { + const contact = await ContactTest.get(); + const response = await app.request( + "/api/contacts/" + (contact.id + 1) + "/addresses", + { + method: "post", + headers: { + Authorization: "test", + }, + body: JSON.stringify({ + country: "Indonesia", + postal_code: "1213", + }), + } + ); + + expect(response.status).toBe(404); + + const body = await response.json(); + expect(body.errors).toBeDefined(); + }); + + it("should success if request is valid", async () => { + const contact = await ContactTest.get(); + const response = await app.request( + "/api/contacts/" + contact.id + "/addresses", + { + method: "post", + headers: { + Authorization: "test", + }, + body: JSON.stringify({ + street: "Jalan", + city: "Kota", + province: "Provinsi", + country: "Indonesia", + postal_code: "12345", + }), + } + ); + + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.data).toBeDefined(); + expect(body.data.id).toBeDefined(); + expect(body.data.street).toBe("Jalan"); + expect(body.data.city).toBe("Kota"); + expect(body.data.province).toBe("Provinsi"); + expect(body.data.country).toBe("Indonesia"); + expect(body.data.postal_code).toBe("12345"); + }); +}); + +describe("GET /api/contacts/{contactId}/addresses/{addressId}", () => { + beforeEach(async () => { + await AddressTest.deleteAll(); + await ContactTest.deleteAll(); + await UserTest.delete(); + + await UserTest.create(); + await ContactTest.create(); + await AddressTest.create(); + }); + + afterEach(async () => { + await AddressTest.deleteAll(); + await ContactTest.deleteAll(); + await UserTest.delete(); + }); + + it("should rejected if address is not found", async () => { + const contact = await ContactTest.get(); + const address = await AddressTest.get(); + const response = await app.request( + "/api/contacts/" + contact.id + "/addresses/" + (address.id + 1), + { + method: "get", + headers: { + Authorization: "test", + }, + } + ); + + expect(response.status).toBe(404); + const body = await response.json(); + expect(body.errors).toBeDefined(); + }); + + it("should success if address is exists", async () => { + const contact = await ContactTest.get(); + const address = await AddressTest.get(); + const response = await app.request( + "/api/contacts/" + contact.id + "/addresses/" + address.id, + { + method: "get", + headers: { + Authorization: "test", + }, + } + ); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.data).toBeDefined(); + expect(body.data.id).toBeDefined(); + expect(body.data.street).toBe(address.street); + expect(body.data.city).toBe(address.city); + expect(body.data.province).toBe(address.province); + expect(body.data.country).toBe(address.country); + expect(body.data.postal_code).toBe(address.postal_code); + }); +}); + +describe("PUT /api/contacts/{contactId}/addresses/{addressId}", () => { + beforeEach(async () => { + await AddressTest.deleteAll(); + await ContactTest.deleteAll(); + await UserTest.delete(); + + await UserTest.create(); + await ContactTest.create(); + await AddressTest.create(); + }); + + afterEach(async () => { + await AddressTest.deleteAll(); + await ContactTest.deleteAll(); + await UserTest.delete(); + }); + + it("should rejected if request is invalid", async () => { + const contact = await ContactTest.get(); + const address = await AddressTest.get(); + const response = await app.request( + "/api/contacts/" + contact.id + "/addresses/" + address.id, + { + method: "put", + headers: { + Authorization: "test", + }, + body: JSON.stringify({ + country: "", + postal_code: "", + }), + } + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.errors).toBeDefined(); + }); + + it("should rejected if address is not found", async () => { + const contact = await ContactTest.get(); + const address = await AddressTest.get(); + const response = await app.request( + "/api/contacts/" + contact.id + "/addresses/" + (address.id + 1), + { + method: "put", + headers: { + Authorization: "test", + }, + body: JSON.stringify({ + country: "Indonesia", + postal_code: "12345", + }), + } + ); + + expect(response.status).toBe(404); + const body = await response.json(); + expect(body.errors).toBeDefined(); + }); + + it("should success if request is valid", async () => { + const contact = await ContactTest.get(); + const address = await AddressTest.get(); + const response = await app.request( + "/api/contacts/" + contact.id + "/addresses/" + address.id, + { + method: "put", + headers: { + Authorization: "test", + }, + body: JSON.stringify({ + street: "A", + city: "B", + province: "C", + country: "Malaysia", + postal_code: "9999", + }), + } + ); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.data).toBeDefined(); + expect(body.data.id).toBe(address.id); + expect(body.data.street).toBe("A"); + expect(body.data.city).toBe("B"); + expect(body.data.province).toBe("C"); + expect(body.data.country).toBe("Malaysia"); + expect(body.data.postal_code).toBe("9999"); + }); +}); + +describe("DELETE /api/contacts/{contactId}/addresses/{addressId}", () => { + beforeEach(async () => { + await AddressTest.deleteAll(); + await ContactTest.deleteAll(); + await UserTest.delete(); + + await UserTest.create(); + await ContactTest.create(); + await AddressTest.create(); + }); + + afterEach(async () => { + await AddressTest.deleteAll(); + await ContactTest.deleteAll(); + await UserTest.delete(); + }); + + it("should rejected if address is not exists", async () => { + const contact = await ContactTest.get(); + const address = await AddressTest.get(); + const response = await app.request( + "/api/contacts/" + contact.id + "/addresses/" + (address.id + 1), + { + method: "delete", + headers: { + Authorization: "test", + }, + } + ); + + expect(response.status).toBe(404); + const body = await response.json(); + expect(body.errors).toBeDefined(); + }); + + it("should success if address is exists", async () => { + const contact = await ContactTest.get(); + const address = await AddressTest.get(); + const response = await app.request( + "/api/contacts/" + contact.id + "/addresses/" + address.id, + { + method: "delete", + headers: { + Authorization: "test", + }, + } + ); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.data).toBeTrue(); + }); +}); + +describe("DELETE /api/contacts/{contactId}/addresses/{addressId}", () => { + beforeEach(async () => { + await AddressTest.deleteAll(); + await ContactTest.deleteAll(); + await UserTest.delete(); + + await UserTest.create(); + await ContactTest.create(); + await AddressTest.create(); + }); + + afterEach(async () => { + await AddressTest.deleteAll(); + await ContactTest.deleteAll(); + await UserTest.delete(); + }); + + it("should rejected if contact id is not found", async () => { + const contact = await ContactTest.get(); + const response = await app.request( + "/api/contacts/" + (contact.id + 1) + "/addresses", + { + method: "get", + headers: { + Authorization: "test", + }, + } + ); + + expect(response.status).toBe(404); + const body = await response.json(); + expect(body.errors).toBeDefined(); + }); + + it("should success if contact is exists", async () => { + const contact = await ContactTest.get(); + const address = await AddressTest.get(); + const response = await app.request( + "/api/contacts/" + contact.id + "/addresses", + { + method: "get", + headers: { + Authorization: "test", + }, + } + ); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.data).toBeDefined(); + expect(body.data.length).toBe(1); + expect(body.data[0].id).toBe(address.id); + expect(body.data[0].street).toBe(address.street); + expect(body.data[0].city).toBe(address.city); + expect(body.data[0].province).toBe(address.province); + expect(body.data[0].country).toBe(address.country); + expect(body.data[0].postal_code).toBe(address.postal_code); + }); +}); diff --git a/test/contact.test.ts b/test/contact.test.ts new file mode 100644 index 0000000..572527d --- /dev/null +++ b/test/contact.test.ts @@ -0,0 +1,456 @@ +import { expect, describe, it, beforeEach, afterEach } from "bun:test"; +import { ContactTest, UserTest } from "./test-util"; +import { app } from "../src"; + +describe("POST /api/contacts", () => { + beforeEach(async () => { + await ContactTest.deleteAll(); + await UserTest.create(); + }); + + afterEach(async () => { + await ContactTest.deleteAll(); + await UserTest.delete(); + }); + + it("should rejected if token is not valid", async () => { + const response = await app.request("/api/contacts", { + method: "post", + headers: { + Authorization: "salah", + }, + body: JSON.stringify({ + first_name: "", + }), + }); + + expect(response.status).toBe(401); + + const body = await response.json(); + expect(body.errors).toBeDefined(); + }); + + it("should rejected if contact is invalid", async () => { + const response = await app.request("/api/contacts", { + method: "post", + headers: { + Authorization: "test", + }, + body: JSON.stringify({ + first_name: "", + }), + }); + + expect(response.status).toBe(400); + + const body = await response.json(); + expect(body.errors).toBeDefined(); + }); + + it("should success if contact is valid (only first_name)", async () => { + const response = await app.request("/api/contacts", { + method: "post", + headers: { + Authorization: "test", + }, + body: JSON.stringify({ + first_name: "IDScript", + }), + }); + + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.data).toBeDefined(); + expect(body.data.first_name).toBe("IDScript"); + expect(body.data.last_name).toBeNull(); + expect(body.data.email).toBeNull(); + expect(body.data.phone).toBeNull(); + }); + + it("should success if contact is valid (full data)", async () => { + const response = await app.request("/api/contacts", { + method: "post", + headers: { + Authorization: "test", + }, + body: JSON.stringify({ + first_name: "IDScript", + last_name: "IDScript", + email: "IDScript@gmail.com", + phone: "23424234324", + }), + }); + + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.data).toBeDefined(); + expect(body.data.first_name).toBe("IDScript"); + expect(body.data.last_name).toBe("IDScript"); + expect(body.data.email).toBe("IDScript@gmail.com"); + expect(body.data.phone).toBe("23424234324"); + }); +}); + +describe("GET /api/contacts/{id}", () => { + beforeEach(async () => { + await ContactTest.deleteAll(); + await UserTest.create(); + await ContactTest.create(); + }); + + afterEach(async () => { + await ContactTest.deleteAll(); + await UserTest.delete(); + }); + + it("should get 404 if contact is not found", async () => { + const contact = await ContactTest.get(); + + const response = await app.request("/api/contacts/" + (contact.id + 1), { + method: "get", + headers: { + Authorization: "test", + }, + }); + + expect(response.status).toBe(404); + + const body = await response.json(); + expect(body.errors).toBeDefined(); + }); + + it("should success if contact is exists", async () => { + const contact = await ContactTest.get(); + + const response = await app.request("/api/contacts/" + contact.id, { + method: "get", + headers: { + Authorization: "test", + }, + }); + + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.data).toBeDefined(); + expect(body.data.first_name).toBe(contact.first_name); + expect(body.data.last_name).toBe(contact.last_name); + expect(body.data.email).toBe(contact.email); + expect(body.data.phone).toBe(contact.phone); + expect(body.data.id).toBe(contact.id); + }); +}); + +describe("PUT /api/contacts/{id}", () => { + beforeEach(async () => { + await ContactTest.deleteAll(); + await UserTest.create(); + await ContactTest.create(); + }); + + afterEach(async () => { + await ContactTest.deleteAll(); + await UserTest.delete(); + }); + + it("should rejected update contact if request is invalid", async () => { + const contact = await ContactTest.get(); + + const response = await app.request("/api/contacts/" + contact.id, { + method: "put", + headers: { + Authorization: "test", + }, + body: JSON.stringify({ + first_name: "", + }), + }); + + expect(response.status).toBe(400); + + const body = await response.json(); + expect(body.errors).toBeDefined(); + }); + + it("should rejected update contact if id is not found", async () => { + const contact = await ContactTest.get(); + + const response = await app.request("/api/contacts/" + (contact.id + 1), { + method: "put", + headers: { + Authorization: "test", + }, + body: JSON.stringify({ + first_name: "IDScript", + }), + }); + + expect(response.status).toBe(404); + + const body = await response.json(); + expect(body.errors).toBeDefined(); + }); + + it("should success update contact if request is valid", async () => { + const contact = await ContactTest.get(); + + const response = await app.request("/api/contacts/" + contact.id, { + method: "put", + headers: { + Authorization: "test", + }, + body: JSON.stringify({ + first_name: "IDScript", + last_name: "IDScript", + email: "IDScript@gmail.com", + phone: "1231234", + }), + }); + + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.data).toBeDefined(); + expect(body.data.first_name).toBe("IDScript"); + expect(body.data.last_name).toBe("IDScript"); + expect(body.data.email).toBe("IDScript@gmail.com"); + expect(body.data.phone).toBe("1231234"); + }); +}); + +describe("DELETE /api/contacts/{id}", () => { + beforeEach(async () => { + await ContactTest.deleteAll(); + await UserTest.create(); + await ContactTest.create(); + }); + + afterEach(async () => { + await ContactTest.deleteAll(); + await UserTest.delete(); + }); + + it("should rejected if contact id is not found", async () => { + const contact = await ContactTest.get(); + + const response = await app.request("/api/contacts/" + (contact.id + 1), { + method: "delete", + headers: { + Authorization: "test", + }, + }); + + expect(response.status).toBe(404); + + const body = await response.json(); + expect(body.errors).toBeDefined(); + }); + + it("should success if contact is exists", async () => { + const contact = await ContactTest.get(); + + const response = await app.request("/api/contacts/" + contact.id, { + method: "delete", + headers: { + Authorization: "test", + }, + }); + + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.data).toBe(true); + }); +}); + +describe("GET /api/contacts", () => { + beforeEach(async () => { + await ContactTest.deleteAll(); + await UserTest.create(); + await ContactTest.createMany(25); + }); + + afterEach(async () => { + await ContactTest.deleteAll(); + await UserTest.delete(); + }); + + it("should be able to search contact", async () => { + const response = await app.request("/api/contacts", { + method: "get", + headers: { + Authorization: "test", + }, + }); + + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.data.length).toBe(10); + expect(body.paging.current_page).toBe(1); + expect(body.paging.size).toBe(10); + expect(body.paging.total_page).toBe(3); + }); + + it("should be able to search contact using name", async () => { + let response = await app.request("/api/contacts?name=IDS", { + method: "get", + headers: { + Authorization: "test", + }, + }); + + expect(response.status).toBe(200); + + let body = await response.json(); + expect(body.data.length).toBe(10); + expect(body.paging.current_page).toBe(1); + expect(body.paging.size).toBe(10); + expect(body.paging.total_page).toBe(3); + + response = await app.request("/api/contacts?name=script", { + method: "get", + headers: { + Authorization: "test", + }, + }); + + expect(response.status).toBe(200); + + body = await response.json(); + expect(body.data.length).toBe(10); + expect(body.paging.current_page).toBe(1); + expect(body.paging.size).toBe(10); + expect(body.paging.total_page).toBe(3); + }); + + it("should be able to search contact using email", async () => { + const response = await app.request("/api/contacts?email=gmail", { + method: "get", + headers: { + Authorization: "test", + }, + }); + + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.data.length).toBe(10); + expect(body.paging.current_page).toBe(1); + expect(body.paging.size).toBe(10); + expect(body.paging.total_page).toBe(3); + }); + + it("should be able to search contact using phone", async () => { + const response = await app.request("/api/contacts?phone=31", { + method: "get", + headers: { + Authorization: "test", + }, + }); + + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.data.length).toBe(10); + expect(body.paging.current_page).toBe(1); + expect(body.paging.size).toBe(10); + expect(body.paging.total_page).toBe(3); + }); + + it("should be able to search without result", async () => { + let response = await app.request("/api/contacts?name=budi", { + method: "get", + headers: { + Authorization: "test", + }, + }); + + expect(response.status).toBe(200); + + let body = await response.json(); + expect(body.data.length).toBe(0); + expect(body.paging.current_page).toBe(1); + expect(body.paging.size).toBe(10); + expect(body.paging.total_page).toBe(0); + + response = await app.request("/api/contacts?email=gakada", { + method: "get", + headers: { + Authorization: "test", + }, + }); + + expect(response.status).toBe(200); + + body = await response.json(); + expect(body.data.length).toBe(0); + expect(body.paging.current_page).toBe(1); + expect(body.paging.size).toBe(10); + expect(body.paging.total_page).toBe(0); + + response = await app.request("/api/contacts?phone=gakada", { + method: "get", + headers: { + Authorization: "test", + }, + }); + + expect(response.status).toBe(200); + + body = await response.json(); + expect(body.data.length).toBe(0); + expect(body.paging.current_page).toBe(1); + expect(body.paging.size).toBe(10); + expect(body.paging.total_page).toBe(0); + }); + + it("should be able to search with paging", async () => { + let response = await app.request("/api/contacts?size=5", { + method: "get", + headers: { + Authorization: "test", + }, + }); + + expect(response.status).toBe(200); + + let body = await response.json(); + expect(body.data.length).toBe(5); + expect(body.paging.current_page).toBe(1); + expect(body.paging.size).toBe(5); + expect(body.paging.total_page).toBe(5); + + response = await app.request("/api/contacts?size=5&page=2", { + method: "get", + headers: { + Authorization: "test", + }, + }); + + expect(response.status).toBe(200); + + body = await response.json(); + expect(body.data.length).toBe(5); + expect(body.paging.current_page).toBe(2); + expect(body.paging.size).toBe(5); + expect(body.paging.total_page).toBe(5); + + response = await app.request("/api/contacts?size=5&page=100", { + method: "get", + headers: { + Authorization: "test", + }, + }); + + expect(response.status).toBe(200); + + body = await response.json(); + expect(body.data.length).toBe(0); + expect(body.paging.current_page).toBe(100); + expect(body.paging.size).toBe(5); + expect(body.paging.total_page).toBe(5); + }); +}); diff --git a/test/index.test.ts b/test/index.test.ts new file mode 100644 index 0000000..ddd3c9e --- /dev/null +++ b/test/index.test.ts @@ -0,0 +1,36 @@ +import { app } from "../src"; +import { expect, describe, it } from "bun:test"; + +describe("POST", () => { + it("URL Not Found", async () => { + const url = crypto.randomUUID(); + const response = await app.request("/" + url, { + method: "get", + body: JSON.stringify({ + first_name: "OK", + }), + }); + + expect(response.status).toBe(404); + + const body = await response.json(); + expect(body.errors).toBeDefined(); + }); + + it("App Version", async () => { + const response = await app.request("/api", { + method: "get", + body: JSON.stringify({ + first_name: "OK", + }), + }); + + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.message).toBeDefined(); + expect(body.version).toBeDefined(); + expect(body.stability).toBeDefined(); + expect(body.errors).toBeUndefined(); + }); +}); diff --git a/test/logger.test.ts b/test/logger.test.ts new file mode 100644 index 0000000..921b899 --- /dev/null +++ b/test/logger.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, spyOn } from "bun:test"; +import { log } from "../src/config/logger"; + +describe("Logger Test", () => { + it("Log error Test", async () => { + const logMsg = "This is an error log Test"; + const errorLog = spyOn(log, "error"); + expect(errorLog).toHaveBeenCalledTimes(0); + log.error(logMsg); + expect(errorLog).toHaveBeenCalledTimes(1); + expect(errorLog.mock.calls).toEqual([[logMsg]]); + }); + + it("Log warn Test", async () => { + const logMsg = "This is an warn log Test"; + const warnLog = spyOn(log, "warn"); + expect(warnLog).toHaveBeenCalledTimes(0); + log.warn(logMsg); + expect(warnLog).toHaveBeenCalledTimes(1); + expect(warnLog.mock.calls).toEqual([[logMsg]]); + }); + + it("Log info Test", async () => { + const logMsg = "This is an info log Test"; + const infoLog = spyOn(log, "info"); + expect(infoLog).toHaveBeenCalledTimes(0); + log.info(logMsg); + expect(infoLog).toHaveBeenCalledTimes(1); + expect(infoLog.mock.calls).toEqual([[logMsg]]); + }); + + it("Log debug Test", async () => { + const logMsg = "This is an debug log Test"; + const debugLog = spyOn(log, "debug"); + expect(debugLog).toHaveBeenCalledTimes(0); + log.debug(logMsg); + expect(debugLog).toHaveBeenCalledTimes(1); + expect(debugLog.mock.calls).toEqual([[logMsg]]); + }); + + it("Log http Test", async () => { + const logMsg = "This is an http log Test"; + const httpLog = spyOn(log, "http"); + expect(httpLog).toHaveBeenCalledTimes(0); + log.http(logMsg); + expect(httpLog).toHaveBeenCalledTimes(1); + expect(httpLog.mock.calls).toEqual([[logMsg]]); + }); + + it("Log verbose Test", async () => { + const logMsg = "This is an verbose log Test"; + const verboseLog = spyOn(log, "verbose"); + expect(verboseLog).toHaveBeenCalledTimes(0); + log.verbose(logMsg); + expect(verboseLog).toHaveBeenCalledTimes(1); + expect(verboseLog.mock.calls).toEqual([[logMsg]]); + }); + + it("Log silly Test", async () => { + const logMsg = "This is an silly log Test"; + const sillyLog = spyOn(log, "silly"); + expect(sillyLog).toHaveBeenCalledTimes(0); + log.silly(logMsg); + expect(sillyLog).toHaveBeenCalledTimes(1); + expect(sillyLog.mock.calls).toEqual([[logMsg]]); + }); +}); diff --git a/test/test-util.ts b/test/test-util.ts new file mode 100644 index 0000000..173fecd --- /dev/null +++ b/test/test-util.ts @@ -0,0 +1,99 @@ +import { prismaClient } from "../src/config/database"; +import type { Address, Contact } from "@prisma/client"; + +export class UserTest { + static async create() { + await prismaClient.user.create({ + data: { + username: "test", + name: "test", + email: "test@gmail.com", + password: await Bun.password.hash("test", { + algorithm: "bcrypt", + cost: 10, + }), + token: "test", + }, + }); + } + + static async delete() { + await prismaClient.user.deleteMany({ + where: { + username: "test", + }, + }); + } +} + +export class ContactTest { + static async deleteAll() { + await prismaClient.contact.deleteMany({ + where: { + username: "test", + }, + }); + } + + static async create() { + await prismaClient.contact.create({ + data: { + first_name: "IDScript", + last_name: "IDScript", + email: "test@gmail.com", + phone: "123123", + username: "test", + }, + }); + } + + static async createMany(n: number) { + for (let i = 0; i < n; i++) { + await this.create(); + } + } + + static async get(): Promise { + return prismaClient.contact.findFirstOrThrow({ + where: { + username: "test", + }, + }); + } +} + +export class AddressTest { + static async create() { + const contact = await ContactTest.get(); + await prismaClient.address.create({ + data: { + contact_id: contact.id, + street: "Jalan", + city: "Kota", + province: "Provinsi", + country: "Indonesia", + postal_code: "12345", + }, + }); + } + + static async get(): Promise
{ + return prismaClient.address.findFirstOrThrow({ + where: { + contact: { + username: "test", + }, + }, + }); + } + + static async deleteAll() { + await prismaClient.address.deleteMany({ + where: { + contact: { + username: "test", + }, + }, + }); + } +} diff --git a/test/user.test.ts b/test/user.test.ts new file mode 100644 index 0000000..4eb69f8 --- /dev/null +++ b/test/user.test.ts @@ -0,0 +1,308 @@ +import { describe, it, expect, afterEach, beforeEach } from "bun:test"; +import { log as logger } from "../src/config/logger"; +import { UserTest } from "./test-util"; +import { app } from "../src"; + +describe("POST /api/users", () => { + afterEach(async () => { + await UserTest.delete(); + }); + + it("should reject register new user if request is invalid", async () => { + const response = await app.request("/api/users", { + method: "post", + body: JSON.stringify({ + username: "", + password: "", + name: "", + email: "", + }), + }); + + const body = await response.json(); + logger.debug(JSON.stringify(body)); + + expect(response.status).toBe(400); + expect(body.errors).toBeDefined(); + }); + + it("should reject register new user if request is invalid 2", async () => { + const response = await app.request("/api/users", { + method: "post", + }); + + const body = await response.json(); + logger.debug(JSON.stringify(body)); + + expect(response.status).toBe(400); + expect(body.errors).toBeDefined(); + }); + + it("should reject register new user if username already exists", async () => { + await UserTest.create(); + + const response = await app.request("/api/users", { + method: "post", + body: JSON.stringify({ + username: "test", + password: "test", + name: "test", + email: "test@gmail.com", + }), + }); + + const body = await response.json(); + logger.debug(JSON.stringify(body)); + + expect(response.status).toBe(400); + expect(body.errors).toBeDefined(); + }); + + it("should register new user success", async () => { + const response = await app.request("/api/users", { + method: "post", + body: JSON.stringify({ + username: "test", + password: "test", + name: "test", + email: "test@gmail.com", + }), + }); + + const body = await response.json(); + logger.info(JSON.stringify(body)); + + expect(response.status).toBe(200); + expect(body.data).toBeDefined(); + expect(body.data.username).toBe("test"); + expect(body.data.name).toBe("test"); + }); +}); + +describe("POST /api/users/login", () => { + beforeEach(async () => { + await UserTest.create(); + }); + + afterEach(async () => { + await UserTest.delete(); + }); + + it("should be able to login", async () => { + const response = await app.request("/api/users/login", { + method: "post", + body: JSON.stringify({ + username: "test", + password: "test", + }), + }); + + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.data.token).toBeDefined(); + }); + + it("should be rejected if username is wrong", async () => { + const response = await app.request("/api/users/login", { + method: "post", + body: JSON.stringify({ + username: "salah", + password: "test", + }), + }); + + expect(response.status).toBe(401); + + const body = await response.json(); + expect(body.errors).toBeDefined(); + }); + + it("should be rejected if password is wrong", async () => { + const response = await app.request("/api/users/login", { + method: "post", + body: JSON.stringify({ + username: "test", + password: "salah", + }), + }); + + expect(response.status).toBe(401); + + const body = await response.json(); + expect(body.errors).toBeDefined(); + }); +}); + +describe("GET /api/users/current", () => { + beforeEach(async () => { + await UserTest.create(); + }); + + afterEach(async () => { + await UserTest.delete(); + }); + + it("should be able to get user", async () => { + const response = await app.request("/api/users/current", { + method: "get", + headers: { + Authorization: "test", + }, + }); + + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.data).toBeDefined(); + expect(body.data.username).toBe("test"); + expect(body.data.name).toBe("test"); + }); + + it("should not be able to get user if token is invalid", async () => { + const response = await app.request("/api/users/current", { + method: "get", + headers: { + Authorization: "salah", + }, + }); + + expect(response.status).toBe(401); + + const body = await response.json(); + expect(body.errors).toBeDefined(); + }); + + it("should not be able to get user if there is no Authorization header", async () => { + const response = await app.request("/api/users/current", { + method: "get", + }); + + expect(response.status).toBe(401); + + const body = await response.json(); + expect(body.errors).toBeDefined(); + }); +}); + +describe("PATCH /api/users/current", () => { + beforeEach(async () => { + await UserTest.create(); + }); + + afterEach(async () => { + await UserTest.delete(); + }); + + it("should be rejected if request is invalid", async () => { + const response = await app.request("/api/users/current", { + method: "patch", + headers: { + Authorization: "test", + }, + body: JSON.stringify({ + name: "", + password: "", + }), + }); + + expect(response.status).toBe(400); + + const body = await response.json(); + expect(body.errors).toBeDefined(); + }); + + it("should be able to update name", async () => { + const response = await app.request("/api/users/current", { + method: "patch", + headers: { + Authorization: "test", + }, + body: JSON.stringify({ + name: "IDScript", + }), + }); + + expect(response.status).toBe(200); + + const body = await response.json(); + logger.debug(JSON.stringify(body)); + expect(body.data).toBeDefined(); + expect(body.data.name).toBe("IDScript"); + }); + + it("should be able to update password", async () => { + let response = await app.request("/api/users/current", { + method: "patch", + headers: { + Authorization: "test", + }, + body: JSON.stringify({ + password: "baru", + }), + }); + + expect(response.status).toBe(200); + + const body = await response.json(); + logger.debug(JSON.stringify(body)); + expect(body.data).toBeDefined(); + expect(body.data.name).toBe("test"); + + response = await app.request("/api/users/login", { + method: "post", + body: JSON.stringify({ + username: "test", + password: "baru", + }), + }); + + expect(response.status).toBe(200); + }); +}); + +describe("DELETE /api/users/current", () => { + beforeEach(async () => { + await UserTest.create(); + }); + + afterEach(async () => { + await UserTest.delete(); + }); + + it("should be able to logout", async () => { + const response = await app.request("/api/users/current", { + method: "delete", + headers: { + Authorization: "test", + }, + }); + + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.data).toBe(true); + }); + + it("should not be able to logout", async () => { + let response = await app.request("/api/users/current", { + method: "delete", + headers: { + Authorization: "test", + }, + }); + + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.data).toBe(true); + + response = await app.request("/api/users/current", { + method: "delete", + headers: { + Authorization: "test", + }, + }); + expect(response.status).toBe(401); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index c442b33..c0ea41c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,28 @@ { - "compilerOptions": { - "strict": true, - "jsx": "react-jsx", - "jsxImportSource": "hono/jsx" - } -} \ No newline at end of file + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "jsxImportSource": "hono/jsx", + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}