feat: add accounts and movies tables in migrations

- Created migration for accounts table with fields: id, username, password, role_id, created_at, updated_at.
- Created migration for movies table with fields: id, title, overview, poster_path, backdrop_path, release_date, tmdb_id.

refactor: update package.json scripts and dependencies

- Changed dev script to use bun instead of tsx.
- Added build:migrate script for migration.
- Updated devDependencies for bun and oxlint.

fix: refactor database connection and migration execution

- Updated PgDatabase to use SQL from bun.
- Refactored migration execution logic to read SQL files and execute them.

feat: implement account creation and validation logic

- Updated AccountEntity to use username instead of email.
- Added validation for username format and password strength.
- Implemented account repository methods for finding by username and inserting accounts.

test: add tests for account entity, repository, and service

- Created tests for AccountEntity to validate username and password.
- Added tests for AccountRepository to ensure correct database interactions.
- Implemented tests for AccountService to validate registration and login logic.

chore: remove outdated tests and files

- Deleted old tests related to email-based account management.
- Cleaned up unused imports and files to streamline the codebase.
This commit is contained in:
2025-10-02 20:54:41 +00:00
parent 91c14f750e
commit aaa0ca5a54
28 changed files with 617 additions and 654 deletions

View File

@@ -1,3 +1,3 @@
{ {
"plugins": ["@prettier/plugin-oxc"] "plugins": ["@prettier/plugin-oxc", "prettier-plugin-sql"]
} }

View File

@@ -3,4 +3,4 @@
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"typescript.tsdk": "node_modules/typescript/lib" "typescript.tsdk": "node_modules/typescript/lib"
} }

185
bun.lock
View File

