Compare commits

4 Commits

Author SHA1 Message Date
aaa0ca5a54 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.
2025-10-02 20:54:41 +00:00
91c14f750e switch to bun 2025-09-22 21:39:45 +00:00
049ed4b956 move to bun 2025-08-26 18:44:33 +02:00
3fe9fc7142 Ajout de la gestion des erreurs HTTP, implémentation de la validation des comptes, et mise à jour des dépendances. Création de tests pour les entités et services de compte, ainsi que l'ajout d'un système de limitation de taux. 2025-08-12 18:29:05 +00:00
34 changed files with 1099 additions and 1843 deletions

View File

@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"mcp__context7__get-library-docs"
],
"deny": [],
"ask": []
}
}

View File

@@ -1,7 +1,6 @@
{
"workspaceFolder": "/workspace",
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,Z",
"runArgs": ["--network=dev-network"],
"build": {
"dockerfile": "Dockerfile"
},

View File

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

View File

@@ -1,2 +1,30 @@
# 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
View 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=="],
}
}

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)
);

1692
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,25 +11,25 @@
"type": "module",
"main": "dist/app.js",
"scripts": {
"dev": "tsx watch src/app.ts",
"dev": "bun run --watch src/app.ts",
"lint": "oxlint .",
"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": {
"@prettier/plugin-oxc": "^0.0.4",
"@types/bun": "1.2.23",
"@types/node": "^24.2.1",
"@types/pg": "^8.15.5",
"oxlint": "^1.11.1",
"oxlint": "^1.19.0",
"prettier": "3.6.2",
"rolldown": "^1.0.0-beta.32",
"tsx": "^4.20.3",
"prettier-plugin-sql": "^0.19.2",
"typescript": "^5.9.2"
},
"dependencies": {
"@hono/node-server": "^1.18.1",
"hono": "^4.9.0",
"pg": "^8.16.3",
"zod": "^4.0.17"
"@hono/zod-validator": "^0.7.3",
"hono": "^4.9.8",
"zod": "^4.1.11"
}
}

View File

@@ -1,17 +1,25 @@
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import loadConfiguration from "./config";
import { DatabaseInterface } from "./database/DatabaseInterface";
import type { DatabaseInterface } from "./database/DatabaseInterface";
import PgDatabase from "./database/PgDatabase";
import { setup as setupAccounts } from "./domain/account/setup";
import { HTTPError } from "./errors";
const config = loadConfiguration();
const database: DatabaseInterface = new PgDatabase(config.database);
const database: DatabaseInterface = PgDatabase.fromOptions(config.database);
const app = new Hono();
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,
fetch: app.fetch,
});
};

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

