diff --git a/knip.jsonc b/knip.jsonc index 3b669b0..9840b89 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -17,6 +17,9 @@ "app/styles/critical-product.tailwind.cjs", // Pulled in by Sass (@import in app.scss and critical.scss) and by the swiper/css vite alias; knip does not follow Sass. "app/styles/vendor/swiper.css", + // Shared mock backend: a library of factories, scenarios and a browser worker that tests and + // Storybook/Playwright use as they need; not every export has a caller yet. + "tests/mocks/**", ], // Generated by the TanStack router plugin; gitignored and absent on a clean checkout. "ignoreUnresolved": ["^\\./routeTree\\.gen$"], diff --git a/package.json b/package.json index 02d4ddf..8d32523 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "globals": "^17.13.0", "jsdom": "^30.0.1", "knip": "^6.43.0", + "msw": "^3.0.3", "postcss": "8.5.26", "prettier": "^3.9.10", "rollup-plugin-visualizer": "^7.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f15e7a7..7cd9630 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,6 +103,9 @@ importers: knip: specifier: ^6.43.0 version: 6.43.0 + msw: + specifier: ^3.0.3 + version: 3.0.3(@types/node@26.6.5)(typescript@6.0.3)(vite@8.2.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@1.21.7)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)) postcss: specifier: 8.5.26 version: 8.5.26 @@ -129,7 +132,7 @@ importers: version: 8.2.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@1.21.7)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1) vitest: specifier: ^4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/coverage-v8@4.1.11)(jsdom@30.1.2)(vite@8.2.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@1.21.7)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/coverage-v8@4.1.11)(jsdom@30.1.2)(msw@3.0.3(@types/node@26.6.5)(typescript@6.0.3)(vite@8.2.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@1.21.7)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(vite@8.2.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@1.21.7)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)) packages: @@ -894,6 +897,41 @@ packages: cpu: [x64] os: [win32] + '@inquirer/ansi@2.0.10': + resolution: {integrity: sha512-dwrc+1aqVZpHNkNDswXGtn1typ+Pxpo5FHZRyBw1le7onEJUkPUkYOY1FIFC1LvFMYUqqLuWBwElhAmencL7MQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/confirm@6.3.5': + resolution: {integrity: sha512-BJ59BOyURiZiAxdr9AuRu38/k+rV+DcTIM2fUF/c/BXrab6H8zMZTTOl3LgkSJH96Xob2vOJxDiBkHiz32Xx2w==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@12.0.6': + resolution: {integrity: sha512-5yxn2LsJ8Tu4ei2a+yNkmFRhM/cb+JMEsEs/AW6CCOnBnpMy4IjYiPMFyHtYq8weOmlTyqqCx40y0DsD7OsEPw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@2.0.10': + resolution: {integrity: sha512-51elrUJV3jZpvD0ygz8+U10Kc6+Lzqln+/tYmnbhy6jo5oXpE6hMCcv+OTBC0enwJXsb3JIuT3VtEinRNCSK2A==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/type@4.2.1': + resolution: {integrity: sha512-lDkPsnv5M5CcYB2T1qTc3dobNTPkVr4DU3Ic2Q6SW8/qVRHgvzdRdmVZ6YqG9Ag1aJTeMsLU0s0WPRh3CZWDQQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@jitsu/js@1.11.0': resolution: {integrity: sha512-QSHeXLVYfhyDKKPvFDAmQQfh6bqYinR+t45F4PiT+WhDkQ3igef6xh8X0+vF/Y9Vua3M1Psd70CkyilF6fYxvA==} peerDependencies: @@ -938,6 +976,13 @@ packages: '@lit/reactive-element@2.1.2': resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==} + '@msw/url@0.1.2': + resolution: {integrity: sha512-DdeBLQxQ+ZrtmwEmaj7GQ7lbzPCgbbbyAqOz/I4ulo68hwBpfdNvQJ5bmihJUkAAKOeCmqCIG3OFZGosqFN4mA==} + + '@mswjs/interceptors@0.45.10': + resolution: {integrity: sha512-pGZkvxzv6PZAQCpZHuu+TRs/4U8GWlClRToQETz4MHwwnq0dSxS279xRQ2StBVPpwXxTKtXpLHeK6MGSuxh8uA==} + engines: {node: '>=22'} + '@napi-rs/wasm-runtime@1.2.6': resolution: {integrity: sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -973,6 +1018,9 @@ packages: resolution: {integrity: sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==} engines: {node: '>=20.0'} + '@open-draft/until@3.0.1': + resolution: {integrity: sha512-s7/9ELP4aP9YZtW7RJaa0Xf3RISaRH9+EFN18FtykJYRHGNrl8f9ymvoNXzjhrjmftFvkQudtuDU9RYhk0xs3A==} + '@opentelemetry/api-logs@0.220.0': resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==} engines: {node: '>=8.0.0'} @@ -2081,6 +2129,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -2100,6 +2151,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@20.19.43': resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} @@ -2114,6 +2168,9 @@ packages: '@types/react@19.3.0': resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + '@types/set-cookie-parser@2.4.10': + resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==} + '@types/supercluster@7.1.3': resolution: {integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==} @@ -2544,6 +2601,10 @@ packages: class-variance-authority@0.7.0: resolution: {integrity: sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + cliui@9.0.1: resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} engines: {node: '>=20'} @@ -2590,6 +2651,10 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2939,9 +3004,18 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-uri@3.1.8: resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fastq@1.20.3: resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} @@ -3168,6 +3242,9 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + headers-polyfill@5.0.1: + resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==} + hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} @@ -3325,6 +3402,9 @@ packages: resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} engines: {node: '>= 0.4'} + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + is-number-object@1.1.1: resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} engines: {node: '>= 0.4'} @@ -3737,6 +3817,26 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msw@3.0.3: + resolution: {integrity: sha512-hsXUCt2gK12q1IUBY9VmMun+yC60eEpVzegAFN5aGZbXhrGqtgPARVWn2ew4mezYvk1G8yhFha9myGSyRyRGWg==} + engines: {node: '>=22.12.0'} + hasBin: true + peerDependencies: + graphql: '>=16' + typescript: '>=5.9.x' + vite: '>=6' + peerDependenciesMeta: + graphql: + optional: true + typescript: + optional: true + vite: + optional: true + + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -3823,6 +3923,9 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + own-keys@1.0.2: resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} engines: {node: '>= 0.4'} @@ -4075,6 +4178,9 @@ packages: engines: {node: '>= 0.4'} hasBin: true + rettime@0.11.12: + resolution: {integrity: sha512-5ExtR1ppiDBQkVoPw5NAqdbfH4Iq2VOmQKHMtUoaFxA9bo2krzFYBtyq6K40xxbYp+2w/YOr6D+1ToF6tTN53A==} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -4157,6 +4263,9 @@ packages: resolution: {integrity: sha512-UeEzHwAK+yl0YLbsvROV1t5yMYEwZZ0CfKYxDvUgS2fO9Vxdf8djqVIB+OuVpuawRNPqw68LE1Du/GMsW2ZPqA==} engines: {node: '>=10'} + set-cookie-parser@3.2.1: + resolution: {integrity: sha512-Npd1Pt+4zb3BrSZDPd2qFwRpS6b1EUEasRrO8xS0zMJi0fApXXK5YuzP0minyJnxwnFHoVoJwA55q0A0cu63gQ==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -4205,6 +4314,10 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + smol-toml@1.9.1: resolution: {integrity: sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==} engines: {node: '>= 18'} @@ -4333,6 +4446,10 @@ packages: resolution: {integrity: sha512-K8uXsBZU6ME97Ia3xbBge8IRCnR1lOmIILzvY/jGVic7dSTQ530s3uO8RvXbPUtkkXLWIwmZLRPbtDxRWVAFdg==} engines: {node: '>= 4.7.0'} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tailwindcss@3.4.19: resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} engines: {node: '>=14.0.0'} @@ -4410,6 +4527,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@5.10.0: + resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==} + engines: {node: '>=20'} + type@2.7.3: resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==} @@ -4508,6 +4629,9 @@ packages: webpack: optional: true + until-async@3.0.2: + resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + update-browserslist-db@1.3.4: resolution: {integrity: sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==} hasBin: true @@ -5379,6 +5503,33 @@ snapshots: '@img/sharp-win32-x64@0.35.5': optional: true + '@inquirer/ansi@2.0.10': {} + + '@inquirer/confirm@6.3.5(@types/node@26.6.5)': + dependencies: + '@inquirer/core': 12.0.6(@types/node@26.6.5) + '@inquirer/type': 4.2.1(@types/node@26.6.5) + optionalDependencies: + '@types/node': 26.6.5 + + '@inquirer/core@12.0.6(@types/node@26.6.5)': + dependencies: + '@inquirer/ansi': 2.0.10 + '@inquirer/figures': 2.0.10 + '@inquirer/type': 4.2.1(@types/node@26.6.5) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 26.6.5 + + '@inquirer/figures@2.0.10': {} + + '@inquirer/type@4.2.1(@types/node@26.6.5)': + optionalDependencies: + '@types/node': 26.6.5 + '@jitsu/js@1.11.0(@jitsu/protocols@1.11.0)(@types/dlv@1.1.5)': dependencies: '@jitsu/protocols': 1.11.0 @@ -5429,6 +5580,19 @@ snapshots: dependencies: '@lit-labs/ssr-dom-shim': 1.6.0 + '@msw/url@0.1.2': {} + + '@mswjs/interceptors@0.45.10': + dependencies: + '@open-draft/until': 3.0.1 + '@types/debug': 4.1.13 + debug: 4.4.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + rettime: 0.11.12 + transitivePeerDependencies: + - supports-color + '@napi-rs/wasm-runtime@1.2.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': dependencies: '@emnapi/core': 1.11.2 @@ -5465,6 +5629,8 @@ snapshots: '@oozcitak/util@10.0.0': {} + '@open-draft/until@3.0.1': {} + '@opentelemetry/api-logs@0.220.0': dependencies: '@opentelemetry/api': 1.9.1 @@ -6489,6 +6655,10 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} '@types/dlv@1.1.5': {} @@ -6501,6 +6671,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/ms@2.1.0': {} + '@types/node@20.19.43': dependencies: undici-types: 6.21.0 @@ -6517,6 +6689,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/set-cookie-parser@2.4.10': + dependencies: + '@types/node': 26.6.5 + '@types/supercluster@7.1.3': dependencies: '@types/geojson': 7946.0.16 @@ -6636,7 +6812,7 @@ snapshots: obug: 2.2.1 std-env: 4.3.0 tinyrainbow: 3.2.0 - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/coverage-v8@4.1.11)(jsdom@30.1.2)(vite@8.2.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@1.21.7)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/coverage-v8@4.1.11)(jsdom@30.1.2)(msw@3.0.3(@types/node@26.6.5)(typescript@6.0.3)(vite@8.2.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@1.21.7)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(vite@8.2.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@1.21.7)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)) '@vitest/expect@4.1.11': dependencies: @@ -6647,12 +6823,13 @@ snapshots: chai: 6.3.0 tinyrainbow: 3.2.0 - '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@1.21.7)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))': + '@vitest/mocker@4.1.11(msw@3.0.3(@types/node@26.6.5)(typescript@6.0.3)(vite@8.2.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@1.21.7)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(vite@8.2.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@1.21.7)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: + msw: 3.0.3(@types/node@26.6.5)(typescript@6.0.3)(vite@8.2.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@1.21.7)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)) vite: 8.2.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@1.21.7)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1) '@vitest/pretty-format@4.1.11': @@ -7044,6 +7221,8 @@ snapshots: dependencies: clsx: 2.0.0 + cli-width@4.1.0: {} + cliui@9.0.1: dependencies: string-width: 7.2.0 @@ -7078,6 +7257,8 @@ snapshots: cookie@1.1.1: {} + cookie@2.0.1: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -7588,8 +7769,18 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + fast-uri@3.1.8: {} + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + fastq@1.20.3: dependencies: reusify: 1.1.0 @@ -7794,6 +7985,11 @@ snapshots: dependencies: function-bind: 1.1.2 + headers-polyfill@5.0.1: + dependencies: + '@types/set-cookie-parser': 2.4.10 + set-cookie-parser: 3.2.1 + hermes-estree@0.25.1: {} hermes-parser@0.25.1: @@ -7955,6 +8151,8 @@ snapshots: is-negative-zero@2.0.3: {} + is-node-process@1.2.0: {} + is-number-object@1.1.1: dependencies: call-bound: 1.0.4 @@ -8305,6 +8503,29 @@ snapshots: ms@2.1.3: {} + msw@3.0.3(@types/node@26.6.5)(typescript@6.0.3)(vite@8.2.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@1.21.7)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)): + dependencies: + '@inquirer/confirm': 6.3.5(@types/node@26.6.5) + '@msw/url': 0.1.2 + '@mswjs/interceptors': 0.45.10 + cookie: 2.0.1 + headers-polyfill: 5.0.1 + is-node-process: 1.2.0 + outvariant: 1.4.3 + rettime: 0.11.12 + tough-cookie: 6.0.2 + type-fest: 5.10.0 + until-async: 3.0.2 + yargs: 18.2.0 + optionalDependencies: + typescript: 6.0.3 + vite: 8.2.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@1.21.7)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1) + transitivePeerDependencies: + - '@types/node' + - supports-color + + mute-stream@3.0.0: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -8395,6 +8616,8 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + outvariant@1.4.3: {} + own-keys@1.0.2: dependencies: call-bound: 1.0.4 @@ -8674,6 +8897,8 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + rettime@0.11.12: {} + reusify@1.1.0: {} rolldown@1.2.13: @@ -8766,6 +8991,8 @@ snapshots: seroval@1.6.9: {} + set-cookie-parser@3.2.1: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -8857,6 +9084,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@4.1.0: {} + smol-toml@1.9.1: {} sonner@2.0.8(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): @@ -8998,6 +9227,8 @@ snapshots: swiper@12.2.0: {} + tagged-tag@1.0.0: {} + tailwindcss@3.4.19(yaml@2.9.1): dependencies: '@alloc/quick-lru': 5.3.0 @@ -9092,6 +9323,10 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@5.10.0: + dependencies: + tagged-tag: 1.0.0 + type@2.7.3: {} typed-array-buffer@1.0.3: @@ -9177,6 +9412,8 @@ snapshots: vite: 8.2.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@1.21.7)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1) webpack: 5.111.1(esbuild@0.28.2)(postcss@8.5.26) + until-async@3.0.2: {} + update-browserslist-db@1.3.4(browserslist@4.29.3): dependencies: browserslist: 4.29.3 @@ -9223,10 +9460,10 @@ snapshots: optionalDependencies: vite: 8.2.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@1.21.7)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1) - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/coverage-v8@4.1.11)(jsdom@30.1.2)(vite@8.2.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@1.21.7)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)): + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/coverage-v8@4.1.11)(jsdom@30.1.2)(msw@3.0.3(@types/node@26.6.5)(typescript@6.0.3)(vite@8.2.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@1.21.7)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(vite@8.2.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@1.21.7)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@1.21.7)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)) + '@vitest/mocker': 4.1.11(msw@3.0.3(@types/node@26.6.5)(typescript@6.0.3)(vite@8.2.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@1.21.7)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)))(vite@8.2.2(@types/node@26.6.5)(esbuild@0.28.2)(jiti@1.21.7)(sass@1.105.1)(terser@5.51.2)(yaml@2.9.1)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 diff --git a/tests/components/home/ProductsSlider.test.tsx b/tests/components/home/ProductsSlider.test.tsx index 7519787..23dbe73 100644 --- a/tests/components/home/ProductsSlider.test.tsx +++ b/tests/components/home/ProductsSlider.test.tsx @@ -1,9 +1,23 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, fireEvent, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; import { ProductsSlider } from '../../../app/components/home/ProductsSlider'; - -const listMock = vi.fn(); +import { STORE_API } from '../../mocks/handlers'; +import { createProduct } from '../../mocks/factories'; +import { installMockServer, server, useScenario } from '../../mocks/server'; + +installMockServer(); + +// Every `GET /products` the block makes, so a test can assert what it asked the API for. +let requests: URL[] = []; +const respondWith = (resolver: Parameters[1]) => + server.use( + http.get(`${STORE_API}/products`, (info) => { + requests.push(new URL(info.request.url)); + return resolver(info); + }) + ); // Only the module under test, not the whole barrel and everything it re-exports. vi.mock('../../../app/components/common', async () => ({ @@ -18,18 +32,6 @@ vi.mock('@salla.sa/twilight-theme-engine/i18n', () => ({ useTranslation: () => ({ t: (_k: string, fb?: string) => fb ?? _k }), })); -vi.mock('@salla.sa/twilight-theme-engine/api/product', () => ({ - product: { - list: (...args: unknown[]) => listMock(...args), - queries: { - list: (params: { source: string; sourceValue?: unknown }) => ({ - queryKey: ['products', 'list', params], - queryFn: () => listMock(params), - }), - }, - }, -})); - vi.mock('@salla.sa/twilight-theme-engine/common', () => ({ Link: ({ to, children }: { to: string; children?: unknown }) => ( {children as never} @@ -54,14 +56,14 @@ function renderWithClient(ui: React.ReactElement) { } beforeEach(() => { - listMock.mockReset(); - listMock.mockResolvedValue({ - items: [ - { id: 1, name: 'P1' }, - { id: 2, name: 'P2' }, - { id: 3, name: 'P3' }, - ], - }); + requests = []; + useScenario('default'); + respondWith(() => + HttpResponse.json({ + data: [1, 2, 3].map((id) => createProduct({ id, name: `P${id}` })), + cursor: { current: 1, next: null, previous: null }, + }) + ); Element.prototype.scrollBy = vi.fn(); }); @@ -75,28 +77,30 @@ describe('ProductsSlider', () => { const { getAllByTestId } = renderWithClient( ); - await waitFor(() => - expect(listMock).toHaveBeenCalledWith( - expect.objectContaining({ source: 'related', sourceValue: 99 }) - ) - ); + await waitFor(() => expect(requests).toHaveLength(1)); + expect(requests[0].searchParams.get('source')).toBe('related'); + expect(requests[0].searchParams.getAll('source_value[]')).toEqual(['99']); await waitFor(() => expect(getAllByTestId('product-card')).toHaveLength(3)); expect(getAllByTestId('product-card')[0].textContent).toBe('P1'); }); it('asks for the block limit and never shows more, even when the API sends more', async () => { - listMock.mockResolvedValue({ - items: Array.from({ length: 12 }, (_, index) => ({ id: index + 1, name: `P${index + 1}` })), - }); + respondWith(() => + HttpResponse.json({ + data: Array.from({ length: 12 }, (_, index) => + createProduct({ id: index + 1, name: `P${index + 1}` }) + ), + }) + ); const { getAllByTestId } = renderWithClient( ); await waitFor(() => expect(getAllByTestId('product-card')).toHaveLength(8)); - expect(listMock).toHaveBeenCalledWith(expect.objectContaining({ perPage: 8 })); + expect(requests[0].searchParams.get('per_page')).toBe('8'); }); it('shows the skeleton until the products resolve', () => { - listMock.mockReturnValue(new Promise(() => {})); + respondWith(() => new Promise(() => {})); const { getByTestId } = renderWithClient( ); @@ -104,7 +108,7 @@ describe('ProductsSlider', () => { }); it('removes the whole block (title included) when the source resolves empty', async () => { - listMock.mockResolvedValue({ items: [] }); + respondWith(() => HttpResponse.json({ data: [] })); const { container } = renderWithClient( { }); it('hides the block on a fetch error, without reporting a false onResolve(0)', async () => { - listMock.mockRejectedValue(new Error('network error')); + respondWith(() => HttpResponse.error()); const onResolve = vi.fn(); const { container } = renderWithClient( { + useScenario('emptyStore'); + // ...render; the real engine `product.list()` now gets an empty list +}); +``` + +Override one request with `server.use(http.get(`${STORE_API}/products`, ...))` (see +`tests/components/home/ProductsSlider.test.tsx`). Nothing is global: a test file that +does not call `installMockServer()` is unaffected. + +## Handled endpoints + +`store/settings`, `products` (sources `latest`, `categories`, `brands`, `selected`, `offers`, +`search`; `page`/`cursor`, `per_page`, `sort=price|-price|name|-name`), `products/:id/details`, +`products/tags/:id`, `categories`, `categories/:id`, `brands`, `brands/:id`, `menus/header`, +`menus/footer`, `blog`, `blog/articles`, `blog/articles/:id`, `cart/:id`, `auth/user`. + +## Not confirmed / not covered + +- Testimonials, reviews, orders, wallet, loyalty, notifications, pages and `component/list` + have no handler: the engine's response shapes for them were not checked, so none were invented. +- Real `sort` values and the exact real `cursor.next` format were not confirmed; the mock + supports the four sorts above and uses the next page number as an opaque cursor. +- `store/settings` returns only `store`, `currencies`, `languages`, `user`; `createStore` + fills the fields the theme reads, not every required field of the engine's `Store`. +- Browser worker: run `npx msw init public/ --save` when wiring Storybook or Playwright. diff --git a/tests/mocks/browser.ts b/tests/mocks/browser.ts new file mode 100644 index 0000000..b50077c --- /dev/null +++ b/tests/mocks/browser.ts @@ -0,0 +1,11 @@ +import { setupWorker } from 'msw/browser'; +import { createHandlers } from './handlers'; +import { scenarios, type ScenarioName } from './scenarios'; + +/** + * Browser worker for Storybook and Playwright. Not used by vitest. + * The worker script is generated into the app's public dir with `npx msw init public/ --save` + * by whichever task wires Storybook or Playwright; it is not committed here. + */ +export const startMockWorker = (scenario: ScenarioName = 'default') => + setupWorker(...createHandlers(scenarios[scenario]())).start({ onUnhandledRequest: 'bypass' }); diff --git a/tests/mocks/factories.ts b/tests/mocks/factories.ts new file mode 100644 index 0000000..66e43d3 --- /dev/null +++ b/tests/mocks/factories.ts @@ -0,0 +1,245 @@ +import type { + Brand, + Cart, + CartItem, + Category, + CurrencyConfig, + MenuItem, + Product, + Store, + User, +} from '@salla.sa/twilight-theme-engine/types'; +import type { ArticleDetail, ArticleSummary } from '@salla.sa/twilight-theme-engine/routes/blog'; + +type Overrides = Partial; + +let sequence = 0; +/** Next id for a factory call. `resetFactories()` restarts it so a test's ids are stable. */ +export const nextId = () => ++sequence; +export const resetFactories = () => { + sequence = 0; +}; + +export const createProduct = (overrides: Overrides = {}): Product => { + const id = overrides.id ?? nextId(); + const price = overrides.price ?? 100; + return { + id, + name: `Product ${id}`, + description: `Description of product ${id}`, + url: `https://store.test/p${id}`, + type: 'product', + status: 'sale', + price, + sale_price: null, + regular_price: typeof price === 'number' ? price : 100, + base_currency_price: { currency: 'SAR', amount: typeof price === 'number' ? price : 100 }, + currency: 'SAR', + max_quantity: 10, + rating: { count: 0, stars: 0 }, + image: { url: `https://cdn.test/products/${id}.webp`, alt: `Product ${id}` }, + images: [{ url: `https://cdn.test/products/${id}.webp`, alt: `Product ${id}`, main: true }], + is_taxable: true, + has_read_more: false, + can_add_note: false, + can_show_remained_quantity: false, + can_upload_file: false, + has_custom_form: false, + has_metadata: false, + is_on_sale: false, + is_available: true, + is_out_of_stock: false, + is_require_shipping: true, + has_size_guide: false, + ...overrides, + }; +}; + +export const createSoldOutProduct = (overrides: Overrides = {}): Product => + createProduct({ + status: 'out', + is_available: false, + is_out_of_stock: true, + quantity: 0, + ...overrides, + }); + +export const createOnSaleProduct = (overrides: Overrides = {}): Product => { + const regular = overrides.regular_price ?? 200; + const sale = overrides.sale_price ?? 150; + return createProduct({ + regular_price: regular, + sale_price: sale, + price: sale, + is_on_sale: true, + ...overrides, + }); +}; + +/** A product with one `radio` option of two values (the engine's `ProductOption` shape). */ +export const createProductWithOptions = (overrides: Overrides = {}): Product => { + const id = overrides.id ?? nextId(); + return createProduct({ + id, + has_options: true, + options: [ + { + id: id * 10, + name: 'Size', + type: 'radio', + required: true, + values: [ + { id: id * 100 + 1, name: 'S' }, + { id: id * 100 + 2, name: 'M' }, + ], + }, + ], + ...overrides, + }); +}; + +export const createBookingProduct = (overrides: Overrides = {}): Product => + createProduct({ type: 'booking', is_require_shipping: false, ...overrides }); + +/** `digital_files_settings` is an opaque `JsonObject` in the engine's types, so it is left empty. */ +export const createDigitalProduct = (overrides: Overrides = {}): Product => + createProduct({ + type: 'digital', + is_require_shipping: false, + digital_files_settings: {}, + ...overrides, + }); + +export const createCategory = (overrides: Overrides = {}): Category => { + const id = overrides.id ?? nextId(); + return { + id, + name: `Category ${id}`, + url: `https://store.test/c${id}`, + products_count: 0, + sub_categories: [], + ...overrides, + }; +}; + +export const createBrand = (overrides: Overrides = {}): Brand => { + const id = overrides.id ?? nextId(); + return { + id, + name: `Brand ${id}`, + logo: `https://cdn.test/brands/${id}.webp`, + url: `https://store.test/b${id}`, + ...overrides, + }; +}; + +export const createMenuItem = (overrides: Overrides = {}): MenuItem => { + const id = overrides.id ?? nextId(); + return { id, title: `Menu item ${id}`, url: `https://store.test/m${id}`, ...overrides }; +}; + +export const createCartItem = (overrides: Overrides = {}): CartItem => { + const product = createProduct({ id: overrides.product_id }); + const quantity = overrides.quantity ?? 1; + return { + id: `item-${product.id}`, + product_id: product.id, + product_name: product.name, + product_image: product.image.url ?? '', + url: product.url, + quantity, + type: 'product', + price: 100, + product_price: 100, + total: (typeof overrides.price === 'number' ? overrides.price : 100) * quantity, + is_available: true, + is_hidden_quantity: false, + can_add_note: false, + can_upload_file: false, + ...overrides, + }; +}; + +export const createCart = (overrides: Overrides = {}): Cart => { + const items = overrides.items ?? []; + const subTotal = items.reduce( + (sum, item) => sum + (typeof item.total === 'number' ? item.total : 0), + 0 + ); + return { + id: String(overrides.id ?? '1'), + count: items.reduce((sum, item) => sum + item.quantity, 0), + items, + sub_total: subTotal, + total: subTotal, + discount: 0, + tax_amount: 0, + has_shipping: false, + real_shipping_cost: 0, + options: [], + ...overrides, + }; +}; + +export const createArticle = (overrides: Overrides = {}): ArticleDetail => { + const id = String(overrides.id ?? nextId()); + return { + id, + name: `Article ${id}`, + description: `Summary of article ${id}`, + image: `https://cdn.test/blog/${id}.webp`, + url: `https://store.test/blog/a-${id}`, + author: { name: 'Store team', url: 'https://store.test/blog/author/1' }, + tags: [], + ...overrides, + }; +}; + +export const toArticleSummary = (article: ArticleDetail): ArticleSummary => { + const summary: Partial = { ...article }; + delete summary.categories; + delete summary.related; + return summary as ArticleSummary; +}; + +export const createUser = (overrides: Overrides = {}): User => ({ + type: 'user', + id: 1, + first_name: 'Test', + last_name: 'Customer', + email: 'customer@test.dev', + mobile: '500000000', + country_code: '+966', + ...overrides, +}); + +export const createCurrency = (overrides: Overrides = {}): CurrencyConfig => ({ + code: 'SAR', + name: 'Saudi Riyal', + symbol: 'SAR', + amount: 1, + country_code: 'SA', + ...overrides, +}); + +/** + * Only the fields the theme reads are filled; the engine's `Store` has more required ones + * (see `types/index.d.ts`), so this is cast rather than claimed complete. + */ +export const createStore = (overrides: Overrides = {}): Store => + ({ + id: 1, + name: 'Test Store', + username: 'test-store', + description: 'A store for tests', + logo: 'https://cdn.test/logo.webp', + icon: 'https://cdn.test/icon.webp', + url: 'https://store.test', + contacts: {}, + settings: { + auth: { email_allowed: true, mobile_allowed: true, is_email_required: false }, + cart: { apply_coupon_enabled: true }, + product: { total_sold_enabled: 0 }, + }, + ...overrides, + }) as Store; diff --git a/tests/mocks/handlers.test.ts b/tests/mocks/handlers.test.ts new file mode 100644 index 0000000..1a56d71 --- /dev/null +++ b/tests/mocks/handlers.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { product } from '@salla.sa/twilight-theme-engine/api/product'; +import { menu } from '@salla.sa/twilight-theme-engine/api/menu'; +import { category } from '@salla.sa/twilight-theme-engine/api/category'; +import { blog } from '@salla.sa/twilight-theme-engine/api/blog'; +import { cart } from '@salla.sa/twilight-theme-engine/api/cart'; +import { createProduct } from './factories'; +import { defaultScenario } from './scenarios'; +import { installMockServer, useScenario } from './server'; + +installMockServer(); + +describe('mock store API, driven through the engine api client', () => { + it('lists products by source, paging and sort', async () => { + const latest = await product.list({ source: 'latest', perPage: 2 }); + expect(latest.items).toHaveLength(2); + expect(latest.next).toBe('2'); + const page2 = await product.list({ source: 'latest', perPage: 2, cursor: latest.next! }); + expect(page2.items).toHaveLength(1); + expect(page2.next).toBeNull(); + + const sorted = await product.list({ source: 'latest', sort: 'price' }); + const prices = sorted.items.map((p) => Number(p.sale_price ?? p.price)); + expect(prices).toEqual([...prices].sort((a, b) => a - b)); + + const offers = (await product.list({ source: 'offers' })).items; + expect(offers).toHaveLength(1); + expect(offers.every((p) => p.is_on_sale)).toBe(true); + expect((await product.list({ source: 'search', keyword: 'nothing-matches' })).items).toEqual( + [] + ); + }); + + it('filters a category source by source_value', async () => { + const [shirts] = await category.list(); + const result = await product.list({ source: 'categories', sourceValue: [Number(shirts.id)] }); + expect(result.items).toHaveLength(2); + }); + + it('serves a product, a cart and the menus', async () => { + const [first] = (await product.list({ source: 'latest' })).items; + expect((await product.find(String(first.id))).id).toBe(first.id); + expect((await cart.get(1))?.count).toBe(1); + expect((await menu.header()).map((m) => m.title)).toEqual(['Home', 'Shop']); + expect(await menu.footer()).toHaveLength(1); + }); + + it('answers 404 for an unknown product', async () => { + await expect(product.find('999999')).rejects.toThrow(); + }); + + it('switches scenarios', async () => { + useScenario('emptyStore'); + expect((await product.list({ source: 'latest' })).items).toEqual([]); + useScenario('soldOutProduct'); + expect((await product.list({ source: 'latest' })).items[0].status).toBe('out'); + useScenario('storeWithBlog'); + expect((await blog.get()).articles).toHaveLength(2); + const [article] = (await blog.articles.list()).data; + expect((await blog.articles.find(article.id)).name).toBe('First post'); + }); + + it('filters a tags source by source_value', async () => { + const data = defaultScenario(); + const tagged = createProduct({ + tags: [{ id: 5, name: 'Summer', url: 'https://store.test/t5' }], + }); + useScenario({ ...data, products: [...data.products, tagged] }); + const result = await product.list({ source: 'tags', sourceValue: [5] }); + expect(result.items.map((p) => p.id)).toEqual([tagged.id]); + expect((await product.list({ source: 'tags', sourceValue: [6] })).items).toEqual([]); + }); +}); diff --git a/tests/mocks/handlers.ts b/tests/mocks/handlers.ts new file mode 100644 index 0000000..36e866e --- /dev/null +++ b/tests/mocks/handlers.ts @@ -0,0 +1,125 @@ +import { http, HttpResponse, type HttpHandler } from 'msw'; +import type { Product } from '@salla.sa/twilight-theme-engine/types'; +import { toArticleSummary } from './factories'; +import { defaultScenario, type MockData } from './scenarios'; + +/** The engine's default (`STORE_API_BASE` in its api client). */ +export const STORE_API = 'https://api.salla.dev/store/v1'; + +const DEFAULT_PER_PAGE = 16; + +const pageOf = (items: T[], url: URL) => { + const perPage = Number(url.searchParams.get('per_page')) || DEFAULT_PER_PAGE; + // The engine hands `cursor.next` back as `cursor=`; here that opaque token is just the next page number. + const page = Number(url.searchParams.get('cursor') ?? url.searchParams.get('page')) || 1; + const slice = items.slice((page - 1) * perPage, page * perPage); + const hasNext = page * perPage < items.length; + return { + data: slice, + cursor: { + current: page, + previous: page > 1 ? String(page - 1) : null, + next: hasNext ? String(page + 1) : null, + }, + }; +}; + +const price = (product: Product) => Number(product.sale_price ?? product.price); + +const SORTERS: Record number> = { + price: (a, b) => price(a) - price(b), + '-price': (a, b) => price(b) - price(a), + name: (a, b) => a.name.localeCompare(b.name), + '-name': (a, b) => b.name.localeCompare(a.name), +}; + +const filterProducts = (products: Product[], url: URL): Product[] => { + const source = url.searchParams.get('source'); + const ids = url.searchParams.getAll('source_value[]').map(Number); + let result = products; + if (source === 'categories') + result = products.filter((p) => ids.includes(Number(p.category?.id))); + else if (source === 'brands') result = products.filter((p) => ids.includes(Number(p.brand?.id))); + else if (source === 'tags') + result = products.filter((p) => p.tags?.some((tag) => ids.includes(Number(tag.id)))); + else if (source === 'selected') result = products.filter((p) => ids.includes(p.id)); + else if (source === 'offers') result = products.filter((p) => p.is_on_sale); + else if (source === 'search') { + const keyword = (url.searchParams.get('keyword') ?? '').toLowerCase(); + result = products.filter((p) => p.name.toLowerCase().includes(keyword)); + } + const sorter = SORTERS[url.searchParams.get('sort') ?? '']; + return sorter ? [...result].sort(sorter) : result; +}; + +/** + * Handlers for the store API paths the engine's api modules call (`api/*.js` in + * `@salla.sa/twilight-theme-engine`). `data` is read on every request, so a test can mutate it. + */ +export const createHandlers = (data: MockData = defaultScenario()): HttpHandler[] => [ + http.get(`${STORE_API}/store/settings`, () => + HttpResponse.json({ + data: { store: data.store, currencies: data.currencies, languages: {}, user: data.user }, + }) + ), + http.get(`${STORE_API}/products`, ({ request }) => { + const url = new URL(request.url); + return HttpResponse.json(pageOf(filterProducts(data.products, url), url)); + }), + http.get(`${STORE_API}/products/tags/:id`, ({ params }) => + HttpResponse.json({ tag: { id: Number(params.id), name: `Tag ${params.id}` } }) + ), + http.get(`${STORE_API}/products/:id/details`, ({ params }) => { + const found = data.products.find((p) => String(p.id) === params.id); + return found + ? HttpResponse.json({ data: found }) + : HttpResponse.json({ error: { message: 'Not found' } }, { status: 404 }); + }), + http.get(`${STORE_API}/categories`, () => HttpResponse.json({ data: data.categories })), + http.get(`${STORE_API}/categories/:id`, ({ params }) => { + const found = data.categories.find((c) => String(c.id) === params.id); + return found + ? HttpResponse.json({ data: found }) + : HttpResponse.json({ error: { message: 'Not found' } }, { status: 404 }); + }), + http.get(`${STORE_API}/brands`, ({ request }) => { + const url = new URL(request.url); + return HttpResponse.json(pageOf(data.brands, url)); + }), + http.get(`${STORE_API}/brands/:id`, ({ params }) => { + const found = data.brands.find((b) => String(b.id) === params.id); + return found + ? HttpResponse.json({ data: found }) + : HttpResponse.json({ error: { message: 'Not found' } }, { status: 404 }); + }), + http.get(`${STORE_API}/menus/header`, () => HttpResponse.json({ data: data.headerMenu })), + http.get(`${STORE_API}/menus/footer`, () => HttpResponse.json({ data: data.footerMenu })), + http.get(`${STORE_API}/blog`, () => { + const summaries = data.articles.map(toArticleSummary); + return HttpResponse.json({ + data: { + categories: [], + slides: summaries.slice(0, 3), + articles: summaries, + popular: summaries.slice(0, 3), + }, + }); + }), + http.get(`${STORE_API}/blog/articles`, ({ request }) => { + const url = new URL(request.url); + return HttpResponse.json({ + ...pageOf(data.articles.map(toArticleSummary), url), + categories: [], + }); + }), + http.get(`${STORE_API}/blog/articles/:id`, ({ params }) => { + const found = data.articles.find((a) => String(a.id) === params.id); + return found + ? HttpResponse.json({ data: found }) + : HttpResponse.json({ error: { message: 'Not found' } }, { status: 404 }); + }), + http.get(`${STORE_API}/cart/:id`, () => HttpResponse.json({ data: { cart: data.cart } })), + http.get(`${STORE_API}/auth/user`, () => HttpResponse.json({ data: data.user })), +]; + +export const handlers = createHandlers(); diff --git a/tests/mocks/index.ts b/tests/mocks/index.ts new file mode 100644 index 0000000..24e51b4 --- /dev/null +++ b/tests/mocks/index.ts @@ -0,0 +1,3 @@ +export * from './factories'; +export * from './handlers'; +export * from './scenarios'; diff --git a/tests/mocks/scenarios.ts b/tests/mocks/scenarios.ts new file mode 100644 index 0000000..1bfe5d7 --- /dev/null +++ b/tests/mocks/scenarios.ts @@ -0,0 +1,115 @@ +import type { + Brand, + Cart, + Category, + CurrencyConfig, + MenuItem, + Product, + Store, + User, +} from '@salla.sa/twilight-theme-engine/types'; +import type { ArticleDetail } from '@salla.sa/twilight-theme-engine/routes/blog'; +import { + createArticle, + createBrand, + createCart, + createCartItem, + createCategory, + createCurrency, + createMenuItem, + createOnSaleProduct, + createProduct, + createSoldOutProduct, + createStore, + createUser, + resetFactories, +} from './factories'; + +/** Everything the handlers serve. A scenario is one of these; tests override single keys. */ +export interface MockData { + store: Store; + currencies: Record; + products: Product[]; + categories: Category[]; + brands: Brand[]; + headerMenu: MenuItem[]; + footerMenu: MenuItem[]; + articles: ArticleDetail[]; + cart: Cart; + user: User; +} + +export const defaultScenario = (): MockData => { + resetFactories(); + const categories = [createCategory({ name: 'Shirts' }), createCategory({ name: 'Shoes' })]; + const products = [ + createProduct({ + category: { id: Number(categories[0].id), name: 'Shirts', url: categories[0].url }, + }), + createProduct({ + category: { id: Number(categories[0].id), name: 'Shirts', url: categories[0].url }, + price: 80, + }), + createOnSaleProduct({ + category: { id: Number(categories[1].id), name: 'Shoes', url: categories[1].url }, + }), + ]; + return { + store: createStore(), + currencies: { SAR: createCurrency() }, + products, + categories, + brands: [createBrand(), createBrand()], + headerMenu: [createMenuItem({ title: 'Home' }), createMenuItem({ title: 'Shop' })], + footerMenu: [createMenuItem({ title: 'Privacy' })], + articles: [], + cart: createCart({ items: [createCartItem({ product_id: products[0].id })] }), + user: createUser(), + }; +}; + +export const emptyStoreScenario = (): MockData => ({ + ...defaultScenario(), + products: [], + categories: [], + brands: [], + headerMenu: [], + footerMenu: [], + cart: createCart(), +}); + +export const soldOutProductScenario = (): MockData => { + const data = defaultScenario(); + return { + ...data, + products: [createSoldOutProduct({ name: 'Sold out product' }), ...data.products], + }; +}; + +export const storeWithBlogScenario = (): MockData => ({ + ...defaultScenario(), + articles: [createArticle({ name: 'First post' }), createArticle({ name: 'Second post' })], +}); + +export const multiCurrencyScenario = (): MockData => ({ + ...defaultScenario(), + currencies: { + SAR: createCurrency(), + USD: createCurrency({ + code: 'USD', + name: 'US Dollar', + symbol: '$', + amount: 0.27, + country_code: 'US', + }), + }, +}); + +export const scenarios = { + default: defaultScenario, + emptyStore: emptyStoreScenario, + soldOutProduct: soldOutProductScenario, + storeWithBlog: storeWithBlogScenario, + multiCurrency: multiCurrencyScenario, +}; +export type ScenarioName = keyof typeof scenarios; diff --git a/tests/mocks/server.ts b/tests/mocks/server.ts new file mode 100644 index 0000000..605af6b --- /dev/null +++ b/tests/mocks/server.ts @@ -0,0 +1,29 @@ +import { setupServer } from 'msw/node'; +import { createHandlers } from './handlers'; +import { defaultScenario, scenarios, type MockData, type ScenarioName } from './scenarios'; + +/** Node (vitest, jsdom) server. Opt in per test file: `installMockServer()` in the file's top level. */ +export const server = setupServer(...createHandlers()); + +/** Swap in a scenario (or your own data) for the rest of the current test. */ +export const useScenario = (scenario: ScenarioName | MockData = 'default') => { + const data = typeof scenario === 'string' ? scenarios[scenario]() : scenario; + server.resetHandlers(...createHandlers(data)); + return data; +}; + +/** + * Start the server before the file's tests and stop it after; reset per test. + * `onUnhandledRequest: 'error'` fails a test that asks for a path no handler knows. + */ +export const installMockServer = ( + hooks: { beforeAll: typeof beforeAll; afterEach: typeof afterEach; afterAll: typeof afterAll } = { + beforeAll, + afterEach, + afterAll, + } +) => { + hooks.beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); + hooks.afterEach(() => server.resetHandlers(...createHandlers(defaultScenario()))); + hooks.afterAll(() => server.close()); +};