@@ -5,21 +5,16 @@
"name": "nixi-api", "name": "nixi-api",
"dependencies": { "dependencies": {
"@hono/zod-validator": "^0.7.3", "@hono/zod-validator": "^0.7.3",
"bcrypt": "^6.0.0",
"hono": "^4.9.8", "hono": "^4.9.8",
"pg": "^8.16.3",
"zod": "^4.1.11", "zod": "^4.1.11",
}, },
"devDependencies": { "devDependencies": {
"@prettier/plugin-oxc": "^0.0.4", "@prettier/plugin-oxc": "^0.0.4",
"@types/bcrypt": "^6.0.0", "@types/bun": "1.2.23",
"@types/bun": "latest",
"@types/node": "^24.2.1", "@types/node": "^24.2.1",
"@types/pg": "^8.15.5", "oxlint": "^1.19.0",
"oxlint": "^1.16.0",
"prettier": "3.6.2", "prettier": "3.6.2",
"rolldown": "^1.0.0-beta.39", "prettier-plugin-sql": "^0.19.2",
"tsx": "^4.20.5",
"typescript": "^5.9.2", "typescript": "^5.9.2",
}, },
}, },
@@ -31,61 +26,9 @@
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.10", "", { "os": "aix", "cpu": "ppc64" }, "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.10", "", { "os": "android", "cpu": "arm" }, "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.10", "", { "os": "android", "cpu": "arm64" }, "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.10", "", { "os": "android", "cpu": "x64" }, "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.10", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.10", "", { "os": "linux", "cpu": "arm" }, "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.10", "", { "os": "linux", "cpu": "ia32" }, "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.10", "", { "os": "linux", "cpu": "x64" }, "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.10", "", { "os": "none", "cpu": "arm64" }, "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.10", "", { "os": "none", "cpu": "x64" }, "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.10", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.10", "", { "os": "openbsd", "cpu": "x64" }, "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.10", "", { "os": "none", "cpu": "arm64" }, "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.10", "", { "os": "sunos", "cpu": "x64" }, "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.10", "", { "os": "win32", "cpu": "ia32" }, "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.10", "", { "os": "win32", "cpu": "x64" }, "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw=="],
"@hono/zod-validator": ["@hono/zod-validator@0.7.3", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-uYGdgVib3RlGD698WR5dVM0zB3UuPY5vHKXffGUbUh7r4xY+mFIhF3/v4AcQVLrU5CQdBso8BJr4wuVoCrjTuQ=="], "@hono/zod-validator": ["@hono/zod-validator@0.7.3", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-uYGdgVib3RlGD698WR5dVM0zB3UuPY5vHKXffGUbUh7r4xY+mFIhF3/v4AcQVLrU5CQdBso8BJr4wuVoCrjTuQ=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.5", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-TBr9Cf9onSAS2LQ2+QHx6XcC6h9+RIzJgbqG3++9TUZSH204AwEy5jg3BTQ0VATsyoGj4ee49tN/y6rvaOOtcg=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.74.0", "", { "os": "android", "cpu": "arm64" }, "sha512-lgq8TJq22eyfojfa2jBFy2m66ckAo7iNRYDdyn9reXYA3I6Wx7tgGWVx1JAp1lO+aUiqdqP/uPlDaETL9tqRcg=="], "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.74.0", "", { "os": "android", "cpu": "arm64" }, "sha512-lgq8TJq22eyfojfa2jBFy2m66ckAo7iNRYDdyn9reXYA3I6Wx7tgGWVx1JAp1lO+aUiqdqP/uPlDaETL9tqRcg=="],
@@ -117,138 +60,80 @@
"@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.74.0", "", { "os": "win32", "cpu": "x64" }, "sha512-1D3x6iU2apLyfTQHygbdaNbX3nZaHu4yaXpD7ilYpoLo7f0MX0tUuoDrqJyJrVGqvyXgc0uz4yXz9tH9ZZhvvg=="], "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.74.0", "", { "os": "win32", "cpu": "x64" }, "sha512-1D3x6iU2apLyfTQHygbdaNbX3nZaHu4yaXpD7ilYpoLo7f0MX0tUuoDrqJyJrVGqvyXgc0uz4yXz9tH9ZZhvvg=="],
"@oxc-project/types": ["@oxc-project/types@0.90.0", "", {}, "sha512-fWvaufWUcLtm/OBKcNmxUkR0kQW5ZKAF0t03BXPqdzpxmnVCmSKzvUDRCOKnSagSfNzG/3ZdKpComH3GMy881g=="], "@oxc-project/types": ["@oxc-project/types@0.74.0", "", {}, "sha512-KOw/RZrVlHGhCXh1RufBFF7Nuo7HdY5w1lRJukM/igIl6x9qtz8QycDvZdzb4qnHO7znrPyo2sJrFJK2eKHgfQ=="],
"@oxlint/darwin-arm64": ["@oxlint/darwin-arm64@1.16.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-t9sBjbcG15Jgwgw2wY+rtfKEazdkKM/YhcdyjmGYeSjBXaczLfp/gZe03taC2qUHK+t6cxSYNkOLXRLWxaf3tw=="], "@oxlint/darwin-arm64": ["@oxlint/darwin-arm64@1.19.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dSozp6FXowhFEjmT0FC/iBWj9KziWfixxaYT367kOXZUyA0hvOzsLsBB780Swr40zvqklUR0d3fbZbziGHRJoQ=="],
"@oxlint/darwin-x64": ["@oxlint/darwin-x64@1.16.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-c9aeLQATeu27TK8gR/p8GfRBsuakx0zs+6UHFq/s8Kux+8tYb3pH1pql/XWUPbxubv48F2MpnD5zgjOrShAgag=="], "@oxlint/darwin-x64": ["@oxlint/darwin-x64@1.19.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-3OY1km70zTlH6b8K8AHSuaEaa4sntmAcBugMZBaJmHkioia7zxlAQV9xtQ2wsBSDQbBmcf1j5Y0NcHP7fmIZvA=="],
"@oxlint/linux-arm64-gnu": ["@oxlint/linux-arm64-gnu@1.16.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZoBtxtRHhftbiKKeScpgUKIg4cu9s7rsBPCkjfMCY0uLjhKqm6ShPEaIuP8515+/Csouciz1ViZhbrya5ligAg=="], "@oxlint/linux-arm64-gnu": ["@oxlint/linux-arm64-gnu@1.19.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-TS9wmx9B/v1f/bNXu3lIEcdNIyS0m0H0+95YIWSTGG3q2cK3FVlyUiiAieZRUzXTN89n6JXtua6dK/TVCqbmkQ=="],
"@oxlint/linux-arm64-musl": ["@oxlint/linux-arm64-musl@1.16.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-a/Dys7CTyj1eZIkD59k9Y3lp5YsHBUeZXR7qHTplKb41H+Ivm5OQPf+rfbCBSLMfCPZCeKQPW36GXOSYLNE1uw=="], "@oxlint/linux-arm64-musl": ["@oxlint/linux-arm64-musl@1.19.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-o5RAxQfVEu7LsBUwSjEDNdM8sla8WlLMRULsTP3vgxyy1eLJxo2u+4McKtM9/P2KiZQw3NylDoaxU4Z4j/XeRQ=="],
"@oxlint/linux-x64-gnu": ["@oxlint/linux-x64-gnu@1.16.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rsfv90ytLhl+s7aa8eE8gGwB1XGbiUA2oyUee/RhGRyeoZoe9/hHNtIcE2XndMYlJToROKmGyrTN4MD2c0xxLQ=="], "@oxlint/linux-x64-gnu": ["@oxlint/linux-x64-gnu@1.19.0", "", { "os": "linux", "cpu": "x64" }, "sha512-QDgAP4TxXsupFEsEGYnaAaKXQQD1lJSi5Htl/b0Vl2xPz8BVBRH+bNDwVGEHVTxT7jdnO2gTEOmfEzOkRJprUQ=="],
"@oxlint/linux-x64-musl": ["@oxlint/linux-x64-musl@1.16.0", "", { "os": "linux", "cpu": "x64" }, "sha512-djwSL4harw46kdCwaORUvApyE9Y6JSnJ7pF5PHcQlJ7S1IusfjzYljXky4hONPO0otvXWdKq1GpJqhmtM0/xbg=="], "@oxlint/linux-x64-musl": ["@oxlint/linux-x64-musl@1.19.0", "", { "os": "linux", "cpu": "x64" }, "sha512-iOQooyYzy7RR2yHNM8oHd2Zw6CdU7/G2Uf5ryFi/cF5NV5zlSH//QSkWwrk/kLF69wKqwE8S8snV7WnRA/tXjA=="],
"@oxlint/win32-arm64": ["@oxlint/win32-arm64@1.16.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-lQBfW4hBiQ47P12UAFXyX3RVHlWCSYp6I89YhG+0zoLipxAfyB37P8G8N43T/fkUaleb8lvt0jyNG6jQTkCmhg=="], "@oxlint/win32-arm64": ["@oxlint/win32-arm64@1.19.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-bvgA2fGpdBF/DpB5hZYQzx5fFFiiHxIiPF5zp24czvsIRkezVi9ZH04lCIVkMBxgvKhnU2jLXAn6E1Mbo4QrFw=="],
"@oxlint/win32-x64": ["@oxlint/win32-x64@1.16.0", "", { "os": "win32", "cpu": "x64" }, "sha512-B5se3JnM4Xu6uHF78hAY9wdk/sdLFib1YwFsLY6rkQKEMFyi+vMZZlDaAS+s+Dt9q7q881U2OhNznZenJZdPdQ=="], "@oxlint/win32-x64": ["@oxlint/win32-x64@1.19.0", "", { "os": "win32", "cpu": "x64" }, "sha512-PloVn/e1kfMsiH0urM4XIhiY0TdqDjwJlzeX8pIKDmxUsKHsjcU8fmddsZSt7K16C2nR3SQVoso2AIR00mRieA=="],
"@prettier/plugin-oxc": ["@prettier/plugin-oxc@0.0.4", "", { "dependencies": { "oxc-parser": "0.74.0" } }, "sha512-UGXe+g/rSRbglL0FOJiar+a+nUrst7KaFmsg05wYbKiInGWP6eAj/f8A2Uobgo5KxEtb2X10zeflNH6RK2xeIQ=="], "@prettier/plugin-oxc": ["@prettier/plugin-oxc@0.0.4", "", { "dependencies": { "oxc-parser": "0.74.0" } }, "sha512-UGXe+g/rSRbglL0FOJiar+a+nUrst7KaFmsg05wYbKiInGWP6eAj/f8A2Uobgo5KxEtb2X10zeflNH6RK2xeIQ=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-beta.39", "", { "os": "android", "cpu": "arm64" }, "sha512-mjraAJQ3VRLPb3BUgVigHvmAYhiBpEeSM0dhvaO6XHtJ0k1o9Ng1Z6Qvlp4/1wDiUf7a10L5c3yleoGZ2r0Maw=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-beta.39", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tnuiLq9vd08KsZeFkFgzCXVKsTgSZGn+YBQjHSEiUvXJy5pfUf82X/YyLCG8P6I+WDd2cgrcLilMBQPZgaNwkg=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-beta.39", "", { "os": "darwin", "cpu": "x64" }, "sha512-wLFoB3ZM4AoeBlsP0eVbPzWfkEgvmnibMQEKUgWRfJnKhUWiSxl0kGdSw1fNYdX3KAqIeA5gPJNvSJmf6g5S3Q=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-beta.39", "", { "os": "freebsd", "cpu": "x64" }, "sha512-wzFZlixF9VMbyi++rHCU4Cy72SH11aBNnkadmvwTAbokwjYHi8NqxQ3/Lx00c700N6kwwuiTsbcGt5DEA9aROw=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.39", "", { "os": "linux", "cpu": "arm" }, "sha512-eVnZcwGbje1uwdFjeQZQ6918RHgGIK7iTC+AoDsgetgAXQmQpnuWYQ9OWa5oTHNQyCkZbMfiHKgpkUPpceMecw=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-beta.39", "", { "os": "linux", "cpu": "arm64" }, "sha512-Td96iRQA0nmRZM6kJ3+LDDKWLh4bl0zqeR+IYxXwPZBw4iXSREzXrcZ3QqgFHqnXPgryIJEW1U1Ebh2xf+b2UA=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-beta.39", "", { "os": "linux", "cpu": "arm64" }, "sha512-bcSIh1TFUoPcexJH+gO1sE6wpSR0j3UpWBnjAwyM1PRKfjtqN4R9Du90ofH5KsR/A35FT3eP4mdnhMDTd5Yt+A=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-beta.39", "", { "os": "linux", "cpu": "x64" }, "sha512-tYEcZdVGovEemh7ELr+VUoezGkuBgRZYvDHHW/HVIw9LQW5HKLtBIGLzFlOfu/Lq5b9FlDKl+lrY6weviaNnKw=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-beta.39", "", { "os": "linux", "cpu": "x64" }, "sha512-xf9QdMC+qwQxtFAty/9RxgCLFdp9pFl09g86hxGPzlzCtHUjd+BmeUnUTXvVC8CHJLWECLQbFP6/233XHG0blA=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-beta.39", "", { "os": "none", "cpu": "arm64" }, "sha512-QCvN02VpE6zFYry0zAU+29D5+O9tJELNt+OjuCubilZdD/S8xFdho7qBJaa3YhFYyA9cReOMVH8Z8b3yWb4hcA=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-beta.39", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.0.5" }, "cpu": "none" }, "sha512-LFgshxApyBNiBHFVpun7tPrIQ4TvxW0f/endC5C4RzEHu7mxexBCQEkO5XrZ42Cr5DUY+ERNbkfNTUv+vVCaxQ=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-beta.39", "", { "os": "win32", "cpu": "arm64" }, "sha512-Mykirawg+s1e0uzVSEFhUBTShvXrOghPnyuLYkCfw8gzy8bMYiJuxsAfcopzZIIAVOHeSblJoiA/e7gYFjg8HA=="],
"@rolldown/binding-win32-ia32-msvc": ["@rolldown/binding-win32-ia32-msvc@1.0.0-beta.39", "", { "os": "win32", "cpu": "ia32" }, "sha512-4PQJfWx7mdzXbAa4y+3OSSo911BZyJ/Is4pJKiwcGUqtvY66MX7BqlNWMr9QAozArAGE2knDubLqCQwZpK631w=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-beta.39", "", { "os": "win32", "cpu": "x64" }, "sha512-0zmmPOWbFfp1g9ofieimHwhuclZMcib0HL52Q+JTRpOHChI2f83TtH3duKWtAaxqhLUndTr/Z5sxzb+G2FNL9g=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.39", "", {}, "sha512-GkTtNCV8ObWbq3LrJStPBv9jkRPct8WlwotVjx3aU0RwfH3LyheixWK9Zhaj22C4EQj/TJxYyetoX+uOn/MWKw=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/bcrypt": ["@types/bcrypt@6.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ=="], "@types/bun": ["@types/bun@1.2.23", "", { "dependencies": { "bun-types": "1.2.23" } }, "sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A=="],
"@types/bun": ["@types/bun@1.2.22", "", { "dependencies": { "bun-types": "1.2.22" } }, "sha512-5A/KrKos2ZcN0c6ljRSOa1fYIyCKhZfIVYeuyb4snnvomnpFqC0tTsEkdqNxbAgExV384OETQ//WAjl3XbYqQA=="],
"@types/node": ["@types/node@24.5.2", "", { "dependencies": { "undici-types": "~7.12.0" } }, "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ=="], "@types/node": ["@types/node@24.5.2", "", { "dependencies": { "undici-types": "~7.12.0" } }, "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ=="],
"@types/pg": ["@types/pg@8.15.5", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ=="], "@types/pegjs": ["@types/pegjs@0.10.6", "", {}, "sha512-eLYXDbZWXh2uxf+w8sXS8d6KSoXTswfps6fvCUuVAGN8eRpfe7h9eSRydxiSJvo9Bf+GzifsDOr9TMQlmJdmkw=="],
"@types/react": ["@types/react@19.1.13", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ=="], "@types/react": ["@types/react@19.1.13", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ=="],
"ansis": ["ansis@4.1.0", "", {}, "sha512-BGcItUBWSMRgOCe+SVZJ+S7yTRG0eGt9cXAHev72yuGcY23hnLA7Bky5L/xLyPINoSN95geovfBkqoTlNZYa7w=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"bcrypt": ["bcrypt@6.0.0", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" } }, "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg=="], "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="],
"bun-types": ["bun-types@1.2.22", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-hwaAu8tct/Zn6Zft4U9BsZcXkYomzpHJX28ofvx7k0Zz2HNz54n1n+tDgxoWFGB4PcFvJXJQloPhaV2eP3Q6EA=="], "bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw=="],
"commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="], "discontinuous-range": ["discontinuous-range@1.0.0", "", {}, "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"get-tsconfig": ["get-tsconfig@4.10.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ=="],
"hono": ["hono@4.9.8", "", {}, "sha512-JW8Bb4RFWD9iOKxg5PbUarBYGM99IcxFl2FPBo2gSJO11jjUDqlP1Bmfyqt8Z/dGhIQ63PMA9LdcLefXyIasyg=="], "hono": ["hono@4.9.8", "", {}, "sha512-JW8Bb4RFWD9iOKxg5PbUarBYGM99IcxFl2FPBo2gSJO11jjUDqlP1Bmfyqt8Z/dGhIQ63PMA9LdcLefXyIasyg=="],
"node-addon-api": ["node-addon-api@8.5.0", "", {}, "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A=="], "jsox": ["jsox@1.2.123", "", { "bin": { "jsox": "lib/cli.js" } }, "sha512-LYordXJ/0Q4G8pUE1Pvh4fkfGvZY7lRe4WIJKl0wr0rtFDVw9lcdNW95GH0DceJ6E9xh41zJNW0vreEz7xOxCw=="],
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], "moo": ["moo@0.5.2", "", {}, "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q=="],
"nearley": ["nearley@2.20.1", "", { "dependencies": { "commander": "^2.19.0", "moo": "^0.5.0", "railroad-diagrams": "^1.0.0", "randexp": "0.4.6" }, "bin": { "nearleyc": "bin/nearleyc.js", "nearley-test": "bin/nearley-test.js", "nearley-unparse": "bin/nearley-unparse.js", "nearley-railroad": "bin/nearley-railroad.js" } }, "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ=="],
"node-sql-parser": ["node-sql-parser@5.3.12", "", { "dependencies": { "@types/pegjs": "^0.10.0", "big-integer": "^1.6.48" } }, "sha512-GQBwA2e44qjbK0MzFwh5bNYefniV6cKT4KfjNDpuh/2EWipUEK1BCMc//moSidp8EF6fHB/EqJwUH9GCh9MAPg=="],
"oxc-parser": ["oxc-parser@0.74.0", "", { "dependencies": { "@oxc-project/types": "^0.74.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm64": "0.74.0", "@oxc-parser/binding-darwin-arm64": "0.74.0", "@oxc-parser/binding-darwin-x64": "0.74.0", "@oxc-parser/binding-freebsd-x64": "0.74.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.74.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.74.0", "@oxc-parser/binding-linux-arm64-gnu": "0.74.0", "@oxc-parser/binding-linux-arm64-musl": "0.74.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.74.0", "@oxc-parser/binding-linux-s390x-gnu": "0.74.0", "@oxc-parser/binding-linux-x64-gnu": "0.74.0", "@oxc-parser/binding-linux-x64-musl": "0.74.0", "@oxc-parser/binding-wasm32-wasi": "0.74.0", "@oxc-parser/binding-win32-arm64-msvc": "0.74.0", "@oxc-parser/binding-win32-x64-msvc": "0.74.0" } }, "sha512-2tDN/ttU8WE6oFh8EzKNam7KE7ZXSG5uXmvX85iNzxdJfMssDWcj3gpYzZi1E04XuE7m3v1dVWl/8BE886vPGw=="], "oxc-parser": ["oxc-parser@0.74.0", "", { "dependencies": { "@oxc-project/types": "^0.74.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm64": "0.74.0", "@oxc-parser/binding-darwin-arm64": "0.74.0", "@oxc-parser/binding-darwin-x64": "0.74.0", "@oxc-parser/binding-freebsd-x64": "0.74.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.74.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.74.0", "@oxc-parser/binding-linux-arm64-gnu": "0.74.0", "@oxc-parser/binding-linux-arm64-musl": "0.74.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.74.0", "@oxc-parser/binding-linux-s390x-gnu": "0.74.0", "@oxc-parser/binding-linux-x64-gnu": "0.74.0", "@oxc-parser/binding-linux-x64-musl": "0.74.0", "@oxc-parser/binding-wasm32-wasi": "0.74.0", "@oxc-parser/binding-win32-arm64-msvc": "0.74.0", "@oxc-parser/binding-win32-x64-msvc": "0.74.0" } }, "sha512-2tDN/ttU8WE6oFh8EzKNam7KE7ZXSG5uXmvX85iNzxdJfMssDWcj3gpYzZi1E04XuE7m3v1dVWl/8BE886vPGw=="],
"oxlint": ["oxlint@1.16.0", "", { "optionalDependencies": { "@oxlint/darwin-arm64": "1.16.0", "@oxlint/darwin-x64": "1.16.0", "@oxlint/linux-arm64-gnu": "1.16.0", "@oxlint/linux-arm64-musl": "1.16.0", "@oxlint/linux-x64-gnu": "1.16.0", "@oxlint/linux-x64-musl": "1.16.0", "@oxlint/win32-arm64": "1.16.0", "@oxlint/win32-x64": "1.16.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.2.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint", "oxc_language_server": "bin/oxc_language_server" } }, "sha512-o6z8s6QVw/d7QuxQ7QFfqDMrIcmHyU3J/MewxjqduJmy4vHt/s7OZISk8zEXjHXZzTWrcFakIrLqU/b9IKTcjg=="], "oxlint": ["oxlint@1.19.0", "", { "optionalDependencies": { "@oxlint/darwin-arm64": "1.19.0", "@oxlint/darwin-x64": "1.19.0", "@oxlint/linux-arm64-gnu": "1.19.0", "@oxlint/linux-arm64-musl": "1.19.0", "@oxlint/linux-x64-gnu": "1.19.0", "@oxlint/linux-x64-musl": "1.19.0", "@oxlint/win32-arm64": "1.19.0", "@oxlint/win32-x64": "1.19.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.2.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint", "oxc_language_server": "bin/oxc_language_server" } }, "sha512-MGeclRJFKaROXcPKMHOuJpOhbC4qkbLeZqSlelQioV/5YeBk/qVYZafUUpVO/yQ28Pld3srsTQusFtPNkVuvNA=="],
"pg": ["pg@8.16.3", "", { "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", "pg-protocol": "^1.10.3", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.2.7" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw=="],
"pg-cloudflare": ["pg-cloudflare@1.2.7", "", {}, "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg=="],
"pg-connection-string": ["pg-connection-string@2.9.1", "", {}, "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w=="],
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
"pg-pool": ["pg-pool@3.10.1", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg=="],
"pg-protocol": ["pg-protocol@1.10.3", "", {}, "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ=="],
"pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="],
"pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="],
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
"postgres-bytea": ["postgres-bytea@1.0.0", "", {}, "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="],
"postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="],
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "prettier-plugin-sql": ["prettier-plugin-sql@0.19.2", "", { "dependencies": { "jsox": "^1.2.123", "node-sql-parser": "^5.3.10", "sql-formatter": "^15.6.5", "tslib": "^2.8.1" }, "peerDependencies": { "prettier": "^3.0.3" } }, "sha512-DAu1Jcanpvs32OAOXsqaVXOpPs4nFLVkB3XwzRiZZVNL5/c+XdlNxWFMiMpMhYhmCG5BW3srK8mhikCOv5tPfg=="],
"rolldown": ["rolldown@1.0.0-beta.39", "", { "dependencies": { "@oxc-project/types": "=0.90.0", "@rolldown/pluginutils": "1.0.0-beta.39", "ansis": "^4.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-beta.39", "@rolldown/binding-darwin-arm64": "1.0.0-beta.39", "@rolldown/binding-darwin-x64": "1.0.0-beta.39", "@rolldown/binding-freebsd-x64": "1.0.0-beta.39", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.39", "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.39", "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.39", "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.39", "@rolldown/binding-linux-x64-musl": "1.0.0-beta.39", "@rolldown/binding-openharmony-arm64": "1.0.0-beta.39", "@rolldown/binding-wasm32-wasi": "1.0.0-beta.39", "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.39", "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.39", "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.39" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-05bTT0CJU9dvCRC0Uc4zwB79W5N9MV9OG/Inyx8KNE2pSrrApJoWxEEArW6rmjx113HIx5IreCoTjzLfgvXTdg=="], "railroad-diagrams": ["railroad-diagrams@1.0.0", "", {}, "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], "randexp": ["randexp@0.4.6", "", { "dependencies": { "discontinuous-range": "1.0.0", "ret": "~0.1.10" } }, "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ=="],
"ret": ["ret@0.1.15", "", {}, "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg=="],
"sql-formatter": ["sql-formatter@15.6.9", "", { "dependencies": { "argparse": "^2.0.1", "nearley": "^2.20.1" }, "bin": { "sql-formatter": "bin/sql-formatter-cli.cjs" } }, "sha512-r9VKnkRfKW7jbhTgytwbM+JqmFclQYN9L58Z3UTktuy9V1f1Y+rGK3t70Truh2wIOJzvZkzobAQ2PwGjjXsr6Q=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsx": ["tsx@4.20.5", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-+wKjMNU9w/EaQayHXb7WA7ZaHY6hN8WgfvHNQ3t1PnU91/7O8TcTnIhCDYTZwnt8JsO9IBqZ30Ln1r7pPF52Aw=="],
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
"undici-types": ["undici-types@7.12.0", "", {}, "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ=="], "undici-types": ["undici-types@7.12.0", "", {}, "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="], "zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
"@oxc-parser/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"oxc-parser/@oxc-project/types": ["@oxc-project/types@0.74.0", "", {}, "sha512-KOw/RZrVlHGhCXh1RufBFF7Nuo7HdY5w1lRJukM/igIl6x9qtz8QycDvZdzb4qnHO7znrPyo2sJrFJK2eKHgfQ=="],
} }
} }

View File

@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS public.accounts (
id integer NOT NULL GENERATED ALWAYS AS IDENTITY,
username text NOT NULL,
password text NOT NULL,
role_id smallint NOT NULL DEFAULT '1',
created_at timestamp without time zone NOT NULL DEFAULT now (),
updated_at timestamp without time zone NOT NULL DEFAULT now (),
CONSTRAINT accounts_pkey PRIMARY KEY (id),
CONSTRAINT accounts_username_key UNIQUE (username)
);

View File

@@ -0,0 +1,11 @@
-- Add migration script here
CREATE TABLE IF NOT EXISTS public.movies (
id integer NOT NULL GENERATED ALWAYS AS IDENTITY,
title text NOT NULL,
overview text NOT NULL,
poster_path text NOT NULL,
backdrop_path text,
release_date date NOT NULL,
tmdb_id integer NOT NULL,
PRIMARY KEY (id)
);

View File

@@ -11,28 +11,25 @@
"type": "module", "type": "module",
"main": "dist/app.js", "main": "dist/app.js",
"scripts": { "scripts": {
"dev": "tsx watch src/app.ts", "dev": "bun run --watch src/app.ts",
"lint": "oxlint .", "lint": "oxlint .",
"type": "tsc --noEmit", "type": "tsc --noEmit",
"build": "bun build --target=bun --outfile dist/app.js src/app.ts", "build": "bun build --target=bun --outfile dist/app.js src/app.ts",
"build:migrate": "bun build --target=bun --outfile dist/migrate.js src/bin/migrate.ts",
"tests": "bun test" "tests": "bun test"
}, },
"devDependencies": { "devDependencies": {
"@prettier/plugin-oxc": "^0.0.4", "@prettier/plugin-oxc": "^0.0.4",
"@types/bcrypt": "^6.0.0", "@types/bun": "1.2.23",
"@types/node": "^24.2.1", "@types/node": "^24.2.1",
"@types/pg": "^8.15.5", "oxlint": "^1.19.0",
"@types/bun": "latest",
"oxlint": "^1.16.0",
"prettier": "3.6.2", "prettier": "3.6.2",
"tsx": "^4.20.5", "prettier-plugin-sql": "^0.19.2",
"typescript": "^5.9.2" "typescript": "^5.9.2"
}, },
"dependencies": { "dependencies": {
"@hono/zod-validator": "^0.7.3", "@hono/zod-validator": "^0.7.3",
"bcrypt": "^6.0.0",
"hono": "^4.9.8", "hono": "^4.9.8",
"pg": "^8.16.3",
"zod": "^4.1.11" "zod": "^4.1.11"
} }
} }

