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
+ }
+}