@@ -12,8 +12,6 @@ export interface Configuration {
}
export default function loadConfiguration(): Configuration {
process.loadEnvFile();
return {
database: {
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 {
const value = process.env[key];
if (value === undefined) {

View File

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

View File

@@ -1,54 +1,71 @@
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(
accountService: AccountServiceInterface,
): Hono {
const app = new Hono();
app.post("/login", async (ctx) => {
try {
const { email, password } = await ctx.req.json();
app.post(
"/login",
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({
success: true,
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) => {
try {
const { email, password } = await ctx.req.json();
const account = await accountService.createAccount(email, password);
return parsed.data;
}),
async (ctx) => {
const { username, password } = ctx.req.valid("json");
const account = await accountService.register(username, password);
return ctx.json({
success: true,
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;
}

View File

@@ -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 {
constructor(
public readonly id: string,
public readonly email: string,
public readonly id: number,
public readonly username: string,
private password: string,
public readonly roleId: number,
public readonly createdAt: 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);
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");
throw new InvalidUsernameFormatError(username);
}
}
private validatePassword(password: string): void {
if (password.length < 8) {
throw new Error("Mot de passe trop court");
}
public async verifyPassword(plainPassword: string): Promise<boolean> {
return await Bun.password.verify(plainPassword, this.password);
}
// Logique métier : vérification du mot de passe
public verifyPassword(plainPassword: string): boolean {
// Dans un vrai projet, on utiliserait bcrypt
return this.password === plainPassword;
public get hashedPassword(): string {
return this.password;
}
// Factory method pour créer un nouveau compte
static create(email: string, password: string): AccountEntity {
const now = new Date();
const id = crypto.randomUUID();
return new AccountEntity(id, email, password, 1, now, now);
}
}
static async create(
username: string,
password: string,
): Promise<CreateAccountEntity> {
AccountEntity.validatePassword(password);
AccountEntity.validateUsername(username);
export type CreateAccountDto = {
email: string;
password: string;
const hashedPassword = await Bun.password.hash(password);
return {
username,
hashedPassword,
roleId: 1,
};
}
}
export type CreateAccountEntity = Omit<
AccountEntity,
"id" | "verifyPassword" | "createdAt" | "updatedAt"
>;

View 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`,
);
}
}

View File

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

View File

@@ -1,26 +1,80 @@
import { DatabaseInterface } from "../../../database/DatabaseInterface";
import { AccountEntity } from "../entity/AccountEntity";
import { AccountRepositoryInterface } from "./AccountRepositoryInterface";
import type { DatabaseInterface } from "../../../database/DatabaseInterface";
import {
AccountEntity,
type CreateAccountEntity,
} from "../entity/AccountEntity";
import type { AccountRepositoryInterface } from "./AccountRepositoryInterface";
export default class AccountRepository implements AccountRepositoryInterface {
constructor(private readonly database: DatabaseInterface) {}
async findByEmail(email: string): Promise<AccountEntity | null> {
// Implémentation simple pour l'exemple
// Dans un vrai projet, on ferait une requête à la base de données
console.log(`Recherche du compte avec email: ${email}`);
async findByUsername(username: string): 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 username = ${username}
`;
if (!result) {
return null;
}
async save(account: AccountEntity): Promise<number> {
// Implémentation simple pour l'exemple
console.log(`Sauvegarde du compte: ${account.id}`);
return 1;
return new AccountEntity(
result.id,
result.username,
result.password,
result.role_id,
result.created_at,
result.updated_at,
);
}
async findById(id: string): Promise<AccountEntity | null> {
// Implémentation simple pour l'exemple
console.log(`Recherche du compte avec ID: ${id}`);
async insert(account: CreateAccountEntity): Promise<number> {
const [result] = await this.database.sql<{ id: number }[]>`
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 new AccountEntity(
result.id,
result.username,
result.password,
result.role_id,
result.created_at,
result.updated_at,
);
}
}

View File

@@ -1,38 +1,40 @@
import { AccountEntity } from "../entity/AccountEntity";
import { AccountRepositoryInterface } from "../repository/AccountRepositoryInterface";
import { AccountServiceInterface } from "./AccountServiceInterface";
import type { AccountRepositoryInterface } from "../repository/AccountRepositoryInterface";
import type { AccountServiceInterface } from "./AccountServiceInterface";
import {
AccountNotFoundError,
AccountAlreadyExistsError,
BadPasswordError,
} from "../errors/AccountErrors";
export default class AccountService implements AccountServiceInterface {
constructor(private readonly accountRepository: AccountRepositoryInterface) {}
async login(email: string, password: string): Promise<AccountEntity> {
// Logique métier DDD : authentification
const account = await this.accountRepository.findByEmail(email);
async login(username: string, password: string): Promise<AccountEntity> {
const account = await this.accountRepository.findByUsername(username);
if (!account) {
throw new Error("Compte non trouvé");
throw new AccountNotFoundError(username);
}
if (!account.verifyPassword(password)) {
throw new Error("Mot de passe incorrect");
if (!(await account.verifyPassword(password))) {
throw new BadPasswordError();
}
return account;
}
async createAccount(email: string, password: string): Promise<AccountEntity> {
// Logique métier DDD : vérifier que l'email n'existe pas déjà
const existingAccount = await this.accountRepository.findByEmail(email);
async register(username: string, password: string): Promise<AccountEntity> {
const existingAccount =
await this.accountRepository.findByUsername(username);
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 = AccountEntity.create(email, password);
const newAccount = await AccountEntity.create(username, password);
const newId = await this.accountRepository.insert(newAccount);
await this.accountRepository.save(newAccount);
return newAccount;
return (await this.accountRepository.findById(newId)) as AccountEntity;
}
}

View File

@@ -2,5 +2,5 @@ import { AccountEntity } from "../entity/AccountEntity";
export interface AccountServiceInterface {
login(email: string, password: string): Promise<AccountEntity>;
createAccount(email: string, password: string): Promise<AccountEntity>;
register(email: string, password: string): Promise<AccountEntity>;
}

View File

@@ -1,5 +1,5 @@
import { Hono } from "hono";
import { DatabaseInterface } from "../../database/DatabaseInterface";
import type { DatabaseInterface } from "../../database/DatabaseInterface";
import toRoutes from "./controller/AccountController";
import AccountRepository from "./repository/AccoutRepository";
import AccountService from "./service/AccountService";

View 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
View 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);
}
}

View 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();
};
}

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

@@ -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
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;
}

View File

@@ -1,16 +1,29 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"lib": ["esnext"],
"types": ["node"],
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"isolatedModules": true,
"skipLibCheck": true
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

9
vite.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["tests/**/*.test.ts"],
},
});