View File

@@ -6,7 +6,7 @@ import { setup as setupAccounts } from "./domain/account/setup";
import { HTTPError } from "./errors"; import { HTTPError } from "./errors";
const config = loadConfiguration(); const config = loadConfiguration();
const database: DatabaseInterface = new PgDatabase(config.database); const database: DatabaseInterface = PgDatabase.fromOptions(config.database);
const app = new Hono(); const app = new Hono();
app.route("/accounts", setupAccounts(database)); app.route("/accounts", setupAccounts(database));

17
src/bin/migrate.ts Normal file
View File

@@ -0,0 +1,17 @@
import path from "node:path";
import Migration from "../database/Migration";
import PgDatabase from "../database/PgDatabase";
const connectionString = process.env.DATABASE_URL;
if (!connectionString)
throw new Error("DATABASE_URL environment variable is not set");
const migrationsFolder = process.env.MIGRATIONS_FOLDER;
if (!migrationsFolder)
throw new Error("MIGRATIONS_FOLDER environment variable is not set");
const db = PgDatabase.fromConnectionString(connectionString);
await db.ping();
const migration = new Migration(db);
await migration.execute(path.resolve("migrations"));

View File

@@ -24,6 +24,19 @@ export default function loadConfiguration(): Configuration {
}; };
} }
export function loadTestConfiguration(): Configuration {
return {
database: {
host: getUnsafeEnv("TEST_DATABASE_HOST"),
port: parseInt(getUnsafeEnv("TEST_DATABASE_PORT")),
user: getUnsafeEnv("TEST_DATABASE_USER"),
password: getUnsafeEnv("TEST_DATABASE_PASSWORD"),
database: getUnsafeEnv("TEST_DATABASE_NAME"),
},
port: 3000,
};
}
export function getEnvOrDefault(key: string, defaultValue: string): string { export function getEnvOrDefault(key: string, defaultValue: string): string {
const value = process.env[key]; const value = process.env[key];
if (value === undefined) { if (value === undefined) {

View File

@@ -1,6 +1,6 @@
export interface DatabaseInterface { export interface DatabaseInterface {
ping(): Promise<void>; ping(): Promise<void>;
fetchAll<T = any>(sql: string, params?: any[]): Promise<T[]>; close(): Promise<void>;
fetchOne<T = any>(sql: string, params?: any[]): Promise<T | undefined>; sql<T = any>(template: TemplateStringsArray, ...values: any[]): Promise<T>;
execute(sql: string, params?: any[]): Promise<void>; exec(query: string): Promise<void>;
} }

29
src/database/Migration.ts Normal file
View File

@@ -0,0 +1,29 @@
import { Glob } from "bun";
import type { DatabaseInterface } from "./DatabaseInterface";
import path from "node:path";
export default class Migration {
private readonly database: DatabaseInterface;
constructor(database: DatabaseInterface) {
this.database = database;
}
async execute(folderPath: string) {
const files = await this.readFolder(folderPath);
const contents = await Promise.all(
files.map((file) => Bun.file(file).text()),
);
for (const content of contents) {
// eslint-disable-next-line no-await-in-loop
await this.database.exec(content);
}
}
private async readFolder(folderPath: string) {
const glob = new Glob(path.join(folderPath, "*.sql"));
const files = await Array.fromAsync(glob.scan());
return files;
}
}

View File

@@ -1,5 +1,5 @@
import { Pool } from "pg"; import { SQL } from "bun";
import { DatabaseInterface } from "./DatabaseInterface"; import type { DatabaseInterface } from "./DatabaseInterface";
export interface PgDatabaseOptions { export interface PgDatabaseOptions {
host: string; host: string;
@@ -10,35 +10,38 @@ export interface PgDatabaseOptions {
} }
export default class PgDatabase implements DatabaseInterface { export default class PgDatabase implements DatabaseInterface {
private readonly pool: Pool; private readonly _sql: SQL;
constructor(options: PgDatabaseOptions) { private constructor(connectionString: string) {
this.pool = new Pool({ this._sql = new SQL(connectionString);
host: options.host, }
port: options.port,
user: options.user, public static fromOptions(options: PgDatabaseOptions): PgDatabase {
password: options.password, return new PgDatabase(
database: options.database, `postgresql://${options.user}:${options.password}@${options.host}:${options.port}/${options.database}`,
}); );
}
public static fromConnectionString(connectionString: string): PgDatabase {
return new PgDatabase(connectionString);
}
async close(): Promise<void> {
await this._sql.close();
} }
async ping(): Promise<void> { async ping(): Promise<void> {
await this.pool.query("SELECT 1"); await this._sql`SELECT 1`;
} }
async fetchAll<T = any>(sql: string, params?: any[]): Promise<T[]> { async sql<T = any>(
const res = await this.pool.query(sql, params); template: TemplateStringsArray,
...values: any[]
return res.rows; ): Promise<T> {
return await this._sql(template, ...values);
} }
async fetchOne<T = any>(sql: string, params?: any[]): Promise<T | undefined> { async exec(query: string): Promise<void> {
const res = await this.fetchAll<T>(sql, params); await this._sql.unsafe(query);
return res[0];
}
async execute(sql: string, params?: any[]): Promise<void> {
await this.pool.query(sql, params);
} }
} }

View File

@@ -34,13 +34,13 @@ export default function toRoutes(
return parsed.data; return parsed.data;
}), }),
async (ctx) => { async (ctx) => {
const { email, password } = ctx.req.valid("json"); const { username, password } = ctx.req.valid("json");
const account = await accountService.login(email, password); const account = await accountService.login(username, password);
return ctx.json({ return ctx.json({
success: true, success: true,
accountId: account.id, accountId: account.id,
email: account.email, username: account.username,
}); });
}, },
); );
@@ -56,13 +56,13 @@ export default function toRoutes(
return parsed.data; return parsed.data;
}), }),
async (ctx) => { async (ctx) => {
const { email, password } = ctx.req.valid("json"); const { username, password } = ctx.req.valid("json");
const account = await accountService.register(email, password); const account = await accountService.register(username, password);
return ctx.json({ return ctx.json({
success: true, success: true,
accountId: account.id, accountId: account.id,
email: account.email, username: account.username,
}); });
}, },
); );

