Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aaa0ca5a54 | |||
| 91c14f750e | |||
| 049ed4b956 | |||
| 3fe9fc7142 |
9
.claude/settings.local.json
Normal file
9
.claude/settings.local.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"mcp__context7__get-library-docs"
|
||||||
|
],
|
||||||
|
"deny": [],
|
||||||
|
"ask": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"workspaceFolder": "/workspace",
|
"workspaceFolder": "/workspace",
|
||||||
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,Z",
|
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,Z",
|
||||||
"runArgs": ["--network=dev-network"],
|
|
||||||
"build": {
|
"build": {
|
||||||
"dockerfile": "Dockerfile"
|
"dockerfile": "Dockerfile"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"plugins": ["@prettier/plugin-oxc"]
|
"plugins": ["@prettier/plugin-oxc", "prettier-plugin-sql"]
|
||||||
}
|
}
|
||||||
|
|||||||
28
README.md
28
README.md
@@ -1,2 +1,30 @@
|
|||||||
# nixi-api
|
# nixi-api
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
- `bun dev` - Start development server with hot reload
|
||||||
|
- `bun run lint` - Run linter (oxlint)
|
||||||
|
- `bun run type` - Type check without emitting
|
||||||
|
- `bun run build` - Build production bundle
|
||||||
|
- `bun run tests` - Run tests with Bun test runner
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
This project uses Bun's built-in test runner instead of Jest or Vitest. Tests are located in the `tests/` directory and use the pattern `*.test.ts`.
|
||||||
|
|
||||||
|
### Running tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
bun test
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
bun test tests/AccountEntity.test.ts
|
||||||
|
|
||||||
|
# Run tests in watch mode
|
||||||
|
bun test --watch
|
||||||
|
|
||||||
|
# Run tests with coverage
|
||||||
|
bun test --coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
|||||||
139
bun.lock
Normal file
139
bun.lock
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "nixi-api",
|
||||||
|
"dependencies": {
|
||||||
|
"@hono/zod-validator": "^0.7.3",
|
||||||
|
"hono": "^4.9.8",
|
||||||
|
"zod": "^4.1.11",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@prettier/plugin-oxc": "^0.0.4",
|
||||||
|
"@types/bun": "1.2.23",
|
||||||
|
"@types/node": "^24.2.1",
|
||||||
|
"oxlint": "^1.19.0",
|
||||||
|
"prettier": "3.6.2",
|
||||||
|
"prettier-plugin-sql": "^0.19.2",
|
||||||
|
"typescript": "^5.9.2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="],
|
||||||
|
|
||||||
|
"@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
|
||||||
|
|
||||||
|
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||||
|
|
||||||
|
"@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@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-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.74.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xbY/io/hkARggbpYEMFX6CwFzb7f4iS6WuBoBeZtdqRWfIEi7sm/uYWXfyVeB8uqOATvJ07WRFC2upI8PSI83g=="],
|
||||||
|
|
||||||
|
"@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.74.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-FIj2gAGtFaW0Zk+TnGyenMUoRu1ju+kJ/h71D77xc1owOItbFZFGa+4WSVck1H8rTtceeJlK+kux+vCjGFCl9Q=="],
|
||||||
|
|
||||||
|
"@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.74.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-W1I+g5TJg0TRRMHgEWNWsTIfe782V3QuaPgZxnfPNmDMywYdtlzllzclBgaDq6qzvZCCQc/UhvNb37KWTCTj8A=="],
|
||||||
|
|
||||||
|
"@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.74.0", "", { "os": "linux", "cpu": "arm" }, "sha512-gxqkyRGApeVI8dgvJ19SYe59XASW3uVxF1YUgkE7peW/XIg5QRAOVTFKyTjI9acYuK1MF6OJHqx30cmxmZLtiQ=="],
|
||||||
|
|
||||||
|
"@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.74.0", "", { "os": "linux", "cpu": "arm" }, "sha512-jpnAUP4Fa93VdPPDzxxBguJmldj/Gpz7wTXKFzpAueqBMfZsy9KNC+0qT2uZ9HGUDMzNuKw0Se3bPCpL/gfD2Q=="],
|
||||||
|
|
||||||
|
"@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.74.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-fcWyM7BNfCkHqIf3kll8fJctbR/PseL4RnS2isD9Y3FFBhp4efGAzhDaxIUK5GK7kIcFh1P+puIRig8WJ6IMVQ=="],
|
||||||
|
|
||||||
|
"@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.74.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-AMY30z/C77HgiRRJX7YtVUaelKq1ex0aaj28XoJu4SCezdS8i0IftUNTtGS1UzGjGZB8zQz5SFwVy4dRu4GLwg=="],
|
||||||
|
|
||||||
|
"@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.74.0", "", { "os": "linux", "cpu": "none" }, "sha512-/RZAP24TgZo4vV/01TBlzRqs0R7E6xvatww4LnmZEBBulQBU/SkypDywfriFqWuFoa61WFXPV7sLcTjJGjim/w=="],
|
||||||
|
|
||||||
|
"@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.74.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-620J1beNAlGSPBD+Msb3ptvrwxu04B8iULCH03zlf0JSLy/5sqlD6qBs0XUVkUJv1vbakUw1gfVnUQqv0UTuEg=="],
|
||||||
|
|
||||||
|
"@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.74.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WBFgQmGtFnPNzHyLKbC1wkYGaRIBxXGofO0+hz1xrrkPgbxbJS1Ukva1EB8sPaVBBQ52Bdc2GjLSp721NWRvww=="],
|
||||||
|
|
||||||
|
"@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.74.0", "", { "os": "linux", "cpu": "x64" }, "sha512-y4mapxi0RGqlp3t6Sm+knJlAEqdKDYrEue2LlXOka/F2i4sRN0XhEMPiSOB3ppHmvK4I2zY2XBYTsX1Fel0fAg=="],
|
||||||
|
|
||||||
|
"@oxc-parser/binding-wasm32-wasi": ["@oxc-parser/binding-wasm32-wasi@0.74.0", "", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.11" }, "cpu": "none" }, "sha512-yDS9bRDh5ymobiS2xBmjlrGdUuU61IZoJBaJC5fELdYT5LJNBXlbr3Yc6m2PWfRJwkH6Aq5fRvxAZ4wCbkGa8w=="],
|
||||||
|
|
||||||
|
"@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.74.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-XFWY52Rfb4N5wEbMCTSBMxRkDLGbAI9CBSL24BIDywwDJMl31gHEVlmHdCDRoXAmanCI6gwbXYTrWe0HvXJ7Aw=="],
|
||||||
|
|
||||||
|
"@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.74.0", "", {}, "sha512-KOw/RZrVlHGhCXh1RufBFF7Nuo7HdY5w1lRJukM/igIl6x9qtz8QycDvZdzb4qnHO7znrPyo2sJrFJK2eKHgfQ=="],
|
||||||
|
|
||||||
|
"@oxlint/darwin-arm64": ["@oxlint/darwin-arm64@1.19.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dSozp6FXowhFEjmT0FC/iBWj9KziWfixxaYT367kOXZUyA0hvOzsLsBB780Swr40zvqklUR0d3fbZbziGHRJoQ=="],
|
||||||
|
|
||||||
|
"@oxlint/darwin-x64": ["@oxlint/darwin-x64@1.19.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-3OY1km70zTlH6b8K8AHSuaEaa4sntmAcBugMZBaJmHkioia7zxlAQV9xtQ2wsBSDQbBmcf1j5Y0NcHP7fmIZvA=="],
|
||||||
|
|
||||||
|
"@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.19.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-o5RAxQfVEu7LsBUwSjEDNdM8sla8WlLMRULsTP3vgxyy1eLJxo2u+4McKtM9/P2KiZQw3NylDoaxU4Z4j/XeRQ=="],
|
||||||
|
|
||||||
|
"@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.19.0", "", { "os": "linux", "cpu": "x64" }, "sha512-iOQooyYzy7RR2yHNM8oHd2Zw6CdU7/G2Uf5ryFi/cF5NV5zlSH//QSkWwrk/kLF69wKqwE8S8snV7WnRA/tXjA=="],
|
||||||
|
|
||||||
|
"@oxlint/win32-arm64": ["@oxlint/win32-arm64@1.19.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-bvgA2fGpdBF/DpB5hZYQzx5fFFiiHxIiPF5zp24czvsIRkezVi9ZH04lCIVkMBxgvKhnU2jLXAn6E1Mbo4QrFw=="],
|
||||||
|
|
||||||
|
"@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=="],
|
||||||
|
|
||||||
|
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||||
|
|
||||||
|
"@types/bun": ["@types/bun@1.2.23", "", { "dependencies": { "bun-types": "1.2.23" } }, "sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@24.5.2", "", { "dependencies": { "undici-types": "~7.12.0" } }, "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ=="],
|
||||||
|
|
||||||
|
"@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=="],
|
||||||
|
|
||||||
|
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
|
"big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"discontinuous-range": ["discontinuous-range@1.0.0", "", {}, "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ=="],
|
||||||
|
|
||||||
|
"hono": ["hono@4.9.8", "", {}, "sha512-JW8Bb4RFWD9iOKxg5PbUarBYGM99IcxFl2FPBo2gSJO11jjUDqlP1Bmfyqt8Z/dGhIQ63PMA9LdcLefXyIasyg=="],
|
||||||
|
|
||||||
|
"jsox": ["jsox@1.2.123", "", { "bin": { "jsox": "lib/cli.js" } }, "sha512-LYordXJ/0Q4G8pUE1Pvh4fkfGvZY7lRe4WIJKl0wr0rtFDVw9lcdNW95GH0DceJ6E9xh41zJNW0vreEz7xOxCw=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"railroad-diagrams": ["railroad-diagrams@1.0.0", "", {}, "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
10
migrations/20240703103050_accounts.up.sql
Normal file
10
migrations/20240703103050_accounts.up.sql
Normal 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)
|
||||||
|
);
|
||||||
11
migrations/20240703103105_movies.up.sql
Normal file
11
migrations/20240703103105_movies.up.sql
Normal 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)
|
||||||
|
);
|
||||||
1692
package-lock.json
generated
1692
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@@ -11,25 +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": "rolldown src/app.ts --file dist/app.js --platform node --format esm --minify"
|
"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"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@prettier/plugin-oxc": "^0.0.4",
|
"@prettier/plugin-oxc": "^0.0.4",
|
||||||
|
"@types/bun": "1.2.23",
|
||||||
"@types/node": "^24.2.1",
|
"@types/node": "^24.2.1",
|
||||||
"@types/pg": "^8.15.5",
|
"oxlint": "^1.19.0",
|
||||||
"oxlint": "^1.11.1",
|
|
||||||
"prettier": "3.6.2",
|
"prettier": "3.6.2",
|
||||||
"rolldown": "^1.0.0-beta.32",
|
"prettier-plugin-sql": "^0.19.2",
|
||||||
"tsx": "^4.20.3",
|
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.18.1",
|
"@hono/zod-validator": "^0.7.3",
|
||||||
"hono": "^4.9.0",
|
"hono": "^4.9.8",
|
||||||
"pg": "^8.16.3",
|
"zod": "^4.1.11"
|
||||||
"zod": "^4.0.17"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
src/app.ts
18
src/app.ts
@@ -1,17 +1,25 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { serve } from "@hono/node-server";
|
|
||||||
import loadConfiguration from "./config";
|
import loadConfiguration from "./config";
|
||||||
import { DatabaseInterface } from "./database/DatabaseInterface";
|
import type { DatabaseInterface } from "./database/DatabaseInterface";
|
||||||
import PgDatabase from "./database/PgDatabase";
|
import PgDatabase from "./database/PgDatabase";
|
||||||
import { setup as setupAccounts } from "./domain/account/setup";
|
import { setup as setupAccounts } from "./domain/account/setup";
|
||||||
|
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));
|
||||||
|
|
||||||
serve({
|
app.onError((err, c) => {
|
||||||
|
if (err instanceof HTTPError) {
|
||||||
|
return c.json({ error: err.message }, err.statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ error: "Internal server error" }, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default {
|
||||||
port: config.port,
|
port: config.port,
|
||||||
fetch: app.fetch,
|
fetch: app.fetch,
|
||||||
});
|
};
|
||||||
|
|||||||
17
src/bin/migrate.ts
Normal file
17
src/bin/migrate.ts
Normal 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"));
|
||||||
@@ -12,8 +12,6 @@ export interface Configuration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function loadConfiguration(): Configuration {
|
export default function loadConfiguration(): Configuration {
|
||||||
process.loadEnvFile();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
database: {
|
database: {
|
||||||
host: getUnsafeEnv("DATABASE_HOST"),
|
host: getUnsafeEnv("DATABASE_HOST"),
|
||||||
@@ -26,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) {
|
||||||
|
|||||||
@@ -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
29
src/database/Migration.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,71 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { AccountServiceInterface } from "../service/AccountServiceInterface";
|
import { validator } from "hono/validator";
|
||||||
|
import type { AccountServiceInterface } from "../service/AccountServiceInterface";
|
||||||
|
import { loginSchema, registerSchema } from "../validation/AccountValidation";
|
||||||
|
import { BadSchemaError } from "../../../errors";
|
||||||
|
import { createRateLimit } from "../../../middleware/rateLimiter";
|
||||||
|
|
||||||
|
const loginRateLimit = createRateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
maxAttempts: 5,
|
||||||
|
keyGenerator: (c) => {
|
||||||
|
const ip =
|
||||||
|
c.req.header("x-forwarded-for") ||
|
||||||
|
c.req.header("x-real-ip") ||
|
||||||
|
"127.0.0.1";
|
||||||
|
return `login:${ip}`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default function toRoutes(
|
export default function toRoutes(
|
||||||
accountService: AccountServiceInterface,
|
accountService: AccountServiceInterface,
|
||||||
): Hono {
|
): Hono {
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
app.post("/login", async (ctx) => {
|
app.post(
|
||||||
try {
|
"/login",
|
||||||
const { email, password } = await ctx.req.json();
|
loginRateLimit,
|
||||||
|
validator("json", (value) => {
|
||||||
|
const parsed = loginSchema.safeParse(value);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new BadSchemaError(parsed.error.message);
|
||||||
|
}
|
||||||
|
|
||||||
const account = await accountService.login(email, password);
|
return parsed.data;
|
||||||
|
}),
|
||||||
|
async (ctx) => {
|
||||||
|
const { username, password } = ctx.req.valid("json");
|
||||||
|
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,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
return ctx.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : "Erreur inconnue",
|
|
||||||
},
|
},
|
||||||
400,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/register",
|
||||||
|
validator("json", (value) => {
|
||||||
|
const parsed = registerSchema.safeParse(value);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new BadSchemaError(parsed.error.message);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
app.post("/register", async (ctx) => {
|
return parsed.data;
|
||||||
try {
|
}),
|
||||||
const { email, password } = await ctx.req.json();
|
async (ctx) => {
|
||||||
|
const { username, password } = ctx.req.valid("json");
|
||||||
const account = await accountService.createAccount(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,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
return ctx.json(
|
|
||||||
{
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : "Erreur inconnue",
|
|
||||||
},
|
},
|
||||||
400,
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,64 @@
|
|||||||
|
import {
|
||||||
|
InvalidUsernameFormatError,
|
||||||
|
WeakPasswordError,
|
||||||
|
} from "../errors/AccountErrors";
|
||||||
|
import {
|
||||||
|
MAX_USERNAME_LENGTH,
|
||||||
|
MIN_PASSWORD_LENGTH,
|
||||||
|
MIN_USERNAME_LENGTH,
|
||||||
|
} 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,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private static validatePassword(password: string): void {
|
||||||
|
if (password.length < MIN_PASSWORD_LENGTH) {
|
||||||
|
throw new WeakPasswordError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static validateUsername(username: string): void {
|
||||||
|
if (
|
||||||
|
username.length < MIN_USERNAME_LENGTH ||
|
||||||
|
username.length > MAX_USERNAME_LENGTH
|
||||||
) {
|
) {
|
||||||
this.validateEmail(email);
|
throw new InvalidUsernameFormatError(username);
|
||||||
this.validatePassword(password);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logique métier : validation de l'email
|
|
||||||
private validateEmail(email: string): void {
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
if (!emailRegex.test(email)) {
|
|
||||||
throw new Error("Format d'email invalide");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private validatePassword(password: string): void {
|
public async verifyPassword(plainPassword: string): Promise<boolean> {
|
||||||
if (password.length < 8) {
|
return await Bun.password.verify(plainPassword, this.password);
|
||||||
throw new Error("Mot de passe trop court");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logique métier : vérification du mot de passe
|
public get hashedPassword(): string {
|
||||||
public verifyPassword(plainPassword: string): boolean {
|
return this.password;
|
||||||
// Dans un vrai projet, on utiliserait bcrypt
|
|
||||||
return this.password === plainPassword;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Factory method pour créer un nouveau compte
|
static async create(
|
||||||
static create(email: string, password: string): AccountEntity {
|
username: string,
|
||||||
const now = new Date();
|
password: string,
|
||||||
const id = crypto.randomUUID();
|
): Promise<CreateAccountEntity> {
|
||||||
return new AccountEntity(id, email, password, 1, now, now);
|
AccountEntity.validatePassword(password);
|
||||||
}
|
AccountEntity.validateUsername(username);
|
||||||
}
|
|
||||||
|
|
||||||
export type CreateAccountDto = {
|
const hashedPassword = await Bun.password.hash(password);
|
||||||
email: string;
|
|
||||||
password: string;
|
return {
|
||||||
|
username,
|
||||||
|
hashedPassword,
|
||||||
|
roleId: 1,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateAccountEntity = Omit<
|
||||||
|
AccountEntity,
|
||||||
|
"id" | "verifyPassword" | "createdAt" | "updatedAt"
|
||||||
|
>;
|
||||||
|
|||||||
44
src/domain/account/errors/AccountErrors.ts
Normal file
44
src/domain/account/errors/AccountErrors.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { HTTPError } from "../../../errors";
|
||||||
|
import { MIN_PASSWORD_LENGTH } from "../validation/AccountValidation";
|
||||||
|
|
||||||
|
export class AccountNotFoundError extends HTTPError {
|
||||||
|
constructor(identifier: string) {
|
||||||
|
super(404, `Compte non trouvé : ${identifier}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AccountAlreadyExistsError extends HTTPError {
|
||||||
|
constructor(username: string) {
|
||||||
|
super(409, `Un compte avec cet username existe déjà : ${username}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BadPasswordError extends HTTPError {
|
||||||
|
constructor() {
|
||||||
|
super(401, "Mot de passe incorrect");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidUsernameFormatError extends HTTPError {
|
||||||
|
constructor(username: string) {
|
||||||
|
super(400, `Format d'username invalide : ${username}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WeakPasswordError extends HTTPError {
|
||||||
|
constructor() {
|
||||||
|
super(
|
||||||
|
400,
|
||||||
|
`Le mot de passe doit contenir au moins ${MIN_PASSWORD_LENGTH} caractères`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TooManyAttemptsError extends HTTPError {
|
||||||
|
constructor(retryAfter: number) {
|
||||||
|
super(
|
||||||
|
429,
|
||||||
|
`Trop de tentatives de connexion. Réessayez dans ${retryAfter} secondes`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<number>;
|
insert(account: CreateAccountEntity): Promise<number>;
|
||||||
findById(id: string): Promise<AccountEntity | null>;
|
findById(id: number): Promise<AccountEntity | null>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,80 @@
|
|||||||
import { DatabaseInterface } from "../../../database/DatabaseInterface";
|
import type { DatabaseInterface } from "../../../database/DatabaseInterface";
|
||||||
import { AccountEntity } from "../entity/AccountEntity";
|
import {
|
||||||
import { AccountRepositoryInterface } from "./AccountRepositoryInterface";
|
AccountEntity,
|
||||||
|
type CreateAccountEntity,
|
||||||
|
} from "../entity/AccountEntity";
|
||||||
|
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> {
|
||||||
// Implémentation simple pour l'exemple
|
const [result] = await this.database.sql<
|
||||||
// Dans un vrai projet, on ferait une requête à la base de données
|
{
|
||||||
console.log(`Recherche du compte avec email: ${email}`);
|
id: number;
|
||||||
|
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}
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async save(account: AccountEntity): Promise<number> {
|
return new AccountEntity(
|
||||||
// Implémentation simple pour l'exemple
|
result.id,
|
||||||
console.log(`Sauvegarde du compte: ${account.id}`);
|
result.username,
|
||||||
return 1;
|
result.password,
|
||||||
|
result.role_id,
|
||||||
|
result.created_at,
|
||||||
|
result.updated_at,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(id: string): Promise<AccountEntity | null> {
|
async insert(account: CreateAccountEntity): Promise<number> {
|
||||||
// Implémentation simple pour l'exemple
|
const [result] = await this.database.sql<{ id: number }[]>`
|
||||||
console.log(`Recherche du compte avec ID: ${id}`);
|
INSERT INTO accounts (username, password, role_id, created_at, updated_at)
|
||||||
|
VALUES (${account.username}, ${account.hashedPassword}, ${account.roleId}, NOW(), NOW())
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
|
||||||
|
return result!.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: number): Promise<AccountEntity | null> {
|
||||||
|
const [result] = await this.database.sql<
|
||||||
|
{
|
||||||
|
id: number;
|
||||||
|
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}
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return new AccountEntity(
|
||||||
|
result.id,
|
||||||
|
result.username,
|
||||||
|
result.password,
|
||||||
|
result.role_id,
|
||||||
|
result.created_at,
|
||||||
|
result.updated_at,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,40 @@
|
|||||||
import { AccountEntity } from "../entity/AccountEntity";
|
import { AccountEntity } from "../entity/AccountEntity";
|
||||||
import { AccountRepositoryInterface } from "../repository/AccountRepositoryInterface";
|
import type { AccountRepositoryInterface } from "../repository/AccountRepositoryInterface";
|
||||||
import { AccountServiceInterface } from "./AccountServiceInterface";
|
import type { AccountServiceInterface } from "./AccountServiceInterface";
|
||||||
|
import {
|
||||||
|
AccountNotFoundError,
|
||||||
|
AccountAlreadyExistsError,
|
||||||
|
BadPasswordError,
|
||||||
|
} from "../errors/AccountErrors";
|
||||||
|
|
||||||
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> {
|
||||||
// Logique métier DDD : authentification
|
const account = await this.accountRepository.findByUsername(username);
|
||||||
const account = await this.accountRepository.findByEmail(email);
|
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
throw new Error("Compte non trouvé");
|
throw new AccountNotFoundError(username);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!account.verifyPassword(password)) {
|
if (!(await account.verifyPassword(password))) {
|
||||||
throw new Error("Mot de passe incorrect");
|
throw new BadPasswordError();
|
||||||
}
|
}
|
||||||
|
|
||||||
return account;
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createAccount(email: string, password: string): Promise<AccountEntity> {
|
async register(username: string, password: string): Promise<AccountEntity> {
|
||||||
// Logique métier DDD : vérifier que l'email n'existe pas déjà
|
const existingAccount =
|
||||||
const existingAccount = await this.accountRepository.findByEmail(email);
|
await this.accountRepository.findByUsername(username);
|
||||||
|
|
||||||
if (existingAccount) {
|
if (existingAccount) {
|
||||||
throw new Error("Un compte avec cet email existe déjà");
|
throw new AccountAlreadyExistsError(username);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utilisation de la factory method de l'entité
|
const newAccount = await AccountEntity.create(username, password);
|
||||||
const newAccount = AccountEntity.create(email, password);
|
const newId = await this.accountRepository.insert(newAccount);
|
||||||
|
|
||||||
await this.accountRepository.save(newAccount);
|
return (await this.accountRepository.findById(newId)) as AccountEntity;
|
||||||
|
|
||||||
return newAccount;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ import { AccountEntity } from "../entity/AccountEntity";
|
|||||||
|
|
||||||
export interface AccountServiceInterface {
|
export interface AccountServiceInterface {
|
||||||
login(email: string, password: string): Promise<AccountEntity>;
|
login(email: string, password: string): Promise<AccountEntity>;
|
||||||
createAccount(email: string, password: string): Promise<AccountEntity>;
|
register(email: string, password: string): Promise<AccountEntity>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { DatabaseInterface } from "../../database/DatabaseInterface";
|
import type { DatabaseInterface } from "../../database/DatabaseInterface";
|
||||||
import toRoutes from "./controller/AccountController";
|
import toRoutes from "./controller/AccountController";
|
||||||
import AccountRepository from "./repository/AccoutRepository";
|
import AccountRepository from "./repository/AccoutRepository";
|
||||||
import AccountService from "./service/AccountService";
|
import AccountService from "./service/AccountService";
|
||||||
|
|||||||
33
src/domain/account/validation/AccountValidation.ts
Normal file
33
src/domain/account/validation/AccountValidation.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const MIN_PASSWORD_LENGTH = 8;
|
||||||
|
export const MAX_USERNAME_LENGTH = 20;
|
||||||
|
export const MIN_USERNAME_LENGTH = 3;
|
||||||
|
|
||||||
|
export const usernameSchema = z
|
||||||
|
.string()
|
||||||
|
.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
|
||||||
|
.string()
|
||||||
|
.min(
|
||||||
|
MIN_PASSWORD_LENGTH,
|
||||||
|
`Le mot de passe doit contenir au moins ${MIN_PASSWORD_LENGTH} caractères`,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const loginSchema = z.object({
|
||||||
|
username: usernameSchema,
|
||||||
|
password: passwordSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const registerSchema = z.object({
|
||||||
|
username: usernameSchema,
|
||||||
|
password: passwordSchema,
|
||||||
|
});
|
||||||
17
src/errors.ts
Normal file
17
src/errors.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
|
|
||||||
|
export class HTTPError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly statusCode: ContentfulStatusCode,
|
||||||
|
message: string,
|
||||||
|
parent?: Error,
|
||||||
|
) {
|
||||||
|
super(message, { cause: parent });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BadSchemaError extends HTTPError {
|
||||||
|
constructor(message: string, parent?: Error) {
|
||||||
|
super(400, message, parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/middleware/rateLimiter.ts
Normal file
61
src/middleware/rateLimiter.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import type { Context, Next } from "hono";
|
||||||
|
import { TooManyAttemptsError } from "../domain/account/errors/AccountErrors";
|
||||||
|
|
||||||
|
interface RateLimitConfig {
|
||||||
|
windowMs: number;
|
||||||
|
maxAttempts: number;
|
||||||
|
keyGenerator?: (c: Context) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AttemptRecord {
|
||||||
|
count: number;
|
||||||
|
resetTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attempts = new Map<string, AttemptRecord>();
|
||||||
|
|
||||||
|
function cleanupExpiredEntries(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, record] of attempts.entries()) {
|
||||||
|
if (now > record.resetTime) {
|
||||||
|
attempts.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRateLimit(config: RateLimitConfig) {
|
||||||
|
const {
|
||||||
|
windowMs,
|
||||||
|
maxAttempts,
|
||||||
|
keyGenerator = (c) => c.req.header("x-forwarded-for") || "127.0.0.1",
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
return async (c: Context, next: Next) => {
|
||||||
|
const key = keyGenerator(c);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
cleanupExpiredEntries();
|
||||||
|
|
||||||
|
const record = attempts.get(key);
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
attempts.set(key, { count: 1, resetTime: now + windowMs });
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now > record.resetTime) {
|
||||||
|
attempts.set(key, { count: 1, resetTime: now + windowMs });
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.count >= maxAttempts) {
|
||||||
|
const retryAfter = Math.ceil((record.resetTime - now) / 1000);
|
||||||
|
throw new TooManyAttemptsError(retryAfter);
|
||||||
|
}
|
||||||
|
|
||||||
|
record.count++;
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
}
|
||||||
80
tests/account/AccountEntity.test.ts
Normal file
80
tests/account/AccountEntity.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
74
tests/account/AccountRepository.test.ts
Normal file
74
tests/account/AccountRepository.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
145
tests/account/AccountService.test.ts
Normal file
145
tests/account/AccountService.test.ts
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
93
tests/account/AccountValidation.test.ts
Normal file
93
tests/account/AccountValidation.test.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, it, expect } from "bun:test";
|
||||||
|
import {
|
||||||
|
passwordSchema,
|
||||||
|
loginSchema,
|
||||||
|
registerSchema,
|
||||||
|
MIN_PASSWORD_LENGTH,
|
||||||
|
} from "../../src/domain/account/validation/AccountValidation";
|
||||||
|
import { WeakPasswordError } from "../../src/domain/account/errors/AccountErrors";
|
||||||
|
import { AccountEntity } from "../../src/domain/account/entity/AccountEntity";
|
||||||
|
|
||||||
|
describe("AccountValidation", () => {
|
||||||
|
describe("Password validation consistency", () => {
|
||||||
|
const validPasswords = ["password123", "abcdefgh", "12345678", "P@ssw0rd!"];
|
||||||
|
|
||||||
|
const invalidPasswords = ["1234567", "short", "", "abc"];
|
||||||
|
|
||||||
|
it("should validate same passwords in Zod and Entity", () => {
|
||||||
|
validPasswords.forEach((password) => {
|
||||||
|
expect(passwordSchema.safeParse(password).success).toBe(true);
|
||||||
|
expect(() =>
|
||||||
|
AccountEntity.create("test@example.com", password),
|
||||||
|
).not.toThrow(WeakPasswordError);
|
||||||
|
});
|
||||||
|
|
||||||
|
invalidPasswords.forEach((password) => {
|
||||||
|
expect(passwordSchema.safeParse(password).success).toBe(false);
|
||||||
|
expect(() =>
|
||||||
|
AccountEntity.create("test@example.com", password),
|
||||||
|
).toThrow(WeakPasswordError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use same minimum length", () => {
|
||||||
|
validPasswords.forEach((password) => {
|
||||||
|
expect(password.length >= MIN_PASSWORD_LENGTH).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
invalidPasswords.forEach((password) => {
|
||||||
|
expect(password.length >= MIN_PASSWORD_LENGTH).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Complete schemas", () => {
|
||||||
|
it("should validate login schema correctly", () => {
|
||||||
|
const validLogin = {
|
||||||
|
username: "testaccount",
|
||||||
|
password: "password123",
|
||||||
|
};
|
||||||
|
const invalidLogin = { username: "ii", password: "123" };
|
||||||
|
|
||||||
|
expect(loginSchema.safeParse(validLogin).success).toBe(true);
|
||||||
|
expect(loginSchema.safeParse(invalidLogin).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate register schema correctly", () => {
|
||||||
|
const validRegister = {
|
||||||
|
username: "testaccount",
|
||||||
|
password: "password123",
|
||||||
|
};
|
||||||
|
const invalidRegister = { username: "ii", password: "123" };
|
||||||
|
|
||||||
|
expect(registerSchema.safeParse(validRegister).success).toBe(true);
|
||||||
|
expect(registerSchema.safeParse(invalidRegister).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Error messages consistency", () => {
|
||||||
|
it("should return consistent password error message", () => {
|
||||||
|
const result = passwordSchema.safeParse("123");
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.issues[0]?.message).toContain(
|
||||||
|
`${MIN_PASSWORD_LENGTH} caractères`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return consistent username error message", () => {
|
||||||
|
const result = registerSchema.safeParse({
|
||||||
|
username: "ii",
|
||||||
|
password: "validPassword123",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.issues[0]?.message).toContain(
|
||||||
|
"doit contenir au moins",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
20
tests/utils.ts
Normal file
20
tests/utils.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,16 +1,29 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "esnext",
|
// Environment setup & latest features
|
||||||
"module": "esnext",
|
"lib": ["ESNext"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "Preserve",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"allowJs": true,
|
||||||
|
|
||||||
|
// Bundler mode
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"lib": ["esnext"],
|
"allowImportingTsExtensions": true,
|
||||||
"types": ["node"],
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
// Best practices
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"exactOptionalPropertyTypes": true,
|
"noImplicitOverride": true,
|
||||||
|
|
||||||
"isolatedModules": true,
|
// Some stricter flags (disabled by default)
|
||||||
"skipLibCheck": true
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
9
vite.config.ts
Normal file
9
vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: "node",
|
||||||
|
include: ["tests/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user