diff --git a/package.json b/package.json index 69c8ebf..b0aad8a 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "dev": "sst dev --stage dev", "test": "vitest", + "test:coverage": "vitest --coverage", "deploy": "sst deploy", "remove": "sst remove", "console": "sst console", @@ -24,6 +25,7 @@ "devDependencies": { "@biomejs/biome": "^1.9.4", "@types/node": "^20.12.7", + "@vitest/coverage-v8": "2.1.5", "drizzle-kit": "^0.30.1", "typescript": "^5.4.5", "vitest": "^2.1.5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52642b8..2d52fe7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,9 @@ importers: '@types/node': specifier: ^20.12.7 version: 20.14.13 + '@vitest/coverage-v8': + specifier: 2.1.5 + version: 2.1.5(vitest@2.1.5(@types/node@20.14.13)) drizzle-kit: specifier: ^0.30.1 version: 0.30.1 @@ -48,10 +51,34 @@ importers: packages: + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@babel/helper-string-parser@7.25.9': + resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.25.9': + resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.26.3': + resolution: {integrity: sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/runtime@7.25.0': resolution: {integrity: sha512-7dRy4DwXwtzBrPbZflqxnvfxLF8kdZXPkhymtDeFoFqE6ldzjQFgYTtYIFARcLEYDrqfBfYcZt1WqFxRoyC9Rw==} engines: {node: '>=6.9.0'} + '@babel/types@7.26.3': + resolution: {integrity: sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@biomejs/biome@1.9.4': resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==} engines: {node: '>=14.21.3'} @@ -524,12 +551,39 @@ packages: cpu: [x64] os: [win32] + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jridgewell/gen-mapping@0.3.8': + resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} + engines: {node: '>=6.0.0'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.0': resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + '@jridgewell/trace-mapping@0.3.25': + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@neondatabase/serverless@0.10.4': resolution: {integrity: sha512-2nZuh3VUO9voBauuh+IGYRhGU/MskWHt1IuZvHcJw6GLjDgtqj/KViKo7SIrLdGLdot7vFbiRRw+BgEy3wT9HA==} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@rollup/rollup-android-arm-eabi@4.27.3': resolution: {integrity: sha512-EzxVSkIvCFxUd4Mgm4xR9YXrcp976qVaHnqom/Tgm+vU79k4vV4eYTjmRvGfeoW8m9LVcsAy/lGjcgVegKEhLQ==} cpu: [arm] @@ -639,6 +693,15 @@ packages: '@upstash/redis@1.34.0': resolution: {integrity: sha512-TrXNoJLkysIl8SBc4u9bNnyoFYoILpCcFJcLyWCccb/QSUmaVKdvY0m5diZqc3btExsapcMbaw/s/wh9Sf1pJw==} + '@vitest/coverage-v8@2.1.5': + resolution: {integrity: sha512-/RoopB7XGW7UEkUndRXF87A9CwkoZAJW01pj8/3pgmDVsjMH2IKy6H1A38po9tmUlwhSyYs0az82rbKd9Yaynw==} + peerDependencies: + '@vitest/browser': 2.1.5 + vitest: 2.1.5 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@2.1.5': resolution: {integrity: sha512-nZSBTW1XIdpZvEJyoP/Sy8fUg0b8od7ZpGDkTUcfJ7wz/VoZAFzFfLyxVxGFhUjJzhYqSbIpfMtl/+k/dpWa3Q==} @@ -668,6 +731,22 @@ packages: '@vitest/utils@2.1.5': resolution: {integrity: sha512-yfj6Yrp0Vesw2cwJbP+cl04OC+IHFsuQsrsJBL9pyGeQXE56v1UAOQco+SR55Vf1nQzfV0QJg1Qum7AaWUwwYg==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.1.0: + resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -675,6 +754,12 @@ packages: aws4fetch@1.0.19: resolution: {integrity: sha512-N+F8pZ9hVjckkHODDyalITRNxBJxAGX5ShkVoAgHwqERXsW8Iu5ziFx3SCjGlxx/YStWBTZx4HI/GCMvTBu5kQ==} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -690,6 +775,17 @@ packages: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} @@ -806,6 +902,15 @@ packages: sqlite3: optional: true + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + es-module-lexer@1.5.4: resolution: {integrity: sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==} @@ -836,6 +941,10 @@ packages: resolution: {integrity: sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==} engines: {node: '>=12.0.0'} + foreground-child@3.3.0: + resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} + engines: {node: '>=14'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -844,10 +953,47 @@ packages: get-tsconfig@4.8.1: resolution: {integrity: sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg==} + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + hono@4.6.5: resolution: {integrity: sha512-qsmN3V5fgtwdKARGLgwwHvcdLKursMd+YOt69eGpl1dUCJb8mCd7hZfyZnBYjxCegBG7qkJRQRUy2oO25yHcyQ==} engines: {node: '>=16.9.0'} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.1.7: + resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jose@4.15.9: resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} @@ -857,6 +1003,9 @@ packages: loupe@3.1.2: resolution: {integrity: sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@6.0.0: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} @@ -864,6 +1013,21 @@ packages: magic-string@0.30.13: resolution: {integrity: sha512-8rYBO+MsWkgjDSOvLomYnzhdwEG51olQ4zL5KXnNJWV5MNmrb4rTZdrtkhxjnD/QyZUqR/Z/XDsUs/4ej2nx0g==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -886,6 +1050,17 @@ packages: openid-client@5.6.4: resolution: {integrity: sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA==} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -945,9 +1120,26 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + semver@7.6.3: + resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1002,6 +1194,30 @@ packages: std-env@3.8.0: resolution: {integrity: sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + test-exclude@7.0.1: + resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} + engines: {node: '>=18'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1089,20 +1305,53 @@ packages: jsdom: optional: true + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} snapshots: + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.25 + + '@babel/helper-string-parser@7.25.9': {} + + '@babel/helper-validator-identifier@7.25.9': {} + + '@babel/parser@7.26.3': + dependencies: + '@babel/types': 7.26.3 + '@babel/runtime@7.25.0': dependencies: regenerator-runtime: 0.14.1 + '@babel/types@7.26.3': + dependencies: + '@babel/helper-string-parser': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 + + '@bcoe/v8-coverage@0.2.3': {} + '@biomejs/biome@1.9.4': optionalDependencies: '@biomejs/cli-darwin-arm64': 1.9.4 @@ -1354,12 +1603,41 @@ snapshots: '@esbuild/win32-x64@0.21.5': optional: true + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/schema@0.1.3': {} + + '@jridgewell/gen-mapping@0.3.8': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.25 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/set-array@1.2.1': {} + '@jridgewell/sourcemap-codec@1.5.0': {} + '@jridgewell/trace-mapping@0.3.25': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + '@neondatabase/serverless@0.10.4': dependencies: '@types/pg': 8.11.6 + '@pkgjs/parseargs@0.11.0': + optional: true + '@rollup/rollup-android-arm-eabi@4.27.3': optional: true @@ -1438,6 +1716,24 @@ snapshots: dependencies: crypto-js: 4.2.0 + '@vitest/coverage-v8@2.1.5(vitest@2.1.5(@types/node@20.14.13))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 0.2.3 + debug: 4.3.7 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.1.7 + magic-string: 0.30.13 + magicast: 0.3.5 + std-env: 3.8.0 + test-exclude: 7.0.1 + tinyrainbow: 1.2.0 + vitest: 2.1.5(@types/node@20.14.13) + transitivePeerDependencies: + - supports-color + '@vitest/expect@2.1.5': dependencies: '@vitest/spy': 2.1.5 @@ -1478,10 +1774,26 @@ snapshots: loupe: 3.1.2 tinyrainbow: 1.2.0 + ansi-regex@5.0.1: {} + + ansi-regex@6.1.0: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.1: {} + assertion-error@2.0.1: {} aws4fetch@1.0.19: {} + balanced-match@1.0.2: {} + + brace-expansion@2.0.1: + dependencies: + balanced-match: 1.0.2 + buffer-from@1.1.2: {} cac@6.7.14: {} @@ -1496,6 +1808,18 @@ snapshots: check-error@2.1.1: {} + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + crypto-js@4.2.0: {} date-fns@2.30.0: @@ -1522,6 +1846,12 @@ snapshots: '@neondatabase/serverless': 0.10.4 '@types/pg': 8.11.6 + eastasianwidth@0.2.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + es-module-lexer@1.5.4: {} esbuild-register@3.6.0(esbuild@0.19.12): @@ -1614,6 +1944,11 @@ snapshots: expect-type@1.1.0: {} + foreground-child@3.3.0: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + fsevents@2.3.3: optional: true @@ -1621,14 +1956,60 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + glob@10.4.5: + dependencies: + foreground-child: 3.3.0 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + has-flag@4.0.0: {} + hono@4.6.5: {} + html-escaper@2.0.2: {} + + is-fullwidth-code-point@3.0.0: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + debug: 4.3.7 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.1.7: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jose@4.15.9: {} jose@5.2.3: {} loupe@3.1.2: {} + lru-cache@10.4.3: {} + lru-cache@6.0.0: dependencies: yallist: 4.0.0 @@ -1637,6 +2018,22 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.26.3 + '@babel/types': 7.26.3 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.6.3 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.1 + + minipass@7.1.2: {} + ms@2.1.3: {} nanoid@3.3.8: {} @@ -1654,6 +2051,15 @@ snapshots: object-hash: 2.2.0 oidc-token-hash: 5.0.3 + package-json-from-dist@1.0.1: {} + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + pathe@1.1.2: {} pathval@2.0.0: {} @@ -1722,8 +2128,18 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.27.3 fsevents: 2.3.3 + semver@7.6.3: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + signal-exit@4.1.0: {} + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -1765,6 +2181,36 @@ snapshots: std-env@3.8.0: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.0: + dependencies: + ansi-regex: 6.1.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + test-exclude@7.0.1: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 10.4.5 + minimatch: 9.0.5 + tinybench@2.9.0: {} tinyexec@0.3.1: {} @@ -1841,9 +2287,25 @@ snapshots: - supports-color - terser + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + yallist@4.0.0: {} diff --git a/src/lib/getCarsByFuelType.ts b/src/lib/getCarsByFuelType.ts index f74f4b3..97bb16e 100644 --- a/src/lib/getCarsByFuelType.ts +++ b/src/lib/getCarsByFuelType.ts @@ -1,8 +1,9 @@ import db from "@/config/db"; +import { getLatestMonth } from "@/lib/getLatestMonth"; import { cars } from "@/schema"; -import { FuelType } from "@/types"; -import { format, subMonths } from "date-fns"; -import { and, asc, desc, gte, ilike, or } from "drizzle-orm"; +import type { FuelType } from "@/types"; +import getTrailingTwelveMonths from "@/utils/getTrailingTwelveMonths"; +import { and, asc, between, desc, eq, ilike, or } from "drizzle-orm"; const HYBRID_TYPES = [ "Diesel-Electric", @@ -11,49 +12,47 @@ const HYBRID_TYPES = [ "Petrol-Electric (Plug-In)", ]; -const FUEL_TYPE_MAP = { - DIESEL: [FuelType.Diesel], - ELECTRIC: [FuelType.Electric], - OTHERS: [FuelType.Others], - PETROL: [FuelType.Petrol], -}; - -const trailingTwelveMonths = format(subMonths(new Date(), 12), "yyyy-MM"); - -export const getCarsByFuelType = async (fuelType: string, month?: string) => { - const normalisedFuelType = fuelType.toUpperCase(); +export const getCarsByFuelType = async (fuelType: FuelType, month?: string) => { + const latestMonth = await getLatestMonth(cars); const filters = [ fuelType && or( - ilike(cars.fuelType, FUEL_TYPE_MAP[normalisedFuelType]), - ...HYBRID_TYPES.map((type) => ilike(cars.fuelType, type)), + ilike(cars.fuel_type, fuelType), + ...HYBRID_TYPES.map((type) => ilike(cars.fuel_type, type)), ), - month && gte(cars.month, trailingTwelveMonths), + month + ? eq(cars.month, month) + : between(cars.month, getTrailingTwelveMonths(latestMonth), latestMonth), ]; - const result = await db - .select() - .from(cars) - .where(and(...filters)) - .orderBy(desc(cars.month), asc(cars.make)); - - return result.reduce((result, { month, make, number, ...car }) => { - const existingCar = result.find( - (car) => car.month === month && car.make === make, - ); - - if (existingCar) { - existingCar.number += Number(number); - } else { - result.push({ - ...car, - month, - make, - number: Number(number), - }); - } - - return result; - }, []); + try { + const results = await db + .select() + .from(cars) + .where(and(...filters)) + .orderBy(desc(cars.month), asc(cars.make)); + + return results.reduce((result, { month, make, number, ...car }) => { + const existingCar = result.find( + (car) => car.month === month && car.make === make, + ); + + if (existingCar) { + existingCar.number += Number(number); + } else { + result.push({ + ...car, + month, + make, + number: Number(number), + }); + } + + return result; + }, []); + } catch (e) { + console.error(e); + throw e; + } }; diff --git a/src/lib/getLatestMonth.ts b/src/lib/getLatestMonth.ts index 8b1c07c..7a7a762 100644 --- a/src/lib/getLatestMonth.ts +++ b/src/lib/getLatestMonth.ts @@ -2,21 +2,23 @@ import db from "@/config/db"; import { desc, max } from "drizzle-orm"; import type { PgTable } from "drizzle-orm/pg-core"; -export const getLatestMonth = async (table: T) => { +export const getLatestMonth = async ( + table: T, +): Promise => { const key = "month"; try { - const result = await db + const results = await db .select({ month: max(table[key]) }) .from(table) .orderBy(desc(max(table[key]))) .limit(1); - if (!result) { + if (!results) { throw new Error(`No data found for table: ${table}`); } - return result[0].month; + return results[0].month; } catch (e) { console.error(e); throw e; diff --git a/src/lib/getUniqueMonths.ts b/src/lib/getUniqueMonths.ts index 90f723b..3a88fd8 100644 --- a/src/lib/getUniqueMonths.ts +++ b/src/lib/getUniqueMonths.ts @@ -16,12 +16,12 @@ export const getUniqueMonths = async ( let months = await redis.smembers(CACHE_KEY); if (months.length === 0) { - const result = await db + const results = await db .selectDistinct({ month: table[key] }) .from(table) .orderBy(desc(table[key])); - months = result.map(({ month }) => month); + months = results.map(({ month }) => month); await redis.sadd(CACHE_KEY, ...months); await redis.expire(CACHE_KEY, CACHE_TTL); diff --git a/src/utils/__tests__/getTrailingTwelveMonths.test.ts b/src/utils/__tests__/getTrailingTwelveMonths.test.ts new file mode 100644 index 0000000..9f87839 --- /dev/null +++ b/src/utils/__tests__/getTrailingTwelveMonths.test.ts @@ -0,0 +1,37 @@ +import getTrailingTwelveMonths from "@/utils/getTrailingTwelveMonths"; +import { describe, expect, it } from "vitest"; + +describe("getTrailingTwelveMonths", () => { + // Basic functionality tests + describe("Standard date inputs", () => { + it("should return correct trailing 12 months start date for mid-year input", () => { + expect(getTrailingTwelveMonths("2023-07")).toBe("2022-08"); + }); + + it("should return correct trailing 12 months start date for year-end", () => { + expect(getTrailingTwelveMonths("2023-12")).toBe("2023-01"); + }); + + it("should return correct trailing 12 months start date for year-start", () => { + expect(getTrailingTwelveMonths("2023-01")).toBe("2022-02"); + }); + }); + + // Edge case tests + describe("Edge cases", () => { + it("should handle single-digit month inputs", () => { + expect(getTrailingTwelveMonths("2023-03")).toBe("2022-04"); + }); + + it("should handle year transitions correctly", () => { + expect(getTrailingTwelveMonths("2024-01")).toBe("2023-02"); + }); + }); + + // Leap year considerations + describe("Leap year handling", () => { + it("should work correctly across leap year boundaries", () => { + expect(getTrailingTwelveMonths("2024-02")).toBe("2023-03"); + }); + }); +}); diff --git a/src/utils/getTrailingTwelveMonths.ts b/src/utils/getTrailingTwelveMonths.ts new file mode 100644 index 0000000..dc8b281 --- /dev/null +++ b/src/utils/getTrailingTwelveMonths.ts @@ -0,0 +1,8 @@ +import { format, subMonths } from "date-fns"; + +const getTrailingTwelveMonths = (dateString: string) => { + const targetDate = new Date(`${dateString}-01`); + return format(subMonths(targetDate, 11), "yyyy-MM"); +}; + +export default getTrailingTwelveMonths; diff --git a/src/v1/routes/cars.ts b/src/v1/routes/cars.ts index 2835928..e688ae5 100644 --- a/src/v1/routes/cars.ts +++ b/src/v1/routes/cars.ts @@ -1,50 +1,63 @@ import db from "@/config/db"; import redis from "@/config/redis"; +import { getLatestMonth } from "@/lib/getLatestMonth"; import { getUniqueMonths } from "@/lib/getUniqueMonths"; import { groupMonthsByYear } from "@/lib/groupMonthsByYear"; import { cars } from "@/schema"; import type { Make } from "@/types"; -import { and, asc, between, desc, ilike } from "drizzle-orm"; +import getTrailingTwelveMonths from "@/utils/getTrailingTwelveMonths"; +import { and, asc, between, desc, eq, ilike } from "drizzle-orm"; import { Hono } from "hono"; const app = new Hono(); app.get("/", async (c) => { const query = c.req.query(); - - const cacheKey = `cars:${JSON.stringify(query)}`; - - const cachedData = await redis.get(cacheKey); - if (cachedData) { - return c.json(cachedData); - } - - const today = new Date(); - const pastYear = new Date(today.getFullYear() - 1, today.getMonth() + 1, 1); - const pastYearFormatted = pastYear.toISOString().slice(0, 7); // YYYY-MM format - const currentMonthFormatted = today.toISOString().slice(0, 7); // YYYY-MM format - - const conditions = [ - ...(query.month - ? [] - : [between(cars.month, pastYearFormatted, currentMonthFormatted)]), - ]; - - for (const [key, value] of Object.entries(query)) { - if (!value) continue; - - conditions.push(ilike(cars[key], `%${value}%`)); + const { month, ...queries } = query; + + // const CACHE_KEY = `cars:${JSON.stringify(query)}`; + // + // const cachedData = await redis.get(CACHE_KEY); + // if (cachedData) { + // return c.json(cachedData); + // } + + try { + const latestMonth = !month && (await getLatestMonth(cars)); + + const filters = [ + month + ? eq(cars.month, month) + : between( + cars.month, + getTrailingTwelveMonths(latestMonth), + latestMonth, + ), + ]; + + for (const [key, value] of Object.entries(queries)) { + filters.push(ilike(cars[key], value.split("-").join("%"))); + } + + const results = await db + .select() + .from(cars) + .where(and(...filters)) + .orderBy(desc(cars.month)); + + // await redis.set(CACHE_KEY, JSON.stringify(results), { ex: 86400 }); + + return c.json(results); + } catch (e) { + console.error("Car query error:", e); + return c.json( + { + error: "An error occurred while fetching cars", + details: e.message, + }, + 500, + ); } - - const response = await db - .select() - .from(cars) - .where(and(...conditions)) - .orderBy(desc(cars.month)); - - await redis.set(cacheKey, JSON.stringify(response), { ex: 86400 }); - - return c.json(response); }); app.get("/months", async (c) => { diff --git a/src/v1/routes/coe.ts b/src/v1/routes/coe.ts index 48cf839..1d3e0b2 100644 --- a/src/v1/routes/coe.ts +++ b/src/v1/routes/coe.ts @@ -33,7 +33,7 @@ app.get("/", async (c) => { to && lte(coe.month, to), ]; - const result = await db + const results = await db .select() .from(coe) .where(and(...filters)) @@ -64,7 +64,7 @@ app.get("/latest", async (c) => { } const latestMonth = await getLatestMonth(coe); - const result = await db + const results = await db .select() .from(coe) .where(eq(coe.month, latestMonth)) diff --git a/src/v1/routes/makes.ts b/src/v1/routes/makes.ts index dc23a9b..37e9812 100644 --- a/src/v1/routes/makes.ts +++ b/src/v1/routes/makes.ts @@ -45,7 +45,7 @@ app.get("/:make", async (c) => { vehicle_type && ilike(cars.vehicle_type, vehicle_type.split("-").join("%")), ].filter(Boolean); - const result = await db + const results = await db .select() .from(cars) .where(and(...filters)) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..4fe1bc1 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,21 @@ +import { resolve } from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + include: ["src/**/*.{js,ts}"], + exclude: ["**/node_modules/**", "**/*.d.ts"], + }, + include: ["src/**/*.{test,spec}.{js,ts}"], + }, + resolve: { + alias: { + "@": resolve(__dirname, "./src"), + }, + }, +});