View File

@@ -1,30 +1,22 @@
import bcrypt from "bcrypt";
import { import {
InvalidEmailFormatError, InvalidUsernameFormatError,
WeakPasswordError, WeakPasswordError,
} from "../errors/AccountErrors"; } from "../errors/AccountErrors";
import { import {
EMAIL_REGEX, MAX_USERNAME_LENGTH,
MIN_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH,
MIN_USERNAME_LENGTH,
} from "../validation/AccountValidation"; } from "../validation/AccountValidation";
export class AccountEntity { export class AccountEntity {
constructor( constructor(
public readonly id: string, public readonly id: number,
public readonly email: string, public readonly username: string,
private password: string, private password: string,
public readonly roleId: number, public readonly roleId: number,
public readonly createdAt: Date, public readonly createdAt: Date,
public readonly updatedAt: Date, public readonly updatedAt: Date,
) { ) {}
this.validateEmail(email);
}
private validateEmail(email: string): void {
if (!EMAIL_REGEX.test(email)) {
throw new InvalidEmailFormatError(email);
}
}
private static validatePassword(password: string): void { private static validatePassword(password: string): void {
if (password.length < MIN_PASSWORD_LENGTH) { if (password.length < MIN_PASSWORD_LENGTH) {
@@ -32,26 +24,41 @@ export class AccountEntity {
} }
} }
public verifyPassword(plainPassword: string): boolean { private static validateUsername(username: string): void {
return bcrypt.compareSync(plainPassword, this.password); if (
username.length < MIN_USERNAME_LENGTH ||
username.length > MAX_USERNAME_LENGTH
) {
throw new InvalidUsernameFormatError(username);
}
}
public async verifyPassword(plainPassword: string): Promise<boolean> {
return await Bun.password.verify(plainPassword, this.password);
} }
public get hashedPassword(): string { public get hashedPassword(): string {
return this.password; return this.password;
} }
static create(email: string, password: string): AccountEntity { static async create(
username: string,
password: string,
): Promise<CreateAccountEntity> {
AccountEntity.validatePassword(password); AccountEntity.validatePassword(password);
AccountEntity.validateUsername(username);
const now = new Date(); const hashedPassword = await Bun.password.hash(password);
const id = crypto.randomUUID();
const hashedPassword = bcrypt.hashSync(password, 10);
return new AccountEntity(id, email, hashedPassword, 1, now, now); return {
username,
hashedPassword,
roleId: 1,
};
} }
} }
export type CreateAccountDto = { export type CreateAccountEntity = Omit<
email: string; AccountEntity,
password: string; "id" | "verifyPassword" | "createdAt" | "updatedAt"
}; >;

View File

@@ -8,8 +8,8 @@ export class AccountNotFoundError extends HTTPError {
} }
export class AccountAlreadyExistsError extends HTTPError { export class AccountAlreadyExistsError extends HTTPError {
constructor(email: string) { constructor(username: string) {
super(409, `Un compte avec cet email existe déjà : ${email}`); super(409, `Un compte avec cet username existe déjà : ${username}`);
} }
} }
@@ -19,9 +19,9 @@ export class BadPasswordError extends HTTPError {
} }
} }
export class InvalidEmailFormatError extends HTTPError { export class InvalidUsernameFormatError extends HTTPError {
constructor(email: string) { constructor(username: string) {
super(400, `Format d'email invalide : ${email}`); super(400, `Format d'username invalide : ${username}`);
} }
} }

