├── .env ├── .gitignore ├── README.md ├── bun.lockb ├── doc ├── address.md ├── contact.md └── user.md ├── package.json ├── prisma ├── migrations │ ├── 20241104140200_create_users_table │ │ └── migration.sql │ ├── 20241104140551_create_contacts_table │ │ └── migration.sql │ ├── 20241104140904_create_addresses_table │ │ └── migration.sql │ ├── 20241104150300_make_token_as_optional │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── src ├── application │ ├── database.ts │ └── logging.ts ├── controller │ ├── address-controller.ts │ ├── contact-controller.ts │ └── user-controller.ts ├── index.ts ├── middleware │ └── auth-middleware.ts ├── model │ ├── address-model.ts │ ├── app-model.ts │ ├── contact-model.ts │ ├── page-model.ts │ └── user-model.ts ├── service │ ├── address-service.ts │ ├── contact-service.ts │ └── user-service.ts └── validation │ ├── address-validation.ts │ ├── contact-validation.ts │ └── user-validation.ts ├── test.http ├── test ├── address.test.ts ├── contact.test.ts ├── test-util.ts └── user.test.ts └── tsconfig.json /.env: -------------------------------------------------------------------------------- 1 | # Environment variables declared in this file are automatically made available to Prisma. 2 | # See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema 3 | 4 | # Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. 5 | # See the documentation for all the connection string options: https://pris.ly/d/connection-strings 6 | 7 | DATABASE_URL="mysql://root:@localhost:3306/belajar_bun_hono_restful_api" 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # deps 2 | node_modules/ 3 | .idea 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | To install dependencies: 2 | ```sh 3 | bun install 4 | ``` 5 | 6 | To run: 7 | ```sh 8 | bun run dev 9 | ``` 10 | 11 | open http://localhost:3000 12 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProgrammerZamanNow/belajar-bun-hono-restful-api/f4525d76324232fbd552b515ec56a03360e8e8e5/bun.lockb -------------------------------------------------------------------------------- /doc/address.md: -------------------------------------------------------------------------------- 1 | # Address API Spec 2 | 3 | ## Create Address 4 | 5 | Endpoint : POST /api/contacts/{idContact}/addresses 6 | 7 | Request Header : 8 | - Authorization : token 9 | 10 | Request Body : 11 | 12 | ```json 13 | { 14 | "street" : "jalan", 15 | "city" : "kota", 16 | "province" : "provinsi", 17 | "country" : "negara", 18 | "postal_code" : "234234" 19 | } 20 | ``` 21 | 22 | Response Body : 23 | 24 | ```json 25 | { 26 | "data" : { 27 | "id" : 1, 28 | "street" : "jalan", 29 | "city" : "kota", 30 | "province" : "provinsi", 31 | "country" : "negara", 32 | "postal_code" : "234234" 33 | } 34 | } 35 | ``` 36 | 37 | ## Get Address 38 | 39 | Endpoint : GET /api/contacts/{idContact}/addresses/{idAddress} 40 | 41 | Request Header : 42 | - Authorization : token 43 | 44 | Response Body : 45 | 46 | ```json 47 | { 48 | "data" : { 49 | "id" : 1, 50 | "street" : "jalan", 51 | "city" : "kota", 52 | "province" : "provinsi", 53 | "country" : "negara", 54 | "postal_code" : "234234" 55 | } 56 | } 57 | ``` 58 | 59 | ## Update Address 60 | 61 | Endpoint : PUT /api/contacts/{idContact}/addresses/{idAddress} 62 | 63 | Request Header : 64 | - Authorization : token 65 | 66 | Request Body : 67 | 68 | ```json 69 | { 70 | "street" : "jalan", 71 | "city" : "kota", 72 | "province" : "provinsi", 73 | "country" : "negara", 74 | "postal_code" : "234234" 75 | } 76 | ``` 77 | 78 | Response Body : 79 | 80 | ```json 81 | { 82 | "data" : { 83 | "id" : 1, 84 | "street" : "jalan", 85 | "city" : "kota", 86 | "province" : "provinsi", 87 | "country" : "negara", 88 | "postal_code" : "234234" 89 | } 90 | } 91 | ``` 92 | 93 | ## Remove Address 94 | 95 | Endpoint : DELETE /api/contacts/{idContact}/addresses/{idAddress} 96 | 97 | Request Header : 98 | - Authorization : token 99 | 100 | Response Body : 101 | 102 | ```json 103 | { 104 | "data": true 105 | } 106 | ``` 107 | 108 | ## List Address 109 | 110 | Endpoint : GET /api/contacts/{idContact}/addresses 111 | 112 | Request Header : 113 | - Authorization : token 114 | 115 | Response Body : 116 | 117 | ```json 118 | { 119 | "data": [ 120 | { 121 | "id": 1, 122 | "street": "jalan", 123 | "city": "kota", 124 | "province": "provinsi", 125 | "country": "negara", 126 | "postal_code": "234234" 127 | }, 128 | { 129 | "id": 2, 130 | "street": "jalan", 131 | "city": "kota", 132 | "province": "provinsi", 133 | "country": "negara", 134 | "postal_code": "234234" 135 | } 136 | ] 137 | } 138 | ``` 139 | -------------------------------------------------------------------------------- /doc/contact.md: -------------------------------------------------------------------------------- 1 | # Contact API Spec 2 | 3 | ## Create Contact 4 | 5 | Endpoint : POST /api/contacts 6 | 7 | Request Header : 8 | - Authorization : token 9 | 10 | Request Body : 11 | 12 | ```json 13 | { 14 | "first_name" : "Nama Depan", 15 | "last_name" : "Nama Belakang", 16 | "email" : "eko@gmail.com", 17 | "phone" : "08999999999" 18 | } 19 | ``` 20 | 21 | Response Body 22 | 23 | ```json 24 | { 25 | "data": { 26 | "id": 1, 27 | "first_name": "Nama Depan", 28 | "last_name": "Nama Belakang", 29 | "email": "eko@gmail.com", 30 | "phone": "08999999999" 31 | } 32 | } 33 | ``` 34 | 35 | ## Get Contact 36 | 37 | Endpoint : GET /api/contacts/{idContact} 38 | 39 | Request Header : 40 | - Authorization : token 41 | 42 | Response Body 43 | 44 | ```json 45 | { 46 | "data": { 47 | "id": 1, 48 | "first_name": "Nama Depan", 49 | "last_name": "Nama Belakang", 50 | "email": "eko@gmail.com", 51 | "phone": "08999999999" 52 | } 53 | } 54 | ``` 55 | 56 | ## Update Contact 57 | 58 | Endpoint : PUT /api/contacts/{idContact} 59 | 60 | Request Header : 61 | - Authorization : token 62 | 63 | Request Body : 64 | 65 | ```json 66 | { 67 | "first_name": "Nama Depan", 68 | "last_name": "Nama Belakang", 69 | "email": "eko@gmail.com", 70 | "phone": "08999999999" 71 | } 72 | ``` 73 | 74 | Response Body 75 | 76 | ```json 77 | { 78 | "data": { 79 | "id": 1, 80 | "first_name": "Nama Depan", 81 | "last_name": "Nama Belakang", 82 | "email": "eko@gmail.com", 83 | "phone": "08999999999" 84 | } 85 | } 86 | ``` 87 | 88 | ## Remove Contact 89 | 90 | Endpoint : DELETE /api/contacts/{idContact} 91 | 92 | Request Header : 93 | - Authorization : token 94 | 95 | Response Body 96 | 97 | ```json 98 | { 99 | "data" : true 100 | } 101 | ``` 102 | 103 | ## Search Contact 104 | 105 | Endpoint : GET /api/contacts 106 | 107 | Request Header : 108 | - Authorization : token 109 | 110 | Query Parameter : 111 | - name : string, search ke first_name atau last_name 112 | - email : string, search ke email 113 | - phone : string, search ke phone 114 | - page : number, default 1 115 | - size : number, default 10 116 | 117 | Response Body 118 | 119 | ```json 120 | { 121 | "data" : [ 122 | { 123 | "id": 1, 124 | "first_name": "Nama Depan", 125 | "last_name": "Nama Belakang", 126 | "email": "eko@gmail.com", 127 | "phone": "08999999999" 128 | }, 129 | { 130 | "id": 2, 131 | "first_name": "Nama Depan", 132 | "last_name": "Nama Belakang", 133 | "email": "eko@gmail.com", 134 | "phone": "08999999999" 135 | } 136 | ], 137 | "paging" : { 138 | "current_page" : 1, 139 | "total_page" : 10, 140 | "size" : 10 141 | } 142 | } 143 | ``` 144 | -------------------------------------------------------------------------------- /doc/user.md: -------------------------------------------------------------------------------- 1 | # User API Spec 2 | 3 | ## Register User 4 | 5 | Endpoint : POST /api/users 6 | 7 | Request Body : 8 | 9 | ```json 10 | { 11 | "username" : "khannedy", 12 | "password" : "rahasia", 13 | "name" : "Eko Kurniawan Khannedy" 14 | } 15 | ``` 16 | 17 | Response Body (Success) : 18 | 19 | ```json 20 | { 21 | "data" : { 22 | "username" : "khannedy", 23 | "name" : "Eko Kurniawan Khannedy" 24 | } 25 | } 26 | ``` 27 | 28 | Response Body (Failed) : 29 | 30 | ```json 31 | { 32 | "errors" : "Username must not blank, ..." 33 | } 34 | ``` 35 | 36 | ## Login User 37 | 38 | Endpoint : POST /api/users/login 39 | 40 | Request Body : 41 | 42 | ```json 43 | { 44 | "username" : "khannedy", 45 | "password" : "rahasia" 46 | } 47 | ``` 48 | 49 | Response Body (Success) : 50 | 51 | ```json 52 | { 53 | "data" : { 54 | "username" : "khannedy", 55 | "name" : "Eko Kurniawan Khannedy", 56 | "token" : "token" 57 | } 58 | } 59 | ``` 60 | 61 | ## Get User 62 | 63 | Endpoint : GET /api/users/current 64 | 65 | Request Header : 66 | - Authorization : token 67 | 68 | Response Body (Success) : 69 | 70 | ```json 71 | { 72 | "data" : { 73 | "username" : "khannedy", 74 | "name" : "Eko Kurniawan Khannedy" 75 | } 76 | } 77 | ``` 78 | 79 | ## Update User 80 | 81 | Endpoint : PATCH /api/users/current 82 | 83 | Request Header : 84 | - Authorization : token 85 | 86 | Request Body : 87 | 88 | ```json 89 | { 90 | "name" : "Kalo mau update nama", 91 | "password" : "kalo mau update password" 92 | } 93 | ``` 94 | 95 | Response Body (Success) : 96 | 97 | ```json 98 | { 99 | "data" : { 100 | "username" : "khannedy", 101 | "name" : "Eko Kurniawan Khannedy" 102 | } 103 | } 104 | ``` 105 | 106 | ## Logout User 107 | 108 | Endpoint : DELETE /api/users/current 109 | 110 | Request Header : 111 | - Authorization : token 112 | 113 | Response Body (Success) : 114 | 115 | ```json 116 | { 117 | "data" : true 118 | } 119 | ``` 120 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "belajar-bun-hono-restful-api", 3 | "scripts": { 4 | "dev": "bun run --hot src/index.ts" 5 | }, 6 | "dependencies": { 7 | "@prisma/client": "^5.21.1", 8 | "hono": "^4.6.9", 9 | "winston": "^3.16.0", 10 | "zod": "^3.23.8" 11 | }, 12 | "devDependencies": { 13 | "@types/bun": "latest", 14 | "prisma": "^5.21.1" 15 | } 16 | } -------------------------------------------------------------------------------- /prisma/migrations/20241104140200_create_users_table/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `users` ( 3 | `username` VARCHAR(100) NOT NULL, 4 | `password` VARCHAR(100) NOT NULL, 5 | `name` VARCHAR(100) NOT NULL, 6 | `token` VARCHAR(100) NOT NULL, 7 | 8 | PRIMARY KEY (`username`) 9 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 10 | -------------------------------------------------------------------------------- /prisma/migrations/20241104140551_create_contacts_table/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `contacts` ( 3 | `id` INTEGER NOT NULL AUTO_INCREMENT, 4 | `first_name` VARCHAR(100) NOT NULL, 5 | `last_name` VARCHAR(100) NULL, 6 | `email` VARCHAR(100) NULL, 7 | `phone` VARCHAR(20) NULL, 8 | `username` VARCHAR(100) NOT NULL, 9 | 10 | PRIMARY KEY (`id`) 11 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 12 | 13 | -- AddForeignKey 14 | ALTER TABLE `contacts` ADD CONSTRAINT `contacts_username_fkey` FOREIGN KEY (`username`) REFERENCES `users`(`username`) ON DELETE RESTRICT ON UPDATE CASCADE; 15 | -------------------------------------------------------------------------------- /prisma/migrations/20241104140904_create_addresses_table/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `addresses` ( 3 | `id` INTEGER NOT NULL AUTO_INCREMENT, 4 | `street` VARCHAR(255) NULL, 5 | `city` VARCHAR(100) NULL, 6 | `province` VARCHAR(100) NULL, 7 | `country` VARCHAR(100) NOT NULL, 8 | `postal_code` VARCHAR(10) NOT NULL, 9 | `contact_id` INTEGER NOT NULL, 10 | 11 | PRIMARY KEY (`id`) 12 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 13 | 14 | -- AddForeignKey 15 | ALTER TABLE `addresses` ADD CONSTRAINT `addresses_contact_id_fkey` FOREIGN KEY (`contact_id`) REFERENCES `contacts`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; 16 | -------------------------------------------------------------------------------- /prisma/migrations/20241104150300_make_token_as_optional/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `users` MODIFY `token` VARCHAR(100) NULL; 3 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "mysql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? 5 | // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init 6 | 7 | generator client { 8 | provider = "prisma-client-js" 9 | } 10 | 11 | datasource db { 12 | provider = "mysql" 13 | url = env("DATABASE_URL") 14 | } 15 | 16 | model User { 17 | username String @id @db.VarChar(100) 18 | password String @db.VarChar(100) 19 | name String @db.VarChar(100) 20 | token String? @db.VarChar(100) 21 | 22 | contacts Contact[] 23 | 24 | @@map("users") 25 | } 26 | 27 | model Contact { 28 | id Int @id @default(autoincrement()) 29 | first_name String @db.VarChar(100) 30 | last_name String? @db.VarChar(100) 31 | email String? @db.VarChar(100) 32 | phone String? @db.VarChar(20) 33 | 34 | username String @db.VarChar(100) 35 | 36 | user User @relation(fields: [username], references: [username]) 37 | addresses Address[] 38 | 39 | @@map("contacts") 40 | } 41 | 42 | model Address { 43 | id Int @id @default(autoincrement()) 44 | street String? @db.VarChar(255) 45 | city String? @db.VarChar(100) 46 | province String? @db.VarChar(100) 47 | country String @db.VarChar(100) 48 | postal_code String @db.VarChar(10) 49 | 50 | contact_id Int 51 | 52 | contact Contact @relation(fields: [contact_id], references: [id]) 53 | 54 | @@map("addresses") 55 | } 56 | -------------------------------------------------------------------------------- /src/application/database.ts: -------------------------------------------------------------------------------- 1 | import {PrismaClient} from "@prisma/client"; 2 | import {logger} from "./logging"; 3 | 4 | export const prismaClient = new PrismaClient({ 5 | log: [ 6 | { 7 | emit: 'event', 8 | level: 'query', 9 | }, 10 | { 11 | emit: 'event', 12 | level: 'error', 13 | }, 14 | { 15 | emit: 'event', 16 | level: 'info', 17 | }, 18 | { 19 | emit: 'event', 20 | level: 'warn', 21 | }, 22 | ], 23 | }) 24 | 25 | prismaClient.$on('query', (e) => { 26 | logger.info(e) 27 | }) 28 | 29 | prismaClient.$on('error', (e) => { 30 | logger.error(e) 31 | }) 32 | 33 | prismaClient.$on('info', (e) => { 34 | logger.info(e) 35 | }) 36 | 37 | prismaClient.$on('warn', (e) => { 38 | logger.warn(e) 39 | }) 40 | -------------------------------------------------------------------------------- /src/application/logging.ts: -------------------------------------------------------------------------------- 1 | import * as winston from "winston"; 2 | 3 | export const logger = winston.createLogger({ 4 | level: "debug", 5 | format: winston.format.json(), 6 | transports: [ 7 | new winston.transports.Console({}) 8 | ] 9 | }) 10 | -------------------------------------------------------------------------------- /src/controller/address-controller.ts: -------------------------------------------------------------------------------- 1 | import {Hono} from "hono"; 2 | import {ApplicationVariables} from "../model/app-model"; 3 | import {authMiddleware} from "../middleware/auth-middleware"; 4 | import {User} from "@prisma/client"; 5 | import { 6 | CreateAddressRequest, 7 | GetAddressRequest, ListAddressRequest, 8 | RemoveAddressRequest, 9 | UpdateAddressRequest 10 | } from "../model/address-model"; 11 | import {AddressService} from "../service/address-service"; 12 | 13 | export const addressController = new Hono<{ Variables: ApplicationVariables }>(); 14 | addressController.use(authMiddleware) 15 | 16 | addressController.post('/api/contacts/:id/addresses', async (c) => { 17 | const user = c.get('user') as User 18 | const contactId = Number(c.req.param("id")) 19 | const request = await c.req.json() as CreateAddressRequest 20 | request.contact_id = contactId 21 | const response = await AddressService.create(user, request) 22 | return c.json({ 23 | data: response 24 | }) 25 | }) 26 | 27 | addressController.get('/api/contacts/:contact_id/addresses/:address_id', async (c) => { 28 | const user = c.get('user') as User 29 | const request: GetAddressRequest = { 30 | contact_id: Number(c.req.param("contact_id")), 31 | id: Number(c.req.param("address_id")) 32 | } 33 | const response = await AddressService.get(user, request) 34 | return c.json({ 35 | data: response 36 | }) 37 | }) 38 | 39 | addressController.put('/api/contacts/:contact_id/addresses/:address_id', async (c) => { 40 | const user = c.get('user') as User 41 | const contactId = Number(c.req.param("contact_id")) 42 | const addressId = Number(c.req.param("address_id")) 43 | const request = await c.req.json() as UpdateAddressRequest 44 | request.contact_id = contactId 45 | request.id = addressId 46 | const response = await AddressService.update(user, request) 47 | return c.json({ 48 | data: response 49 | }) 50 | }) 51 | 52 | addressController.delete('/api/contacts/:contact_id/addresses/:address_id', async (c) => { 53 | const user = c.get('user') as User 54 | const request: RemoveAddressRequest = { 55 | id: Number(c.req.param("address_id")), 56 | contact_id: Number(c.req.param("contact_id")) 57 | } 58 | const response = await AddressService.remove(user, request) 59 | return c.json({ 60 | data: response 61 | }) 62 | }) 63 | 64 | addressController.get('/api/contacts/:contact_id/addresses', async (c) => { 65 | const user = c.get('user') as User 66 | const request: ListAddressRequest = { 67 | contact_id: Number(c.req.param("contact_id")) 68 | } 69 | const response = await AddressService.list(user, request) 70 | return c.json({ 71 | data: response 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /src/controller/contact-controller.ts: -------------------------------------------------------------------------------- 1 | import {Hono} from "hono"; 2 | import {ApplicationVariables} from "../model/app-model"; 3 | import {authMiddleware} from "../middleware/auth-middleware"; 4 | import {User} from "@prisma/client"; 5 | import {ContactService} from "../service/contact-service"; 6 | import {CreateContactRequest, SearchContactRequest, UpdateContactRequest} from "../model/contact-model"; 7 | 8 | export const contactController = new Hono<{ Variables: ApplicationVariables }>(); 9 | contactController.use(authMiddleware) 10 | 11 | contactController.post('/api/contacts', async (c) => { 12 | const user = c.get('user') as User 13 | const request = await c.req.json() as CreateContactRequest 14 | const response = await ContactService.create(user, request) 15 | 16 | return c.json({ 17 | data: response 18 | }) 19 | }) 20 | 21 | contactController.get('/api/contacts/:id', async (c) => { 22 | const user = c.get('user') as User 23 | const contactId = Number(c.req.param("id")) 24 | const response = await ContactService.get(user, contactId) 25 | 26 | return c.json({ 27 | data: response 28 | }) 29 | }) 30 | 31 | contactController.put('/api/contacts/:id', async (c) => { 32 | const user = c.get('user') as User 33 | const contactId = Number(c.req.param("id")) 34 | const request = await c.req.json() as UpdateContactRequest 35 | request.id = contactId 36 | const response = await ContactService.update(user, request) 37 | 38 | return c.json({ 39 | data: response 40 | }) 41 | }) 42 | 43 | contactController.delete('/api/contacts/:id', async (c) => { 44 | const user = c.get('user') as User 45 | const contactId = Number(c.req.param("id")) 46 | const response = await ContactService.delete(user, contactId) 47 | 48 | return c.json({ 49 | data: response 50 | }) 51 | }) 52 | 53 | contactController.get('/api/contacts', async (c) => { 54 | const user = c.get('user') as User 55 | const request: SearchContactRequest = { 56 | name: c.req.query("name"), 57 | email: c.req.query("email"), 58 | phone: c.req.query("phone"), 59 | page: c.req.query("page") ? Number(c.req.query("page")) : 1, 60 | size: c.req.query("size") ? Number(c.req.query("size")) : 10, 61 | } 62 | const response = await ContactService.search(user, request) 63 | return c.json(response) 64 | }) 65 | -------------------------------------------------------------------------------- /src/controller/user-controller.ts: -------------------------------------------------------------------------------- 1 | import {Hono} from "hono"; 2 | import {LoginUserRequest, RegisterUserRequest, toUserResponse, UpdateUserRequest} from "../model/user-model"; 3 | import {UserService} from "../service/user-service"; 4 | import {ApplicationVariables} from "../model/app-model"; 5 | import {User} from "@prisma/client"; 6 | import {authMiddleware} from "../middleware/auth-middleware"; 7 | 8 | export const userController = new Hono<{ Variables: ApplicationVariables }>(); 9 | 10 | userController.post('/api/users', async (c) => { 11 | const request = await c.req.json() as RegisterUserRequest; 12 | 13 | const response = await UserService.register(request) 14 | 15 | return c.json({ 16 | data: response 17 | }) 18 | }) 19 | 20 | userController.post('/api/users/login', async (c) => { 21 | const request = await c.req.json() as LoginUserRequest; 22 | 23 | const response = await UserService.login(request) 24 | 25 | return c.json({ 26 | data: response 27 | }) 28 | }) 29 | 30 | userController.use(authMiddleware) 31 | 32 | userController.get('/api/users/current', async (c) => { 33 | const user = c.get('user') as User 34 | 35 | return c.json({ 36 | data: toUserResponse(user) 37 | }) 38 | }) 39 | 40 | userController.patch('/api/users/current', async (c) => { 41 | const user = c.get('user') as User 42 | const request = await c.req.json() as UpdateUserRequest; 43 | 44 | const response = await UserService.update(user, request) 45 | 46 | return c.json({ 47 | data: response 48 | }) 49 | }) 50 | 51 | userController.delete('/api/users/current', async (c) => { 52 | const user = c.get('user') as User 53 | 54 | const response = await UserService.logout(user) 55 | 56 | return c.json({ 57 | data: response 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import {Hono} from 'hono' 2 | import {userController} from "./controller/user-controller"; 3 | import {HTTPException} from "hono/http-exception"; 4 | import {ZodError} from "zod"; 5 | import {contactController} from "./controller/contact-controller"; 6 | import {addressController} from "./controller/address-controller"; 7 | 8 | const app = new Hono() 9 | 10 | app.get('/', (c) => { 11 | return c.text('Hello Hono!') 12 | }) 13 | 14 | app.route('/', userController) 15 | app.route('/', contactController) 16 | app.route('/', addressController) 17 | 18 | app.onError(async (err, c) => { 19 | if (err instanceof HTTPException) { 20 | c.status(err.status) 21 | return c.json({ 22 | errors: err.message 23 | }) 24 | } else if (err instanceof ZodError) { 25 | c.status(400) 26 | return c.json({ 27 | errors: err.message 28 | }) 29 | } else { 30 | c.status(500) 31 | return c.json({ 32 | errors: err.message 33 | }) 34 | } 35 | }) 36 | 37 | export default app 38 | -------------------------------------------------------------------------------- /src/middleware/auth-middleware.ts: -------------------------------------------------------------------------------- 1 | import {MiddlewareHandler} from "hono"; 2 | import {UserService} from "../service/user-service"; 3 | 4 | export const authMiddleware: MiddlewareHandler = async (c, next) => { 5 | const token = c.req.header('Authorization') 6 | const user = await UserService.get(token) 7 | 8 | c.set('user', user) 9 | 10 | await next() 11 | } 12 | -------------------------------------------------------------------------------- /src/model/address-model.ts: -------------------------------------------------------------------------------- 1 | import {Address} from "@prisma/client"; 2 | 3 | export type CreateAddressRequest = { 4 | contact_id: number; 5 | street?: string; 6 | city?: string; 7 | province?: string; 8 | country: string; 9 | postal_code: string; 10 | } 11 | 12 | export type AddressResponse = { 13 | id: number; 14 | street?: string | null; 15 | city?: string | null; 16 | province?: string | null; 17 | country: string; 18 | postal_code: string; 19 | } 20 | 21 | export type GetAddressRequest = { 22 | contact_id: number; 23 | id: number; 24 | } 25 | 26 | export type UpdateAddressRequest = { 27 | id: number; 28 | contact_id: number; 29 | street?: string; 30 | city?: string; 31 | province?: string; 32 | country: string; 33 | postal_code: string; 34 | } 35 | 36 | export type RemoveAddressRequest = { 37 | contact_id: number; 38 | id: number; 39 | } 40 | 41 | export type ListAddressRequest = { 42 | contact_id: number; 43 | } 44 | 45 | export function toAddressResponse(address: Address): AddressResponse { 46 | return { 47 | id: address.id, 48 | street: address.street, 49 | city: address.city, 50 | province: address.province, 51 | country: address.country, 52 | postal_code: address.postal_code, 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/model/app-model.ts: -------------------------------------------------------------------------------- 1 | import {User} from "@prisma/client"; 2 | 3 | export type ApplicationVariables = { 4 | user: User 5 | } 6 | -------------------------------------------------------------------------------- /src/model/contact-model.ts: -------------------------------------------------------------------------------- 1 | import {Contact} from "@prisma/client"; 2 | 3 | export type CreateContactRequest = { 4 | first_name: string; 5 | last_name?: string; 6 | email?: string; 7 | phone?: string; 8 | } 9 | 10 | export type ContactResponse = { 11 | id: number; 12 | first_name: string; 13 | last_name?: string | null; 14 | email?: string | null; 15 | phone?: string | null; 16 | } 17 | 18 | export type UpdateContactRequest = { 19 | id: number; 20 | first_name: string; 21 | last_name?: string; 22 | email?: string; 23 | phone?: string; 24 | } 25 | 26 | export type SearchContactRequest = { 27 | name?: string; 28 | phone?: string; 29 | email?: string; 30 | page: number; 31 | size: number; 32 | } 33 | 34 | export function toContactResponse(contact: Contact): ContactResponse { 35 | return { 36 | id: contact.id, 37 | first_name: contact.first_name, 38 | last_name: contact.last_name, 39 | email: contact.email, 40 | phone: contact.phone 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/model/page-model.ts: -------------------------------------------------------------------------------- 1 | export type Paging = { 2 | current_page: number; 3 | total_page: number; 4 | size: number; 5 | } 6 | 7 | export type Pageable = { 8 | data: Array; 9 | paging: Paging 10 | } 11 | -------------------------------------------------------------------------------- /src/model/user-model.ts: -------------------------------------------------------------------------------- 1 | import {User} from "@prisma/client"; 2 | 3 | export type RegisterUserRequest = { 4 | username: string; 5 | password: string; 6 | name: string; 7 | } 8 | 9 | export type LoginUserRequest = { 10 | username: string; 11 | password: string; 12 | } 13 | 14 | export type UpdateUserRequest = { 15 | password?: string; 16 | name?: string; 17 | } 18 | 19 | export type UserResponse = { 20 | username: string; 21 | name: string; 22 | token?: string; 23 | } 24 | 25 | export function toUserResponse(user: User): UserResponse { 26 | return { 27 | name: user.name, 28 | username: user.username 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/service/address-service.ts: -------------------------------------------------------------------------------- 1 | import {Address, User} from "@prisma/client"; 2 | import { 3 | AddressResponse, 4 | CreateAddressRequest, 5 | GetAddressRequest, ListAddressRequest, RemoveAddressRequest, 6 | toAddressResponse, 7 | UpdateAddressRequest 8 | } from "../model/address-model"; 9 | import {AddressValidation} from "../validation/address-validation"; 10 | import {ContactService} from "./contact-service"; 11 | import {prismaClient} from "../application/database"; 12 | import {HTTPException} from "hono/http-exception"; 13 | 14 | export class AddressService { 15 | 16 | static async create(user: User, request: CreateAddressRequest): Promise { 17 | request = AddressValidation.CREATE.parse(request) 18 | await ContactService.contactMustExists(user, request.contact_id) 19 | 20 | const address = await prismaClient.address.create({ 21 | data: request 22 | }) 23 | 24 | return toAddressResponse(address) 25 | } 26 | 27 | static async get(user: User, request: GetAddressRequest): Promise { 28 | request = AddressValidation.GET.parse(request) 29 | await ContactService.contactMustExists(user, request.contact_id) 30 | const address = await this.addressMustExists(request.contact_id, request.id) 31 | 32 | return toAddressResponse(address) 33 | } 34 | 35 | static async addressMustExists(contactId: number, addressId: number): Promise
{ 36 | const address = await prismaClient.address.findFirst({ 37 | where: { 38 | contact_id: contactId, 39 | id: addressId 40 | } 41 | }) 42 | 43 | if (!address) { 44 | throw new HTTPException(404, { 45 | message: "Address is not found" 46 | }) 47 | } 48 | return address 49 | } 50 | 51 | static async update(user: User, request: UpdateAddressRequest): Promise { 52 | request = AddressValidation.UPDATE.parse(request) 53 | await ContactService.contactMustExists(user, request.contact_id) 54 | await this.addressMustExists(request.contact_id, request.id) 55 | 56 | const address = await prismaClient.address.update({ 57 | where: { 58 | id: request.id, 59 | contact_id: request.contact_id 60 | }, 61 | data: request 62 | }) 63 | 64 | return toAddressResponse(address) 65 | } 66 | 67 | static async remove(user: User, request: RemoveAddressRequest): Promise { 68 | request = AddressValidation.REMOVE.parse(request) 69 | await ContactService.contactMustExists(user, request.contact_id) 70 | await this.addressMustExists(request.contact_id, request.id) 71 | 72 | await prismaClient.address.delete({ 73 | where: { 74 | id: request.id, 75 | contact_id: request.contact_id 76 | } 77 | }) 78 | 79 | return true 80 | } 81 | 82 | static async list(user: User, request: ListAddressRequest): Promise> { 83 | request = AddressValidation.LIST.parse(request) 84 | await ContactService.contactMustExists(user, request.contact_id) 85 | 86 | const addresses = await prismaClient.address.findMany({ 87 | where: { 88 | contact_id: request.contact_id 89 | } 90 | }) 91 | 92 | return addresses.map(address => toAddressResponse(address)) 93 | } 94 | 95 | } 96 | -------------------------------------------------------------------------------- /src/service/contact-service.ts: -------------------------------------------------------------------------------- 1 | import {Contact, User} from "@prisma/client"; 2 | import { 3 | ContactResponse, 4 | CreateContactRequest, 5 | SearchContactRequest, 6 | toContactResponse, 7 | UpdateContactRequest 8 | } from "../model/contact-model"; 9 | import {ContactValidation} from "../validation/contact-validation"; 10 | import {prismaClient} from "../application/database"; 11 | import {HTTPException} from "hono/http-exception"; 12 | import {Pageable} from "../model/page-model"; 13 | 14 | export class ContactService { 15 | 16 | static async create(user: User, request: CreateContactRequest): Promise { 17 | request = ContactValidation.CREATE.parse(request) 18 | 19 | let data = { 20 | ...request, 21 | ...{username: user.username} 22 | } 23 | 24 | const contact = await prismaClient.contact.create({ 25 | data: data 26 | }) 27 | 28 | return toContactResponse(contact) 29 | } 30 | 31 | static async get(user: User, contactId: number): Promise { 32 | contactId = ContactValidation.GET.parse(contactId) 33 | const contact = await this.contactMustExists(user, contactId) 34 | return toContactResponse(contact) 35 | } 36 | 37 | static async contactMustExists(user: User, contactId: number): Promise { 38 | const contact = await prismaClient.contact.findFirst({ 39 | where: { 40 | id: contactId, 41 | username: user.username 42 | } 43 | }) 44 | 45 | if (!contact) { 46 | throw new HTTPException(404, { 47 | message: "Contact is not found" 48 | }) 49 | } 50 | 51 | return contact 52 | } 53 | 54 | static async update(user: User, request: UpdateContactRequest): Promise { 55 | request = ContactValidation.UPDATE.parse(request) 56 | await this.contactMustExists(user, request.id) 57 | 58 | const contact = await prismaClient.contact.update({ 59 | where: { 60 | username: user.username, 61 | id: request.id 62 | }, 63 | data: request 64 | }) 65 | 66 | return toContactResponse(contact) 67 | } 68 | 69 | static async delete(user: User, contactId: number): Promise { 70 | contactId = ContactValidation.DELETE.parse(contactId) 71 | await this.contactMustExists(user, contactId) 72 | 73 | await prismaClient.contact.delete({ 74 | where: { 75 | username: user.username, 76 | id: contactId 77 | } 78 | }) 79 | 80 | return true 81 | } 82 | 83 | static async search(user: User, request: SearchContactRequest): Promise> { 84 | request = ContactValidation.SEARCH.parse(request) 85 | 86 | const filters = []; 87 | if (request.name) { 88 | filters.push({ 89 | OR: [ 90 | { 91 | first_name: { 92 | contains: request.name 93 | } 94 | }, 95 | { 96 | last_name: { 97 | contains: request.name 98 | } 99 | } 100 | ] 101 | }) 102 | } 103 | 104 | if (request.email) { 105 | filters.push({ 106 | email: { 107 | contains: request.email 108 | } 109 | }) 110 | } 111 | 112 | if (request.phone) { 113 | filters.push({ 114 | phone: { 115 | contains: request.phone 116 | } 117 | }) 118 | } 119 | 120 | const skip = (request.page - 1) * request.size 121 | 122 | const contacts = await prismaClient.contact.findMany({ 123 | where: { 124 | username: user.username, 125 | AND: filters 126 | }, 127 | take: request.size, 128 | skip: skip 129 | }) 130 | 131 | const total = await prismaClient.contact.count({ 132 | where: { 133 | username: user.username, 134 | AND: filters 135 | } 136 | }) 137 | 138 | return { 139 | data: contacts.map(contact => toContactResponse(contact)), 140 | paging: { 141 | current_page: request.page, 142 | size: request.size, 143 | total_page: Math.ceil(total / request.size) 144 | } 145 | } 146 | } 147 | 148 | } 149 | -------------------------------------------------------------------------------- /src/service/user-service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LoginUserRequest, 3 | RegisterUserRequest, 4 | toUserResponse, 5 | UpdateUserRequest, 6 | UserResponse 7 | } from "../model/user-model"; 8 | import {UserValidation} from "../validation/user-validation"; 9 | import {prismaClient} from "../application/database"; 10 | import {HTTPException} from "hono/http-exception"; 11 | import {User} from "@prisma/client"; 12 | 13 | export class UserService { 14 | 15 | static async register(request: RegisterUserRequest): Promise { 16 | request = UserValidation.REGISTER.parse(request) 17 | 18 | const totalUserWithSameUsername = await prismaClient.user.count({ 19 | where: { 20 | username: request.username 21 | } 22 | }) 23 | 24 | if (totalUserWithSameUsername != 0) { 25 | throw new HTTPException(400, { 26 | message: "Username already exists" 27 | }) 28 | } 29 | 30 | request.password = await Bun.password.hash(request.password, { 31 | algorithm: "bcrypt", 32 | cost: 10 33 | }) 34 | 35 | const user = await prismaClient.user.create({ 36 | data: request 37 | }) 38 | 39 | return toUserResponse(user) 40 | } 41 | 42 | static async login(request: LoginUserRequest): Promise { 43 | request = UserValidation.LOGIN.parse(request) 44 | 45 | let user = await prismaClient.user.findUnique({ 46 | where: { 47 | username: request.username 48 | } 49 | }) 50 | 51 | if (!user) { 52 | throw new HTTPException(401, { 53 | message: "Username or password is wrong" 54 | }) 55 | } 56 | 57 | const isPasswordValid = await Bun.password.verify(request.password, user.password, 'bcrypt') 58 | if (!isPasswordValid) { 59 | throw new HTTPException(401, { 60 | message: "Username or password is wrong" 61 | }) 62 | } 63 | 64 | user = await prismaClient.user.update({ 65 | where: { 66 | username: request.username 67 | }, 68 | data: { 69 | token: crypto.randomUUID() 70 | } 71 | }) 72 | 73 | const response = toUserResponse(user) 74 | response.token = user.token!; 75 | return response 76 | } 77 | 78 | static async get(token: string | undefined | null): Promise { 79 | const result = UserValidation.TOKEN.safeParse(token) 80 | 81 | if (result.error) { 82 | throw new HTTPException(401, { 83 | message: "Unauthorized" 84 | }) 85 | } 86 | 87 | token = result.data; 88 | 89 | const user = await prismaClient.user.findFirst({ 90 | where: { 91 | token: token 92 | } 93 | }) 94 | 95 | if (!user) { 96 | throw new HTTPException(401, { 97 | message: "Unauthorized" 98 | }) 99 | } 100 | 101 | return user; 102 | } 103 | 104 | static async update(user: User, request: UpdateUserRequest): Promise { 105 | request = UserValidation.UPDATE.parse(request) 106 | 107 | if (request.name) { 108 | user.name = request.name 109 | } 110 | 111 | if (request.password) { 112 | user.password = await Bun.password.hash(request.password, { 113 | algorithm: "bcrypt", 114 | cost: 10 115 | }) 116 | } 117 | 118 | user = await prismaClient.user.update({ 119 | where: { 120 | username: user.username 121 | }, 122 | data: user 123 | }) 124 | 125 | return toUserResponse(user) 126 | } 127 | 128 | static async logout(user: User): Promise { 129 | await prismaClient.user.update({ 130 | where: { 131 | username: user.username 132 | }, 133 | data: { 134 | token: null 135 | } 136 | }) 137 | 138 | return true; 139 | } 140 | 141 | } 142 | -------------------------------------------------------------------------------- /src/validation/address-validation.ts: -------------------------------------------------------------------------------- 1 | import {z, ZodType} from "zod"; 2 | 3 | export class AddressValidation { 4 | 5 | static readonly CREATE: ZodType = z.object({ 6 | contact_id: z.number().positive(), 7 | street: z.string().min(1).max(255).optional(), 8 | city: z.string().min(1).max(100).optional(), 9 | province: z.string().min(1).max(100).optional(), 10 | country: z.string().min(1).max(100), 11 | postal_code: z.string().min(1).max(10), 12 | }) 13 | 14 | static readonly GET: ZodType = z.object({ 15 | contact_id: z.number().positive(), 16 | id: z.number().positive(), 17 | }) 18 | 19 | static readonly UPDATE: ZodType = z.object({ 20 | id: z.number().positive(), 21 | contact_id: z.number().positive(), 22 | street: z.string().min(1).max(255).optional(), 23 | city: z.string().min(1).max(100).optional(), 24 | province: z.string().min(1).max(100).optional(), 25 | country: z.string().min(1).max(100), 26 | postal_code: z.string().min(1).max(10), 27 | }) 28 | 29 | static readonly REMOVE: ZodType = z.object({ 30 | contact_id: z.number().positive(), 31 | id: z.number().positive(), 32 | }) 33 | 34 | static readonly LIST: ZodType = z.object({ 35 | contact_id: z.number().positive() 36 | }) 37 | 38 | } 39 | -------------------------------------------------------------------------------- /src/validation/contact-validation.ts: -------------------------------------------------------------------------------- 1 | import {z, ZodType} from "zod"; 2 | 3 | export class ContactValidation { 4 | 5 | static readonly CREATE: ZodType = z.object({ 6 | first_name: z.string().min(1).max(100), 7 | last_name: z.string().min(1).max(100).optional(), 8 | email: z.string().min(1).max(100).email().optional(), 9 | phone: z.string().min(1).max(20).optional(), 10 | }) 11 | 12 | static readonly GET: ZodType = z.number().positive() 13 | 14 | static readonly UPDATE: ZodType = z.object({ 15 | id: z.number().positive(), 16 | first_name: z.string().min(1).max(100), 17 | last_name: z.string().min(1).max(100).optional(), 18 | email: z.string().min(1).max(100).email().optional(), 19 | phone: z.string().min(1).max(20).optional(), 20 | }) 21 | 22 | static readonly DELETE: ZodType = z.number().positive() 23 | 24 | static readonly SEARCH: ZodType = z.object({ 25 | name: z.string().min(1).optional(), 26 | email: z.string().min(1).optional(), 27 | phone: z.string().min(1).optional(), 28 | page: z.number().min(1).positive(), 29 | size: z.number().min(1).max(100).positive() 30 | }) 31 | 32 | } 33 | -------------------------------------------------------------------------------- /src/validation/user-validation.ts: -------------------------------------------------------------------------------- 1 | import {z, ZodType} from "zod"; 2 | 3 | export class UserValidation { 4 | 5 | static readonly REGISTER: ZodType = z.object({ 6 | username: z.string().min(1).max(100), 7 | password: z.string().min(1).max(100), 8 | name: z.string().min(1).max(100), 9 | }) 10 | 11 | static readonly LOGIN: ZodType = z.object({ 12 | username: z.string().min(1).max(100), 13 | password: z.string().min(1).max(100) 14 | }) 15 | 16 | static readonly TOKEN: ZodType = z.string().min(1) 17 | 18 | static readonly UPDATE: ZodType = z.object({ 19 | password: z.string().min(1).max(100).optional(), 20 | name: z.string().min(1).max(100).optional(), 21 | }) 22 | 23 | } 24 | -------------------------------------------------------------------------------- /test.http: -------------------------------------------------------------------------------- 1 | GET http://localhost:3000 2 | 3 | ### Register User 4 | POST http://localhost:3000/api/users 5 | Content-Type: application/json 6 | Accept: application/json 7 | 8 | { 9 | "username" : "eko", 10 | "password" : "eko123", 11 | "name" : "Eko Kurniawan" 12 | } 13 | 14 | ### Login User 15 | POST http://localhost:3000/api/users/login 16 | Content-Type: application/json 17 | Accept: application/json 18 | 19 | { 20 | "username" : "eko", 21 | "password" : "rahasia" 22 | } 23 | 24 | ### Get Current User 25 | GET http://localhost:3000/api/users/current 26 | Content-Type: application/json 27 | Accept: application/json 28 | Authorization: 9e6e8c80-bc4b-4936-8ed8-debbe9c4c343 29 | 30 | ### Update Current User 31 | PATCH http://localhost:3000/api/users/current 32 | Content-Type: application/json 33 | Accept: application/json 34 | Authorization: 9e6e8c80-bc4b-4936-8ed8-debbe9c4c343 35 | 36 | { 37 | "name" : "Eko Kurniawan Khannedy" 38 | } 39 | 40 | ### Update Password 41 | PATCH http://localhost:3000/api/users/current 42 | Content-Type: application/json 43 | Accept: application/json 44 | Authorization: 9e6e8c80-bc4b-4936-8ed8-debbe9c4c343 45 | 46 | { 47 | "password" : "rahasia" 48 | } 49 | 50 | ### Logout User 51 | DELETE http://localhost:3000/api/users/current 52 | Content-Type: application/json 53 | Accept: application/json 54 | Authorization: 9e6e8c80-bc4b-4936-8ed8-debbe9c4c343 55 | 56 | ### Create Contact 57 | POST http://localhost:3000/api/contacts 58 | Content-Type: application/json 59 | Accept: application/json 60 | Authorization: 9e6e8c80-bc4b-4936-8ed8-debbe9c4c343 61 | 62 | { 63 | "first_name" : "Salah 2", 64 | "last_name" : "Last Name", 65 | "email" : "kontak2@gmail.com", 66 | "phone" : "23423432434" 67 | } 68 | 69 | ### Search Contact 70 | GET http://localhost:3000/api/contacts 71 | Content-Type: application/json 72 | Accept: application/json 73 | Authorization: 9e6e8c80-bc4b-4936-8ed8-debbe9c4c343 74 | 75 | ### Detail Contact 76 | GET http://localhost:3000/api/contacts/1446 77 | Content-Type: application/json 78 | Accept: application/json 79 | Authorization: 9e6e8c80-bc4b-4936-8ed8-debbe9c4c343 80 | 81 | ### Update Contact 82 | PUT http://localhost:3000/api/contacts/1444 83 | Content-Type: application/json 84 | Accept: application/json 85 | Authorization: 9e6e8c80-bc4b-4936-8ed8-debbe9c4c343 86 | 87 | { 88 | "id": 1444, 89 | "first_name": "Kontak 1", 90 | "last_name": "Eko", 91 | "email": "eko@gmail.com", 92 | "phone": "123123" 93 | } 94 | 95 | ### Delete Contact 96 | DELETE http://localhost:3000/api/contacts/1446 97 | Content-Type: application/json 98 | Accept: application/json 99 | Authorization: 9e6e8c80-bc4b-4936-8ed8-debbe9c4c343 100 | 101 | ### Create Address 102 | POST http://localhost:3000/api/contacts/1444/addresses 103 | Content-Type: application/json 104 | Accept: application/json 105 | Authorization: 9e6e8c80-bc4b-4936-8ed8-debbe9c4c343 106 | 107 | { 108 | "street" : "Jalan A", 109 | "city" : "Jakarta", 110 | "province" : "DKI Jakarta", 111 | "country" : "Indonesia", 112 | "postal_code" : "234234" 113 | } 114 | 115 | 116 | ### List Address 117 | GET http://localhost:3000/api/contacts/1444/addresses 118 | Content-Type: application/json 119 | Accept: application/json 120 | Authorization: 9e6e8c80-bc4b-4936-8ed8-debbe9c4c343 121 | 122 | ### Detail Address 123 | GET http://localhost:3000/api/contacts/1444/addresses/104 124 | Content-Type: application/json 125 | Accept: application/json 126 | Authorization: 9e6e8c80-bc4b-4936-8ed8-debbe9c4c343 127 | 128 | ### Update Address 129 | PUT http://localhost:3000/api/contacts/1444/addresses/101 130 | Content-Type: application/json 131 | Accept: application/json 132 | Authorization: 9e6e8c80-bc4b-4936-8ed8-debbe9c4c343 133 | 134 | { 135 | "id": 101, 136 | "street": "Jalan B", 137 | "city": "Bandung", 138 | "province": "Jawa Barat", 139 | "country": "Indonesia", 140 | "postal_code": "234234" 141 | } 142 | 143 | ### Delete Address 144 | DELETE http://localhost:3000/api/contacts/1444/addresses/101 145 | Content-Type: application/json 146 | Accept: application/json 147 | Authorization: 9e6e8c80-bc4b-4936-8ed8-debbe9c4c343 148 | -------------------------------------------------------------------------------- /test/address.test.ts: -------------------------------------------------------------------------------- 1 | import {expect, describe, it, beforeEach, afterEach} from "bun:test"; 2 | import {AddressTest, ContactTest, UserTest} from "./test-util"; 3 | import app from "../src"; 4 | 5 | describe("POST /api/contacts/{id}/addresses", () => { 6 | 7 | beforeEach(async () => { 8 | await AddressTest.deleteAll() 9 | await ContactTest.deleteAll() 10 | await UserTest.delete() 11 | 12 | await UserTest.create() 13 | await ContactTest.create() 14 | }) 15 | 16 | afterEach(async () => { 17 | await AddressTest.deleteAll() 18 | await ContactTest.deleteAll() 19 | await UserTest.delete() 20 | }) 21 | 22 | it('should rejected if request is not valid', async () => { 23 | const contact = await ContactTest.get() 24 | const response = await app.request('/api/contacts/' + contact.id + '/addresses', { 25 | method: 'post', 26 | headers: { 27 | 'Authorization': 'test' 28 | }, 29 | body: JSON.stringify({ 30 | country: "", 31 | postal_code: "" 32 | }) 33 | }) 34 | 35 | expect(response.status).toBe(400) 36 | 37 | const body = await response.json() 38 | expect(body.errors).toBeDefined() 39 | }); 40 | 41 | it('should rejected if contact is not found', async () => { 42 | const contact = await ContactTest.get() 43 | const response = await app.request('/api/contacts/' + (contact.id + 1) + '/addresses', { 44 | method: 'post', 45 | headers: { 46 | 'Authorization': 'test' 47 | }, 48 | body: JSON.stringify({ 49 | country: "Indonesia", 50 | postal_code: "1213" 51 | }) 52 | }) 53 | 54 | expect(response.status).toBe(404) 55 | 56 | const body = await response.json() 57 | expect(body.errors).toBeDefined() 58 | }); 59 | 60 | it('should success if request is valid', async () => { 61 | const contact = await ContactTest.get() 62 | const response = await app.request('/api/contacts/' + contact.id + '/addresses', { 63 | method: 'post', 64 | headers: { 65 | 'Authorization': 'test' 66 | }, 67 | body: JSON.stringify({ 68 | street: "Jalan", 69 | city: "Kota", 70 | province: "Provinsi", 71 | country: "Indonesia", 72 | postal_code: "12345" 73 | }) 74 | }) 75 | 76 | expect(response.status).toBe(200) 77 | 78 | const body = await response.json() 79 | expect(body.data).toBeDefined() 80 | expect(body.data.id).toBeDefined() 81 | expect(body.data.street).toBe("Jalan") 82 | expect(body.data.city).toBe("Kota") 83 | expect(body.data.province).toBe("Provinsi") 84 | expect(body.data.country).toBe("Indonesia") 85 | expect(body.data.postal_code).toBe("12345") 86 | }); 87 | 88 | }); 89 | 90 | describe("GET /api/contacts/{contactId}/addresses/{addressId}", () => { 91 | 92 | beforeEach(async () => { 93 | await AddressTest.deleteAll() 94 | await ContactTest.deleteAll() 95 | await UserTest.delete() 96 | 97 | await UserTest.create() 98 | await ContactTest.create() 99 | await AddressTest.create() 100 | }) 101 | 102 | afterEach(async () => { 103 | await AddressTest.deleteAll() 104 | await ContactTest.deleteAll() 105 | await UserTest.delete() 106 | }) 107 | 108 | it('should rejected if address is not found', async () => { 109 | const contact = await ContactTest.get() 110 | const address = await AddressTest.get() 111 | const response = await app.request('/api/contacts/' + contact.id + '/addresses/' + (address.id + 1), { 112 | method: 'get', 113 | headers: { 114 | 'Authorization': 'test' 115 | } 116 | }) 117 | 118 | expect(response.status).toBe(404) 119 | const body = await response.json() 120 | expect(body.errors).toBeDefined() 121 | }); 122 | 123 | it('should success if address is exists', async () => { 124 | const contact = await ContactTest.get() 125 | const address = await AddressTest.get() 126 | const response = await app.request('/api/contacts/' + contact.id + '/addresses/' + address.id, { 127 | method: 'get', 128 | headers: { 129 | 'Authorization': 'test' 130 | } 131 | }) 132 | 133 | expect(response.status).toBe(200) 134 | const body = await response.json() 135 | expect(body.data).toBeDefined() 136 | expect(body.data.id).toBeDefined() 137 | expect(body.data.street).toBe(address.street) 138 | expect(body.data.city).toBe(address.city) 139 | expect(body.data.province).toBe(address.province) 140 | expect(body.data.country).toBe(address.country) 141 | expect(body.data.postal_code).toBe(address.postal_code) 142 | }); 143 | }) 144 | 145 | describe("PUT /api/contacts/{contactId}/addresses/{addressId}", () => { 146 | 147 | beforeEach(async () => { 148 | await AddressTest.deleteAll() 149 | await ContactTest.deleteAll() 150 | await UserTest.delete() 151 | 152 | await UserTest.create() 153 | await ContactTest.create() 154 | await AddressTest.create() 155 | }) 156 | 157 | afterEach(async () => { 158 | await AddressTest.deleteAll() 159 | await ContactTest.deleteAll() 160 | await UserTest.delete() 161 | }) 162 | 163 | it('should rejected if request is invalid', async () => { 164 | const contact = await ContactTest.get() 165 | const address = await AddressTest.get() 166 | const response = await app.request('/api/contacts/' + contact.id + '/addresses/' + address.id, { 167 | method: 'put', 168 | headers: { 169 | 'Authorization': 'test' 170 | }, 171 | body: JSON.stringify({ 172 | country: "", 173 | postal_code: "", 174 | }) 175 | }) 176 | 177 | expect(response.status).toBe(400) 178 | const body = await response.json() 179 | expect(body.errors).toBeDefined() 180 | }); 181 | 182 | it('should rejected if address is not found', async () => { 183 | const contact = await ContactTest.get() 184 | const address = await AddressTest.get() 185 | const response = await app.request('/api/contacts/' + contact.id + '/addresses/' + (address.id + 1), { 186 | method: 'put', 187 | headers: { 188 | 'Authorization': 'test' 189 | }, 190 | body: JSON.stringify({ 191 | country: "Indonesia", 192 | postal_code: "12345", 193 | }) 194 | }) 195 | 196 | expect(response.status).toBe(404) 197 | const body = await response.json() 198 | expect(body.errors).toBeDefined() 199 | }); 200 | 201 | it('should success if request is valid', async () => { 202 | const contact = await ContactTest.get() 203 | const address = await AddressTest.get() 204 | const response = await app.request('/api/contacts/' + contact.id + '/addresses/' + address.id, { 205 | method: 'put', 206 | headers: { 207 | 'Authorization': 'test' 208 | }, 209 | body: JSON.stringify({ 210 | street: "A", 211 | city: "B", 212 | province: "C", 213 | country: "Malaysia", 214 | postal_code: "9999", 215 | }) 216 | }) 217 | 218 | expect(response.status).toBe(200) 219 | const body = await response.json() 220 | expect(body.data).toBeDefined() 221 | expect(body.data.id).toBe(address.id) 222 | expect(body.data.street).toBe("A") 223 | expect(body.data.city).toBe("B") 224 | expect(body.data.province).toBe("C") 225 | expect(body.data.country).toBe("Malaysia") 226 | expect(body.data.postal_code).toBe("9999") 227 | }); 228 | 229 | }) 230 | 231 | describe("DELETE /api/contacts/{contactId}/addresses/{addressId}", () => { 232 | 233 | beforeEach(async () => { 234 | await AddressTest.deleteAll() 235 | await ContactTest.deleteAll() 236 | await UserTest.delete() 237 | 238 | await UserTest.create() 239 | await ContactTest.create() 240 | await AddressTest.create() 241 | }) 242 | 243 | afterEach(async () => { 244 | await AddressTest.deleteAll() 245 | await ContactTest.deleteAll() 246 | await UserTest.delete() 247 | }) 248 | 249 | it('should rejected if address is not exists', async () => { 250 | const contact = await ContactTest.get() 251 | const address = await AddressTest.get() 252 | const response = await app.request('/api/contacts/' + contact.id + '/addresses/' + (address.id + 1), { 253 | method: 'delete', 254 | headers: { 255 | 'Authorization': 'test' 256 | } 257 | }) 258 | 259 | expect(response.status).toBe(404) 260 | const body = await response.json() 261 | expect(body.errors).toBeDefined() 262 | }); 263 | 264 | it('should success if address is exists', async () => { 265 | const contact = await ContactTest.get() 266 | const address = await AddressTest.get() 267 | const response = await app.request('/api/contacts/' + contact.id + '/addresses/' + address.id, { 268 | method: 'delete', 269 | headers: { 270 | 'Authorization': 'test' 271 | } 272 | }) 273 | 274 | expect(response.status).toBe(200) 275 | const body = await response.json() 276 | expect(body.data).toBeTrue() 277 | }); 278 | 279 | }) 280 | 281 | describe("DELETE /api/contacts/{contactId}/addresses/{addressId}", () => { 282 | 283 | beforeEach(async () => { 284 | await AddressTest.deleteAll() 285 | await ContactTest.deleteAll() 286 | await UserTest.delete() 287 | 288 | await UserTest.create() 289 | await ContactTest.create() 290 | await AddressTest.create() 291 | }) 292 | 293 | afterEach(async () => { 294 | await AddressTest.deleteAll() 295 | await ContactTest.deleteAll() 296 | await UserTest.delete() 297 | }) 298 | 299 | it('should rejected if contact id is not found', async () => { 300 | const contact = await ContactTest.get() 301 | const address = await AddressTest.get() 302 | const response = await app.request('/api/contacts/' + (contact.id + 1) + '/addresses', { 303 | method: 'get', 304 | headers: { 305 | 'Authorization': 'test' 306 | } 307 | }) 308 | 309 | expect(response.status).toBe(404) 310 | const body = await response.json() 311 | expect(body.errors).toBeDefined() 312 | }); 313 | 314 | it('should success if contact is exists', async () => { 315 | const contact = await ContactTest.get() 316 | const address = await AddressTest.get() 317 | const response = await app.request('/api/contacts/' + contact.id + '/addresses', { 318 | method: 'get', 319 | headers: { 320 | 'Authorization': 'test' 321 | } 322 | }) 323 | 324 | expect(response.status).toBe(200) 325 | const body = await response.json() 326 | expect(body.data).toBeDefined() 327 | expect(body.data.length).toBe(1) 328 | expect(body.data[0].id).toBe(address.id) 329 | expect(body.data[0].street).toBe(address.street) 330 | expect(body.data[0].city).toBe(address.city) 331 | expect(body.data[0].province).toBe(address.province) 332 | expect(body.data[0].country).toBe(address.country) 333 | expect(body.data[0].postal_code).toBe(address.postal_code) 334 | }); 335 | 336 | }) 337 | -------------------------------------------------------------------------------- /test/contact.test.ts: -------------------------------------------------------------------------------- 1 | import {expect, describe, it, beforeEach, afterEach} from "bun:test"; 2 | import {ContactTest, UserTest} from "./test-util"; 3 | import app from "../src"; 4 | 5 | describe('POST /api/contacts', () => { 6 | 7 | beforeEach(async () => { 8 | await ContactTest.deleteAll() 9 | await UserTest.create() 10 | }) 11 | 12 | afterEach(async () => { 13 | await ContactTest.deleteAll() 14 | await UserTest.delete() 15 | }) 16 | 17 | it('should rejected if token is not valid', async () => { 18 | const response = await app.request('/api/contacts', { 19 | method: 'post', 20 | headers: { 21 | 'Authorization': 'salah' 22 | }, 23 | body: JSON.stringify({ 24 | first_name: "" 25 | }) 26 | }) 27 | 28 | expect(response.status).toBe(401) 29 | 30 | const body = await response.json() 31 | expect(body.errors).toBeDefined() 32 | }); 33 | 34 | it('should rejected if contact is invalid', async () => { 35 | const response = await app.request('/api/contacts', { 36 | method: 'post', 37 | headers: { 38 | 'Authorization': 'test' 39 | }, 40 | body: JSON.stringify({ 41 | first_name: "" 42 | }) 43 | }) 44 | 45 | expect(response.status).toBe(400) 46 | 47 | const body = await response.json() 48 | expect(body.errors).toBeDefined() 49 | }); 50 | 51 | it('should success if contact is valid (only first_name)', async () => { 52 | const response = await app.request('/api/contacts', { 53 | method: 'post', 54 | headers: { 55 | 'Authorization': 'test' 56 | }, 57 | body: JSON.stringify({ 58 | first_name: "Eko" 59 | }) 60 | }) 61 | 62 | expect(response.status).toBe(200) 63 | 64 | const body = await response.json() 65 | expect(body.data).toBeDefined() 66 | expect(body.data.first_name).toBe("Eko") 67 | expect(body.data.last_name).toBeNull() 68 | expect(body.data.email).toBeNull() 69 | expect(body.data.phone).toBeNull() 70 | }); 71 | 72 | it('should success if contact is valid (full data)', async () => { 73 | const response = await app.request('/api/contacts', { 74 | method: 'post', 75 | headers: { 76 | 'Authorization': 'test' 77 | }, 78 | body: JSON.stringify({ 79 | first_name: "Eko", 80 | last_name: "Khannedy", 81 | email: "eko@gmail.com", 82 | phone: "23424234324" 83 | }) 84 | }) 85 | 86 | expect(response.status).toBe(200) 87 | 88 | const body = await response.json() 89 | expect(body.data).toBeDefined() 90 | expect(body.data.first_name).toBe("Eko") 91 | expect(body.data.last_name).toBe("Khannedy") 92 | expect(body.data.email).toBe("eko@gmail.com") 93 | expect(body.data.phone).toBe("23424234324") 94 | }); 95 | 96 | }); 97 | 98 | describe('GEt /api/contacts/{id}', () => { 99 | 100 | beforeEach(async () => { 101 | await ContactTest.deleteAll() 102 | await UserTest.create() 103 | await ContactTest.create() 104 | }) 105 | 106 | afterEach(async () => { 107 | await ContactTest.deleteAll() 108 | await UserTest.delete() 109 | }) 110 | 111 | it('should get 404 if contact is not found', async () => { 112 | const contact = await ContactTest.get() 113 | 114 | const response = await app.request('/api/contacts/' + (contact.id + 1), { 115 | method: 'get', 116 | headers: { 117 | 'Authorization': 'test' 118 | } 119 | }) 120 | 121 | expect(response.status).toBe(404) 122 | 123 | const body = await response.json() 124 | expect(body.errors).toBeDefined() 125 | }); 126 | 127 | it('should success if contact is exists', async () => { 128 | const contact = await ContactTest.get() 129 | 130 | const response = await app.request('/api/contacts/' + contact.id, { 131 | method: 'get', 132 | headers: { 133 | 'Authorization': 'test' 134 | } 135 | }) 136 | 137 | expect(response.status).toBe(200) 138 | 139 | const body = await response.json() 140 | expect(body.data).toBeDefined() 141 | expect(body.data.first_name).toBe(contact.first_name) 142 | expect(body.data.last_name).toBe(contact.last_name) 143 | expect(body.data.email).toBe(contact.email) 144 | expect(body.data.phone).toBe(contact.phone) 145 | expect(body.data.id).toBe(contact.id) 146 | }); 147 | }) 148 | 149 | describe('PUT /api/contacts/{id}', () => { 150 | 151 | beforeEach(async () => { 152 | await ContactTest.deleteAll() 153 | await UserTest.create() 154 | await ContactTest.create() 155 | }) 156 | 157 | afterEach(async () => { 158 | await ContactTest.deleteAll() 159 | await UserTest.delete() 160 | }) 161 | 162 | it('should rejected update contact if request is invalid', async () => { 163 | const contact = await ContactTest.get() 164 | 165 | const response = await app.request('/api/contacts/' + contact.id, { 166 | method: 'put', 167 | headers: { 168 | 'Authorization': 'test' 169 | }, 170 | body: JSON.stringify({ 171 | first_name: "" 172 | }) 173 | }) 174 | 175 | expect(response.status).toBe(400) 176 | 177 | const body = await response.json() 178 | expect(body.errors).toBeDefined() 179 | }); 180 | 181 | it('should rejected update contact if id is not found', async () => { 182 | const contact = await ContactTest.get() 183 | 184 | const response = await app.request('/api/contacts/' + (contact.id + 1), { 185 | method: 'put', 186 | headers: { 187 | 'Authorization': 'test' 188 | }, 189 | body: JSON.stringify({ 190 | first_name: "Eko" 191 | }) 192 | }) 193 | 194 | expect(response.status).toBe(404) 195 | 196 | const body = await response.json() 197 | expect(body.errors).toBeDefined() 198 | }); 199 | 200 | it('should success update contact if request is valid', async () => { 201 | const contact = await ContactTest.get() 202 | 203 | const response = await app.request('/api/contacts/' + contact.id, { 204 | method: 'put', 205 | headers: { 206 | 'Authorization': 'test' 207 | }, 208 | body: JSON.stringify({ 209 | first_name: "Eko", 210 | last_name: "Khannedy", 211 | email: "eko@gmail.com", 212 | phone: "1231234" 213 | }) 214 | }) 215 | 216 | expect(response.status).toBe(200) 217 | 218 | const body = await response.json() 219 | expect(body.data).toBeDefined() 220 | expect(body.data.first_name).toBe("Eko") 221 | expect(body.data.last_name).toBe("Khannedy") 222 | expect(body.data.email).toBe("eko@gmail.com") 223 | expect(body.data.phone).toBe("1231234") 224 | }); 225 | 226 | }); 227 | 228 | describe('DELETE /api/contacts/{id}', () => { 229 | 230 | beforeEach(async () => { 231 | await ContactTest.deleteAll() 232 | await UserTest.create() 233 | await ContactTest.create() 234 | }) 235 | 236 | afterEach(async () => { 237 | await ContactTest.deleteAll() 238 | await UserTest.delete() 239 | }) 240 | 241 | it('should rejected if contact id is not found', async () => { 242 | const contact = await ContactTest.get() 243 | 244 | const response = await app.request('/api/contacts/' + (contact.id + 1), { 245 | method: 'delete', 246 | headers: { 247 | 'Authorization': 'test' 248 | } 249 | }) 250 | 251 | expect(response.status).toBe(404) 252 | 253 | const body = await response.json() 254 | expect(body.errors).toBeDefined() 255 | }); 256 | 257 | it('should success if contact is exists', async () => { 258 | const contact = await ContactTest.get() 259 | 260 | const response = await app.request('/api/contacts/' + contact.id, { 261 | method: 'delete', 262 | headers: { 263 | 'Authorization': 'test' 264 | } 265 | }) 266 | 267 | expect(response.status).toBe(200) 268 | 269 | const body = await response.json() 270 | expect(body.data).toBe(true) 271 | }); 272 | 273 | }); 274 | 275 | describe('GET /api/contacts', () => { 276 | 277 | beforeEach(async () => { 278 | await ContactTest.deleteAll() 279 | await UserTest.create() 280 | await ContactTest.createMany(25) 281 | }) 282 | 283 | afterEach(async () => { 284 | await ContactTest.deleteAll() 285 | await UserTest.delete() 286 | }) 287 | 288 | it('should be able to search contact', async () => { 289 | const response = await app.request('/api/contacts', { 290 | method: 'get', 291 | headers: { 292 | 'Authorization': 'test' 293 | } 294 | }) 295 | 296 | expect(response.status).toBe(200) 297 | 298 | const body = await response.json() 299 | expect(body.data.length).toBe(10) 300 | expect(body.paging.current_page).toBe(1) 301 | expect(body.paging.size).toBe(10) 302 | expect(body.paging.total_page).toBe(3) 303 | }); 304 | 305 | it('should be able to search contact using name', async () => { 306 | let response = await app.request('/api/contacts?name=ko', { 307 | method: 'get', 308 | headers: { 309 | 'Authorization': 'test' 310 | } 311 | }) 312 | 313 | expect(response.status).toBe(200) 314 | 315 | let body = await response.json() 316 | expect(body.data.length).toBe(10) 317 | expect(body.paging.current_page).toBe(1) 318 | expect(body.paging.size).toBe(10) 319 | expect(body.paging.total_page).toBe(3) 320 | 321 | response = await app.request('/api/contacts?name=awan', { 322 | method: 'get', 323 | headers: { 324 | 'Authorization': 'test' 325 | } 326 | }) 327 | 328 | expect(response.status).toBe(200) 329 | 330 | body = await response.json() 331 | expect(body.data.length).toBe(10) 332 | expect(body.paging.current_page).toBe(1) 333 | expect(body.paging.size).toBe(10) 334 | expect(body.paging.total_page).toBe(3) 335 | }); 336 | 337 | it('should be able to search contact using email', async () => { 338 | let response = await app.request('/api/contacts?email=gmail', { 339 | method: 'get', 340 | headers: { 341 | 'Authorization': 'test' 342 | } 343 | }) 344 | 345 | expect(response.status).toBe(200) 346 | 347 | let body = await response.json() 348 | expect(body.data.length).toBe(10) 349 | expect(body.paging.current_page).toBe(1) 350 | expect(body.paging.size).toBe(10) 351 | expect(body.paging.total_page).toBe(3) 352 | }); 353 | 354 | it('should be able to search contact using phone', async () => { 355 | let response = await app.request('/api/contacts?phone=31', { 356 | method: 'get', 357 | headers: { 358 | 'Authorization': 'test' 359 | } 360 | }) 361 | 362 | expect(response.status).toBe(200) 363 | 364 | let body = await response.json() 365 | expect(body.data.length).toBe(10) 366 | expect(body.paging.current_page).toBe(1) 367 | expect(body.paging.size).toBe(10) 368 | expect(body.paging.total_page).toBe(3) 369 | }); 370 | 371 | it('should be able to search without result', async () => { 372 | let response = await app.request('/api/contacts?name=budi', { 373 | method: 'get', 374 | headers: { 375 | 'Authorization': 'test' 376 | } 377 | }) 378 | 379 | expect(response.status).toBe(200) 380 | 381 | let body = await response.json() 382 | expect(body.data.length).toBe(0) 383 | expect(body.paging.current_page).toBe(1) 384 | expect(body.paging.size).toBe(10) 385 | expect(body.paging.total_page).toBe(0) 386 | 387 | response = await app.request('/api/contacts?email=gakada', { 388 | method: 'get', 389 | headers: { 390 | 'Authorization': 'test' 391 | } 392 | }) 393 | 394 | expect(response.status).toBe(200) 395 | 396 | body = await response.json() 397 | expect(body.data.length).toBe(0) 398 | expect(body.paging.current_page).toBe(1) 399 | expect(body.paging.size).toBe(10) 400 | expect(body.paging.total_page).toBe(0) 401 | 402 | response = await app.request('/api/contacts?phone=gakada', { 403 | method: 'get', 404 | headers: { 405 | 'Authorization': 'test' 406 | } 407 | }) 408 | 409 | expect(response.status).toBe(200) 410 | 411 | body = await response.json() 412 | expect(body.data.length).toBe(0) 413 | expect(body.paging.current_page).toBe(1) 414 | expect(body.paging.size).toBe(10) 415 | expect(body.paging.total_page).toBe(0) 416 | }); 417 | 418 | it('should be able to search with paging', async () => { 419 | let response = await app.request('/api/contacts?size=5', { 420 | method: 'get', 421 | headers: { 422 | 'Authorization': 'test' 423 | } 424 | }) 425 | 426 | expect(response.status).toBe(200) 427 | 428 | let body = await response.json() 429 | expect(body.data.length).toBe(5) 430 | expect(body.paging.current_page).toBe(1) 431 | expect(body.paging.size).toBe(5) 432 | expect(body.paging.total_page).toBe(5) 433 | 434 | response = await app.request('/api/contacts?size=5&page=2', { 435 | method: 'get', 436 | headers: { 437 | 'Authorization': 'test' 438 | } 439 | }) 440 | 441 | expect(response.status).toBe(200) 442 | 443 | body = await response.json() 444 | expect(body.data.length).toBe(5) 445 | expect(body.paging.current_page).toBe(2) 446 | expect(body.paging.size).toBe(5) 447 | expect(body.paging.total_page).toBe(5) 448 | 449 | response = await app.request('/api/contacts?size=5&page=100', { 450 | method: 'get', 451 | headers: { 452 | 'Authorization': 'test' 453 | } 454 | }) 455 | 456 | expect(response.status).toBe(200) 457 | 458 | body = await response.json() 459 | expect(body.data.length).toBe(0) 460 | expect(body.paging.current_page).toBe(100) 461 | expect(body.paging.size).toBe(5) 462 | expect(body.paging.total_page).toBe(5) 463 | }); 464 | 465 | }); 466 | -------------------------------------------------------------------------------- /test/test-util.ts: -------------------------------------------------------------------------------- 1 | import {prismaClient} from "../src/application/database"; 2 | import {Address, Contact} from "@prisma/client"; 3 | 4 | export class UserTest { 5 | 6 | static async create() { 7 | await prismaClient.user.create({ 8 | data: { 9 | username: "test", 10 | name: "test", 11 | password: await Bun.password.hash("test", { 12 | algorithm: "bcrypt", 13 | cost: 10 14 | }), 15 | token: "test" 16 | } 17 | }) 18 | } 19 | 20 | static async delete() { 21 | await prismaClient.user.deleteMany({ 22 | where: { 23 | username: "test" 24 | } 25 | }) 26 | } 27 | 28 | } 29 | 30 | export class ContactTest { 31 | 32 | static async deleteAll() { 33 | await prismaClient.contact.deleteMany({ 34 | where: { 35 | username: 'test' 36 | } 37 | }) 38 | } 39 | 40 | static async create() { 41 | await prismaClient.contact.create({ 42 | data: { 43 | first_name: "Eko", 44 | last_name: "Kurniawan", 45 | email: "test@gmail.com", 46 | phone: "123123", 47 | username: "test" 48 | } 49 | }) 50 | } 51 | 52 | static async createMany(n: number) { 53 | for (let i = 0; i < n; i++) { 54 | await this.create() 55 | } 56 | } 57 | 58 | static async get(): Promise { 59 | return prismaClient.contact.findFirstOrThrow({ 60 | where: { 61 | username: "test" 62 | } 63 | }) 64 | } 65 | 66 | } 67 | 68 | export class AddressTest { 69 | 70 | static async create() { 71 | const contact = await ContactTest.get() 72 | await prismaClient.address.create({ 73 | data: { 74 | contact_id: contact.id, 75 | street: "Jalan", 76 | city: "Kota", 77 | province: "Provinsi", 78 | country: "Indonesia", 79 | postal_code: "12345" 80 | } 81 | }) 82 | } 83 | 84 | static async get(): Promise
{ 85 | return prismaClient.address.findFirstOrThrow({ 86 | where: { 87 | contact: { 88 | username: "test" 89 | } 90 | } 91 | }) 92 | } 93 | 94 | static async deleteAll() { 95 | await prismaClient.address.deleteMany({ 96 | where: { 97 | contact: { 98 | username: 'test' 99 | } 100 | } 101 | }) 102 | } 103 | 104 | } 105 | -------------------------------------------------------------------------------- /test/user.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, it, expect, afterEach, beforeEach} from "bun:test" 2 | import app from "../src"; 3 | import {logger} from "../src/application/logging"; 4 | import {UserTest} from "./test-util"; 5 | 6 | describe('POST /api/users', () => { 7 | 8 | afterEach(async () => { 9 | await UserTest.delete(); 10 | }) 11 | 12 | it('should reject register new user if request is invalid', async () => { 13 | const response = await app.request('/api/users', { 14 | method: 'post', 15 | body: JSON.stringify({ 16 | username: "", 17 | password: "", 18 | name: "" 19 | }) 20 | }) 21 | 22 | const body = await response.json() 23 | logger.debug(body) 24 | 25 | expect(response.status).toBe(400) 26 | expect(body.errors).toBeDefined() 27 | }); 28 | 29 | it('should reject register new user if username already exists', async () => { 30 | await UserTest.create(); 31 | 32 | const response = await app.request('/api/users', { 33 | method: 'post', 34 | body: JSON.stringify({ 35 | username: "test", 36 | password: "test", 37 | name: "test" 38 | }) 39 | }) 40 | 41 | const body = await response.json() 42 | logger.debug(body) 43 | 44 | expect(response.status).toBe(400) 45 | expect(body.errors).toBeDefined() 46 | }); 47 | 48 | it('should register new user success', async () => { 49 | const response = await app.request('/api/users', { 50 | method: 'post', 51 | body: JSON.stringify({ 52 | username: "test", 53 | password: "test", 54 | name: "test" 55 | }) 56 | }) 57 | 58 | const body = await response.json() 59 | logger.debug(body) 60 | 61 | expect(response.status).toBe(200) 62 | expect(body.data).toBeDefined() 63 | expect(body.data.username).toBe("test") 64 | expect(body.data.name).toBe("test") 65 | }); 66 | 67 | }) 68 | 69 | describe('POST /api/users/login', () => { 70 | 71 | beforeEach(async () => { 72 | await UserTest.create() 73 | }) 74 | 75 | afterEach(async () => { 76 | await UserTest.delete() 77 | }) 78 | 79 | it('should be able to login', async () => { 80 | const response = await app.request('/api/users/login', { 81 | method: 'post', 82 | body: JSON.stringify({ 83 | username: "test", 84 | password: "test" 85 | }) 86 | }) 87 | 88 | expect(response.status).toBe(200) 89 | 90 | const body = await response.json(); 91 | expect(body.data.token).toBeDefined(); 92 | }); 93 | 94 | it('should be rejected if username is wrong', async () => { 95 | const response = await app.request('/api/users/login', { 96 | method: 'post', 97 | body: JSON.stringify({ 98 | username: "salah", 99 | password: "test" 100 | }) 101 | }) 102 | 103 | expect(response.status).toBe(401) 104 | 105 | const body = await response.json(); 106 | expect(body.errors).toBeDefined(); 107 | }); 108 | 109 | it('should be rejected if password is wrong', async () => { 110 | const response = await app.request('/api/users/login', { 111 | method: 'post', 112 | body: JSON.stringify({ 113 | username: "test", 114 | password: "salah" 115 | }) 116 | }) 117 | 118 | expect(response.status).toBe(401) 119 | 120 | const body = await response.json(); 121 | expect(body.errors).toBeDefined(); 122 | }); 123 | 124 | }); 125 | 126 | describe('GET /api/users/current', () => { 127 | 128 | beforeEach(async () => { 129 | await UserTest.create() 130 | }) 131 | 132 | afterEach(async () => { 133 | await UserTest.delete() 134 | }) 135 | 136 | it('should be able to get user', async () => { 137 | const response = await app.request('/api/users/current', { 138 | method: 'get', 139 | headers: { 140 | 'Authorization': 'test' 141 | } 142 | }) 143 | 144 | expect(response.status).toBe(200) 145 | 146 | const body = await response.json() 147 | expect(body.data).toBeDefined() 148 | expect(body.data.username).toBe("test") 149 | expect(body.data.name).toBe("test") 150 | }); 151 | 152 | it('should not be able to get user if token is invalid', async () => { 153 | const response = await app.request('/api/users/current', { 154 | method: 'get', 155 | headers: { 156 | 'Authorization': 'salah' 157 | } 158 | }) 159 | 160 | expect(response.status).toBe(401) 161 | 162 | const body = await response.json() 163 | expect(body.errors).toBeDefined() 164 | }); 165 | 166 | it('should not be able to get user if there is no Authorization header', async () => { 167 | const response = await app.request('/api/users/current', { 168 | method: 'get' 169 | }) 170 | 171 | expect(response.status).toBe(401) 172 | 173 | const body = await response.json() 174 | expect(body.errors).toBeDefined() 175 | }); 176 | 177 | }); 178 | 179 | describe('PATCH /api/users/current', () => { 180 | 181 | beforeEach(async () => { 182 | await UserTest.create() 183 | }) 184 | 185 | afterEach(async () => { 186 | await UserTest.delete() 187 | }) 188 | 189 | it('should be rejected if request is invalid', async () => { 190 | const response = await app.request('/api/users/current', { 191 | method: 'patch', 192 | headers: { 193 | 'Authorization': 'test' 194 | }, 195 | body: JSON.stringify({ 196 | name: "", 197 | password: "" 198 | }) 199 | }) 200 | 201 | expect(response.status).toBe(400) 202 | 203 | const body = await response.json() 204 | expect(body.errors).toBeDefined() 205 | }); 206 | 207 | it('should be able to update name', async () => { 208 | const response = await app.request('/api/users/current', { 209 | method: 'patch', 210 | headers: { 211 | 'Authorization': 'test' 212 | }, 213 | body: JSON.stringify({ 214 | name: "Eko" 215 | }) 216 | }) 217 | 218 | expect(response.status).toBe(200) 219 | 220 | const body = await response.json() 221 | logger.error(body) 222 | expect(body.data).toBeDefined() 223 | expect(body.data.name).toBe("Eko") 224 | }); 225 | 226 | it('should be able to update password', async () => { 227 | let response = await app.request('/api/users/current', { 228 | method: 'patch', 229 | headers: { 230 | 'Authorization': 'test' 231 | }, 232 | body: JSON.stringify({ 233 | password: "baru" 234 | }) 235 | }) 236 | 237 | expect(response.status).toBe(200) 238 | 239 | const body = await response.json() 240 | logger.error(body) 241 | expect(body.data).toBeDefined() 242 | expect(body.data.name).toBe("test") 243 | 244 | response = await app.request('/api/users/login', { 245 | method: 'post', 246 | body: JSON.stringify({ 247 | username: "test", 248 | password: "baru" 249 | }) 250 | }) 251 | 252 | expect(response.status).toBe(200) 253 | }); 254 | 255 | }); 256 | 257 | describe('DELETE /api/users/current', () => { 258 | 259 | beforeEach(async () => { 260 | await UserTest.create() 261 | }) 262 | 263 | afterEach(async () => { 264 | await UserTest.delete() 265 | }) 266 | 267 | it('should be able to logout', async () => { 268 | const response = await app.request('/api/users/current', { 269 | method: 'delete', 270 | headers: { 271 | 'Authorization': 'test' 272 | } 273 | }) 274 | 275 | expect(response.status).toBe(200) 276 | 277 | const body = await response.json() 278 | expect(body.data).toBe(true) 279 | }); 280 | 281 | it('should not be able to logout', async () => { 282 | let response = await app.request('/api/users/current', { 283 | method: 'delete', 284 | headers: { 285 | 'Authorization': 'test' 286 | } 287 | }) 288 | 289 | expect(response.status).toBe(200) 290 | 291 | const body = await response.json() 292 | expect(body.data).toBe(true) 293 | 294 | response = await app.request('/api/users/current', { 295 | method: 'delete', 296 | headers: { 297 | 'Authorization': 'test' 298 | } 299 | }) 300 | expect(response.status).toBe(401) 301 | }); 302 | 303 | }); 304 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "jsx": "react-jsx", 5 | "jsxImportSource": "hono/jsx" 6 | } 7 | } --------------------------------------------------------------------------------