View File

@@ -1,7 +1,10 @@
import { AccountEntity } from "../entity/AccountEntity"; import {
AccountEntity,
type CreateAccountEntity,
} from "../entity/AccountEntity";
export interface AccountRepositoryInterface { export interface AccountRepositoryInterface {
findByEmail(email: string): Promise<AccountEntity | null>; findByUsername(username: string): Promise<AccountEntity | null>;
save(account: AccountEntity): Promise<string>; insert(account: CreateAccountEntity): Promise<number>;
findById(id: string): Promise<AccountEntity | null>; findById(id: number): Promise<AccountEntity | null>;
} }

View File

@@ -1,33 +1,36 @@
import type { DatabaseInterface } from "../../../database/DatabaseInterface"; import type { DatabaseInterface } from "../../../database/DatabaseInterface";
import { AccountEntity } from "../entity/AccountEntity"; import {
AccountEntity,
type CreateAccountEntity,
} from "../entity/AccountEntity";
import type { AccountRepositoryInterface } from "./AccountRepositoryInterface"; import type { AccountRepositoryInterface } from "./AccountRepositoryInterface";
export default class AccountRepository implements AccountRepositoryInterface { export default class AccountRepository implements AccountRepositoryInterface {
constructor(private readonly database: DatabaseInterface) {} constructor(private readonly database: DatabaseInterface) {}
async findByEmail(email: string): Promise<AccountEntity | null> { async findByUsername(username: string): Promise<AccountEntity | null> {
const sql = ` const [result] = await this.database.sql<
SELECT id, email, password, role_id, created_at, updated_at {
FROM accounts id: number;
WHERE email = $1 username: string;
password: string;
role_id: number;
created_at: Date;
updated_at: Date;
}[]
>`
SELECT id, username, password, role_id, created_at, updated_at
FROM accounts
WHERE username = ${username}
`; `;
const result = await this.database.fetchOne<{
id: string;
email: string;
password: string;
role_id: number;
created_at: Date;
updated_at: Date;
}>(sql, [email]);
if (!result) { if (!result) {
return null; return null;
} }
return new AccountEntity( return new AccountEntity(
result.id, result.id,
result.email, result.username,
result.password, result.password,
result.role_id, result.role_id,
result.created_at, result.created_at,
@@ -35,53 +38,39 @@ export default class AccountRepository implements AccountRepositoryInterface {
); );
} }
async save(account: AccountEntity): Promise<string> { async insert(account: CreateAccountEntity): Promise<number> {
const sql = ` const [result] = await this.database.sql<{ id: number }[]>`
INSERT INTO accounts (id, email, password, role_id, created_at, updated_at) INSERT INTO accounts (username, password, role_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6) VALUES (${account.username}, ${account.hashedPassword}, ${account.roleId}, NOW(), NOW())
ON CONFLICT (id) DO UPDATE SET
email = EXCLUDED.email,
password = EXCLUDED.password,
role_id = EXCLUDED.role_id,
updated_at = EXCLUDED.updated_at
RETURNING id RETURNING id
`; `;
const result = await this.database.fetchOne<{ id: string }>(sql, [
account.id,
account.email,
account.hashedPassword,
account.roleId,
account.createdAt,
account.updatedAt,
]);
return result!.id; return result!.id;
} }
async findById(id: string): Promise<AccountEntity | null> { async findById(id: number): Promise<AccountEntity | null> {
const sql = ` const [result] = await this.database.sql<
SELECT id, email, password, role_id, created_at, updated_at {
FROM accounts id: number;
WHERE id = $1 username: string;
password: string;
role_id: number;
created_at: Date;
updated_at: Date;
}[]
>`
SELECT id, username, password, role_id, created_at, updated_at
FROM accounts
WHERE id = ${id}
`; `;
const result = await this.database.fetchOne<{
id: string;
email: string;
password: string;
role_id: number;
created_at: Date;
updated_at: Date;
}>(sql, [id]);
if (!result) { if (!result) {
return null; return null;
} }
return new AccountEntity( return new AccountEntity(
result.id, result.id,
result.email, result.username,
result.password, result.password,
result.role_id, result.role_id,
result.created_at, result.created_at,

View File

@@ -10,30 +10,31 @@ import {
export default class AccountService implements AccountServiceInterface { export default class AccountService implements AccountServiceInterface {
constructor(private readonly accountRepository: AccountRepositoryInterface) {} constructor(private readonly accountRepository: AccountRepositoryInterface) {}
async login(email: string, password: string): Promise<AccountEntity> { async login(username: string, password: string): Promise<AccountEntity> {
const account = await this.accountRepository.findByEmail(email); const account = await this.accountRepository.findByUsername(username);
if (!account) { if (!account) {
throw new AccountNotFoundError(email); throw new AccountNotFoundError(username);
} }
if (!account.verifyPassword(password)) { if (!(await account.verifyPassword(password))) {
throw new BadPasswordError(); throw new BadPasswordError();
} }
return account; return account;
} }
async register(email: string, password: string): Promise<AccountEntity> { async register(username: string, password: string): Promise<AccountEntity> {
const existingAccount = await this.accountRepository.findByEmail(email); const existingAccount =
await this.accountRepository.findByUsername(username);
if (existingAccount) { if (existingAccount) {
throw new AccountAlreadyExistsError(email); throw new AccountAlreadyExistsError(username);
} }
const newAccount = AccountEntity.create(email, password); const newAccount = await AccountEntity.create(username, password);
await this.accountRepository.save(newAccount); const newId = await this.accountRepository.insert(newAccount);
return newAccount; return (await this.accountRepository.findById(newId)) as AccountEntity;
} }
} }

View File

@@ -1,11 +1,19 @@
import { z } from "zod"; import { z } from "zod";
export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const MIN_PASSWORD_LENGTH = 8; export const MIN_PASSWORD_LENGTH = 8;
export const MAX_USERNAME_LENGTH = 20;
export const MIN_USERNAME_LENGTH = 3;
export const emailSchema = z export const usernameSchema = z
.string() .string()
.regex(EMAIL_REGEX, "Format d'email invalide"); .min(
MIN_USERNAME_LENGTH,
`Le nom d'utilisateur doit contenir au moins ${MIN_USERNAME_LENGTH} caractères`,
)
.max(
MAX_USERNAME_LENGTH,
`Le nom d'utilisateur doit contenir au plus ${MAX_USERNAME_LENGTH} caractères`,
);
export const passwordSchema = z export const passwordSchema = z
.string() .string()
@@ -15,11 +23,11 @@ export const passwordSchema = z
); );
export const loginSchema = z.object({ export const loginSchema = z.object({
email: emailSchema, username: usernameSchema,
password: passwordSchema, password: passwordSchema,
}); });
export const registerSchema = z.object({ export const registerSchema = z.object({
email: emailSchema, username: usernameSchema,
password: passwordSchema, password: passwordSchema,
}); });

View File

@@ -1,5 +1,5 @@
import { Context, Next } from 'hono'; import type { Context, Next } from "hono";
import { TooManyAttemptsError } from '../domain/account/errors/AccountErrors'; import { TooManyAttemptsError } from "../domain/account/errors/AccountErrors";
interface RateLimitConfig { interface RateLimitConfig {
windowMs: number; windowMs: number;
@@ -24,34 +24,38 @@ function cleanupExpiredEntries(): void {
} }
export function createRateLimit(config: RateLimitConfig) { export function createRateLimit(config: RateLimitConfig) {
const { windowMs, maxAttempts, keyGenerator = (c) => c.req.header('x-forwarded-for') || '127.0.0.1' } = config; const {
windowMs,
maxAttempts,
keyGenerator = (c) => c.req.header("x-forwarded-for") || "127.0.0.1",
} = config;
return async (c: Context, next: Next) => { return async (c: Context, next: Next) => {
const key = keyGenerator(c); const key = keyGenerator(c);
const now = Date.now(); const now = Date.now();
cleanupExpiredEntries(); cleanupExpiredEntries();
const record = attempts.get(key); const record = attempts.get(key);
if (!record) { if (!record) {
attempts.set(key, { count: 1, resetTime: now + windowMs }); attempts.set(key, { count: 1, resetTime: now + windowMs });
await next(); await next();
return; return;
} }
if (now > record.resetTime) { if (now > record.resetTime) {
attempts.set(key, { count: 1, resetTime: now + windowMs }); attempts.set(key, { count: 1, resetTime: now + windowMs });
await next(); await next();
return; return;
} }
if (record.count >= maxAttempts) { if (record.count >= maxAttempts) {
const retryAfter = Math.ceil((record.resetTime - now) / 1000); const retryAfter = Math.ceil((record.resetTime - now) / 1000);
throw new TooManyAttemptsError(retryAfter); throw new TooManyAttemptsError(retryAfter);
} }
record.count++; record.count++;
await next(); await next();
}; };
} }

View File

@@ -1,56 +0,0 @@
import { describe, it, expect } from "bun:test";
import { AccountEntity } from "../src/domain/account/entity/AccountEntity";
import {
InvalidEmailFormatError,
WeakPasswordError,
} from "../src/domain/account/errors/AccountErrors";
import { MIN_PASSWORD_LENGTH } from "../src/domain/account/validation/AccountValidation";
describe("AccountEntity", () => {
describe("create", () => {
it("should create an account with valid email and password", () => {
const email = "test@example.com";
const password = "a".repeat(MIN_PASSWORD_LENGTH); // Use minimum length
const account = AccountEntity.create(email, password);
expect(account.email).toBe(email);
expect(account.roleId).toBe(1);
expect(account.id).toBeDefined();
expect(account.createdAt).toBeInstanceOf(Date);
expect(account.updatedAt).toBeInstanceOf(Date);
});
it("should throw InvalidEmailFormatError for invalid email", () => {
const invalidEmail = "invalid-email";
const password = "password123";
expect(() => {
AccountEntity.create(invalidEmail, password);
}).toThrow(InvalidEmailFormatError);
});
it("should throw WeakPasswordError for short password", () => {
const email = "test@example.com";
const shortPassword = "a".repeat(MIN_PASSWORD_LENGTH - 1); // One less than minimum
expect(() => {
AccountEntity.create(email, shortPassword);
}).toThrow(WeakPasswordError);
});
});
describe("verifyPassword", () => {
it("should return true for correct password", () => {
const account = AccountEntity.create("test@example.com", "password123");
expect(account.verifyPassword("password123")).toBe(true);
});
it("should return false for incorrect password", () => {
const account = AccountEntity.create("test@example.com", "password123");
expect(account.verifyPassword("wrongpassword")).toBe(false);
});
});
});

View File

@@ -1,130 +0,0 @@
import { describe, it, expect, jest, beforeEach } from "bun:test";
import AccountRepository from "../src/domain/account/repository/AccoutRepository";
import { AccountEntity } from "../src/domain/account/entity/AccountEntity";
describe("AccountRepository", () => {
let accountRepository: AccountRepository;
let mockDatabase: {
ping: ReturnType<typeof jest.fn>;
fetchAll: ReturnType<typeof jest.fn>;
fetchOne: ReturnType<typeof jest.fn>;
execute: ReturnType<typeof jest.fn>;
};
beforeEach(() => {
mockDatabase = {
ping: jest.fn(() => Promise.resolve()),
fetchAll: jest.fn(() => Promise.resolve([])),
fetchOne: jest.fn(() => Promise.resolve(undefined)),
execute: jest.fn(() => Promise.resolve()),
};
accountRepository = new AccountRepository(mockDatabase as any);
});
describe("findByEmail", () => {
it("should return account when found", async () => {
const email = "test@example.com";
const mockResult = {
id: "123",
email: email,
password: "hashedPassword",
role_id: 1,
created_at: new Date(),
updated_at: new Date(),
};
mockDatabase.fetchOne.mockImplementation(() =>
Promise.resolve(mockResult),
);
const result = await accountRepository.findByEmail(email);
expect(result).toBeInstanceOf(AccountEntity);
expect(result?.email).toBe(email);
expect(mockDatabase.fetchOne).toHaveBeenCalledWith(
expect.stringContaining(
"SELECT id, email, password, role_id, created_at, updated_at",
),
[email],
);
});
it("should return null when account not found", async () => {
const email = "nonexistent@example.com";
mockDatabase.fetchOne.mockImplementation(() =>
Promise.resolve(undefined),
);
const result = await accountRepository.findByEmail(email);
expect(result).toBeNull();
});
});
describe("findById", () => {
it("should return account when found", async () => {
const id = "123";
const mockResult = {
id: id,
email: "test@example.com",
password: "hashedPassword",
role_id: 1,
created_at: new Date(),
updated_at: new Date(),
};
mockDatabase.fetchOne.mockImplementation(() =>
Promise.resolve(mockResult),
);
const result = await accountRepository.findById(id);
expect(result).toBeInstanceOf(AccountEntity);
expect(result?.id).toBe(id);
expect(mockDatabase.fetchOne).toHaveBeenCalledWith(
expect.stringContaining(
"SELECT id, email, password, role_id, created_at, updated_at",
),
[id],
);
});
it("should return null when account not found", async () => {
const id = "nonexistent";
mockDatabase.fetchOne.mockImplementation(() =>
Promise.resolve(undefined),
);
const result = await accountRepository.findById(id);
expect(result).toBeNull();
});
});
describe("save", () => {
it("should save account successfully", async () => {
const account = AccountEntity.create("test@example.com", "password123");
mockDatabase.fetchOne.mockImplementation(() =>
Promise.resolve({ id: account.id }),
);
const result = await accountRepository.save(account);
expect(result).toBe(account.id);
expect(mockDatabase.fetchOne).toHaveBeenCalledWith(
expect.stringContaining("INSERT INTO accounts"),
expect.arrayContaining([
account.id,
account.email,
expect.any(String),
account.roleId,
account.createdAt,
account.updatedAt,
]),
);
});
});
});

View File

@@ -1,108 +0,0 @@
import { describe, it, expect, jest, beforeEach } from "bun:test";
import AccountService from "../src/domain/account/service/AccountService";
import { AccountEntity } from "../src/domain/account/entity/AccountEntity";
import {
AccountNotFoundError,
AccountAlreadyExistsError,
BadPasswordError,
} from "../src/domain/account/errors/AccountErrors";
describe("AccountService", () => {
let accountService: AccountService;
let mockAccountRepository: {
findByEmail: ReturnType<typeof jest.fn>;
save: ReturnType<typeof jest.fn>;
findById: ReturnType<typeof jest.fn>;
};
beforeEach(() => {
mockAccountRepository = {
findByEmail: jest.fn(() => Promise.resolve(null)),
save: jest.fn(() => Promise.resolve("123")),
findById: jest.fn(() => Promise.resolve(null)),
};
accountService = new AccountService(mockAccountRepository as any);
});
describe("createAccount", () => {
it("should create a new account successfully", async () => {
const email = "test@example.com";
const password = "password123";
mockAccountRepository.findByEmail.mockImplementation(() =>
Promise.resolve(null),
);
mockAccountRepository.save.mockImplementation(() =>
Promise.resolve("123"),
);
const result = await accountService.register(email, password);
expect(result).toBeInstanceOf(AccountEntity);
expect(result.email).toBe(email);
expect(mockAccountRepository.findByEmail).toHaveBeenCalledWith(email);
expect(mockAccountRepository.save).toHaveBeenCalledWith(
expect.any(AccountEntity),
);
});
it("should throw error if account already exists", async () => {
const email = "test@example.com";
const password = "password123";
const existingAccount = AccountEntity.create(email, password);
mockAccountRepository.findByEmail.mockImplementation(() =>
Promise.resolve(existingAccount),
);
await expect(accountService.register(email, password)).rejects.toThrow(
AccountAlreadyExistsError,
);
});
});
describe("login", () => {
it("should login successfully with correct credentials", async () => {
const email = "test@example.com";
const password = "password123";
const account = AccountEntity.create(email, password);
mockAccountRepository.findByEmail.mockImplementation(() =>
Promise.resolve(account),
);
const result = await accountService.login(email, password);
expect(result).toBe(account);
expect(mockAccountRepository.findByEmail).toHaveBeenCalledWith(email);
});
it("should throw error if account not found", async () => {
const email = "test@example.com";
const password = "password123";
mockAccountRepository.findByEmail.mockImplementation(() =>
Promise.resolve(null),
);
await expect(accountService.login(email, password)).rejects.toThrow(
AccountNotFoundError,
);
});
it("should throw error if password is incorrect", async () => {
const email = "test@example.com";
const password = "password123";
const wrongPassword = "wrongpassword";
const account = AccountEntity.create(email, password);
mockAccountRepository.findByEmail.mockImplementation(() =>
Promise.resolve(account),
);
await expect(accountService.login(email, wrongPassword)).rejects.toThrow(
BadPasswordError,
);
});
});
});

View File

@@ -0,0 +1,80 @@
import { describe, it, expect } from "bun:test";
import {
InvalidUsernameFormatError,
WeakPasswordError,
} from "../../src/domain/account/errors/AccountErrors";
import {
MAX_USERNAME_LENGTH,
MIN_PASSWORD_LENGTH,
} from "../../src/domain/account/validation/AccountValidation";
import { AccountEntity } from "../../src/domain/account/entity/AccountEntity";
describe("AccountEntity", () => {
describe("create", () => {
it("should create an account with valid username and password", async () => {
const email = "testaccount";
const password = "a".repeat(MIN_PASSWORD_LENGTH);
const account = await AccountEntity.create(email, password);
expect(account.username).toBe(email);
expect(account.roleId).toBe(1);
expect(account).toBeDefined();
});
it("should throw InvalidUsernameFormatError for invalid username", () => {
const invalidUsername = "a".repeat(MAX_USERNAME_LENGTH + 1);
const password = "a".repeat(MIN_PASSWORD_LENGTH);
expect(AccountEntity.create(invalidUsername, password)).rejects.toThrow(
InvalidUsernameFormatError,
);
});
it("should throw WeakPasswordError for short password", () => {
const username = "testaccount";
const shortPassword = "a".repeat(MIN_PASSWORD_LENGTH - 1);
expect(AccountEntity.create(username, shortPassword)).rejects.toThrow(
WeakPasswordError,
);
});
});
describe("verifyPassword", () => {
it("should return true for correct password", async () => {
const createAccount = await AccountEntity.create(
"test@example.com",
"password123",
);
const account = new AccountEntity(
1,
createAccount.username,
createAccount.hashedPassword,
createAccount.roleId,
new Date(),
new Date(),
);
expect(await account.verifyPassword("password123")).toBe(true);
});
it("should return false for incorrect password", async () => {
const createAccount = await AccountEntity.create(
"test@example.com",
"password123",
);
const account = new AccountEntity(
1,
createAccount.username,
createAccount.hashedPassword,
createAccount.roleId,
new Date(),
new Date(),
);
expect(await account.verifyPassword("wrongpassword")).toBe(false);
});
});
});

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import AccountRepository from "../../src/domain/account/repository/AccoutRepository";
import { AccountEntity } from "../../src/domain/account/entity/AccountEntity";
import { initTestDatabase } from "../utils";
import type { DatabaseInterface } from "../../src/database/DatabaseInterface";
describe("AccountRepository", () => {
let accountRepository: AccountRepository;
let database: DatabaseInterface;
beforeEach(async () => {
database = await initTestDatabase();
accountRepository = new AccountRepository(database);
});
afterEach(async () => {
await database?.close();
});
describe("findByUsername", () => {
it("should return account when found", async () => {
const username = "testaccount";
const createAccount = await AccountEntity.create(username, "password123");
await accountRepository.insert(createAccount);
const result = await accountRepository.findByUsername(username);
expect(result).toBeInstanceOf(AccountEntity);
expect(result?.username).toBe(username);
});
it("should return null when account not found", async () => {
const username = "nonexistentaccount";
const result = await accountRepository.findByUsername(username);
expect(result).toBeNull();
});
});
describe("findById", () => {
it("should return account when found", async () => {
const createAccount = await AccountEntity.create(
"test@example.com",
"password123",
);
const newId = await accountRepository.insert(createAccount);
const result = await accountRepository.findById(newId);
expect(result).toBeInstanceOf(AccountEntity);
expect(result?.id).toBe(newId);
});
it("should return null when account not found", async () => {
const id = 999;
const result = await accountRepository.findById(id);
expect(result).toBeNull();
});
});
describe("insert", () => {
it("should insert account successfully", async () => {
const account = await AccountEntity.create(
"test@example.com",
"password123",
);
const result = await accountRepository.insert(account);
expect(result).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,145 @@
import { describe, it, expect, jest, beforeEach } from "bun:test";
import {
AccountNotFoundError,
AccountAlreadyExistsError,
BadPasswordError,
} from "../../src/domain/account/errors/AccountErrors";
import AccountService from "../../src/domain/account/service/AccountService";
import { AccountEntity } from "../../src/domain/account/entity/AccountEntity";
describe("AccountService", () => {
let accountService: AccountService;
let mockAccountRepository: {
findByUsername: ReturnType<typeof jest.fn>;
insert: ReturnType<typeof jest.fn>;
findById: ReturnType<typeof jest.fn>;
};
beforeEach(() => {
mockAccountRepository = {
findByUsername: jest.fn(() => Promise.resolve(null)),
insert: jest.fn(() => Promise.resolve("123")),
findById: jest.fn(() => Promise.resolve(null)),
};
accountService = new AccountService(mockAccountRepository as any);
});
describe("createAccount", () => {
it("should create a new account successfully", async () => {
const username = "testusername";
const password = "password123";
mockAccountRepository.findByUsername.mockImplementation(() =>
Promise.resolve(null),
);
mockAccountRepository.findById.mockImplementation(() =>
Promise.resolve(
new AccountEntity(
123,
username,
"hashedpassword",
1,
new Date(),
new Date(),
),
),
);
mockAccountRepository.insert.mockImplementation(() =>
Promise.resolve(123),
);
const result = await accountService.register(username, password);
expect(result).toBeInstanceOf(AccountEntity);
expect(result.username).toBe(username);
expect(mockAccountRepository.findByUsername).toHaveBeenCalledWith(
username,
);
});
it("should throw error if account already exists", async () => {
const username = "testusername";
const password = "password123";
const existingAccount = await AccountEntity.create(username, password);
mockAccountRepository.findByUsername.mockImplementation(() =>
Promise.resolve(existingAccount),
);
expect(accountService.register(username, password)).rejects.toThrow(
AccountAlreadyExistsError,
);
});
});
describe("login", () => {
it("should login successfully with correct credentials", async () => {
const username = "testaccount";
const password = "password123";
const createAccount = await AccountEntity.create(username, password);
const account = new AccountEntity(
1,
createAccount.username,
createAccount.hashedPassword,
createAccount.roleId,
new Date(),
new Date(),
);
mockAccountRepository.findByUsername.mockImplementation(() =>
Promise.resolve(account),
);
const result = await accountService.login(username, password);
expect({ username: result.username, roleId: result.roleId }).toEqual({
username: account.username,
roleId: account.roleId,
});
expect(mockAccountRepository.findByUsername).toHaveBeenCalledWith(
username,
);
});
it("should throw error if account not found", async () => {
const username = "testaccount";
const password = "password123";
mockAccountRepository.findByUsername.mockImplementation(() =>
Promise.resolve(null),
);
expect(accountService.login(username, password)).rejects.toThrow(
AccountNotFoundError,
);
});
it("should throw error if password is incorrect", async () => {
const username = "testaccount";
const password = "password123";
const wrongPassword = "wrongpassword";
const createAccount = await AccountEntity.create(username, password);
const account = new AccountEntity(
1,
createAccount.username,
createAccount.hashedPassword,
createAccount.roleId,
new Date(),
new Date(),
);
mockAccountRepository.findByUsername.mockImplementation(() =>
Promise.resolve(account),
);
expect(accountService.login(username, wrongPassword)).rejects.toThrow(
BadPasswordError,
);
});
});
});

View File

@@ -1,62 +1,14 @@
import { describe, it, expect } from "bun:test"; import { describe, it, expect } from "bun:test";
import { import {
emailSchema,
passwordSchema, passwordSchema,
loginSchema, loginSchema,
registerSchema, registerSchema,
EMAIL_REGEX,
MIN_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH,
} from "../src/domain/account/validation/AccountValidation"; } from "../../src/domain/account/validation/AccountValidation";
import { AccountEntity } from "../src/domain/account/entity/AccountEntity"; import { WeakPasswordError } from "../../src/domain/account/errors/AccountErrors";
import { import { AccountEntity } from "../../src/domain/account/entity/AccountEntity";
InvalidEmailFormatError,
WeakPasswordError,
} from "../src/domain/account/errors/AccountErrors";
describe("AccountValidation", () => { describe("AccountValidation", () => {
describe("Email validation consistency", () => {
const validEmails = [
"test@example.com",
"user.name@domain.co.uk",
"firstname+lastname@example.org",
];
const invalidEmails = [
"invalid-email",
"@example.com",
"test@",
"test",
"test@domain",
"",
];
it("should validate same emails in Zod and Entity", () => {
validEmails.forEach((email) => {
expect(emailSchema.safeParse(email).success).toBe(true);
expect(() => AccountEntity.create(email, "password123")).not.toThrow(
InvalidEmailFormatError,
);
});
invalidEmails.forEach((email) => {
expect(emailSchema.safeParse(email).success).toBe(false);
expect(() => AccountEntity.create(email, "password123")).toThrow(
InvalidEmailFormatError,
);
});
});
it("should use same regex pattern", () => {
validEmails.forEach((email) => {
expect(EMAIL_REGEX.test(email)).toBe(true);
});
invalidEmails.forEach((email) => {
expect(EMAIL_REGEX.test(email)).toBe(false);
});
});
});
describe("Password validation consistency", () => { describe("Password validation consistency", () => {
const validPasswords = ["password123", "abcdefgh", "12345678", "P@ssw0rd!"]; const validPasswords = ["password123", "abcdefgh", "12345678", "P@ssw0rd!"];
@@ -91,8 +43,11 @@ describe("AccountValidation", () => {
describe("Complete schemas", () => { describe("Complete schemas", () => {
it("should validate login schema correctly", () => { it("should validate login schema correctly", () => {
const validLogin = { email: "test@example.com", password: "password123" }; const validLogin = {
const invalidLogin = { email: "invalid", password: "123" }; username: "testaccount",
password: "password123",
};
const invalidLogin = { username: "ii", password: "123" };
expect(loginSchema.safeParse(validLogin).success).toBe(true); expect(loginSchema.safeParse(validLogin).success).toBe(true);
expect(loginSchema.safeParse(invalidLogin).success).toBe(false); expect(loginSchema.safeParse(invalidLogin).success).toBe(false);
@@ -100,10 +55,10 @@ describe("AccountValidation", () => {
it("should validate register schema correctly", () => { it("should validate register schema correctly", () => {
const validRegister = { const validRegister = {
email: "test@example.com", username: "testaccount",
password: "password123", password: "password123",
}; };
const invalidRegister = { email: "invalid", password: "123" }; const invalidRegister = { username: "ii", password: "123" };
expect(registerSchema.safeParse(validRegister).success).toBe(true); expect(registerSchema.safeParse(validRegister).success).toBe(true);
expect(registerSchema.safeParse(invalidRegister).success).toBe(false); expect(registerSchema.safeParse(invalidRegister).success).toBe(false);
@@ -121,11 +76,17 @@ describe("AccountValidation", () => {
} }
}); });
it("should return consistent email error message", () => { it("should return consistent username error message", () => {
const result = emailSchema.safeParse("invalid-email"); const result = registerSchema.safeParse({
username: "ii",
password: "validPassword123",
});
expect(result.success).toBe(false); expect(result.success).toBe(false);
if (!result.success) { if (!result.success) {
expect(result.error.issues[0]?.message).toBe("Format d'email invalide"); expect(result.error.issues[0]?.message).toContain(
"doit contenir au moins",
);
} }
}); });
}); });

20
tests/utils.ts Normal file
View File

@@ -0,0 +1,20 @@
import path from "node:path";
import { loadTestConfiguration } from "../src/config";
import Migration from "../src/database/Migration";
import PgDatabase from "../src/database/PgDatabase";
import type { DatabaseInterface } from "../src/database/DatabaseInterface";
export async function initTestDatabase(): Promise<DatabaseInterface> {
const testConfiguration = loadTestConfiguration();
const database = PgDatabase.fromOptions(testConfiguration.database);
await database.ping();
await database.exec("DROP SCHEMA public CASCADE");
await database.exec("CREATE SCHEMA public");
const migration = new Migration(database);
await migration.execute(path.resolve("migrations"));
return database;
}