From d65b3f81dbe59535648c8881b79a1cfd12196945 Mon Sep 17 00:00:00 2001 From: Alanwiz00 Date: Thu, 16 Jul 2026 13:02:39 +0100 Subject: [PATCH] fix(marketplace): guard realtime bid updates against stale and duplicate deliveries --- app/frontend/package-lock.json | 523 ++++++++++++++++++ app/frontend/package.json | 5 +- .../src/__tests__/bidUpdates.test.tsx | 118 ++++ .../src/__tests__/useRealtimeUpdates.test.tsx | 100 ++++ app/frontend/src/app/generator/page.tsx | 4 +- app/frontend/src/app/layout.tsx | 2 +- app/frontend/src/app/marketplace/page.tsx | 92 +-- .../src/app/pay/PaymentPageClient.tsx | 4 +- app/frontend/src/hooks/useRealtimeUpdates.ts | 45 +- app/frontend/src/lib/bidUpdates.ts | 55 ++ app/frontend/src/lib/errorReporter.ts | 5 +- app/frontend/vitest.config.ts | 15 + app/frontend/vitest.setup.ts | 1 + 13 files changed, 911 insertions(+), 58 deletions(-) create mode 100644 app/frontend/src/__tests__/bidUpdates.test.tsx create mode 100644 app/frontend/src/__tests__/useRealtimeUpdates.test.tsx create mode 100644 app/frontend/src/lib/bidUpdates.ts create mode 100644 app/frontend/vitest.config.ts create mode 100644 app/frontend/vitest.setup.ts diff --git a/app/frontend/package-lock.json b/app/frontend/package-lock.json index 7447602c2..1898d9d85 100644 --- a/app/frontend/package-lock.json +++ b/app/frontend/package-lock.json @@ -27,6 +27,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "15.5.9", + "jsdom": "^29.1.1", "tailwindcss": "^4", "typescript": "^5", "vitest": "^3.2.3" @@ -52,6 +53,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -88,6 +140,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", @@ -707,6 +912,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -3430,6 +3653,16 @@ "dev": true, "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -3645,6 +3878,20 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -3787,6 +4034,20 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -3859,6 +4120,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -3996,6 +4264,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.24.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", @@ -5130,6 +5411,19 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -5505,6 +5799,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -5711,6 +6012,47 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -6108,6 +6450,16 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/lucide-react": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.11.0.tgz", @@ -6148,6 +6500,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -6536,6 +6895,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -6927,6 +7299,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/reselect": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", @@ -7109,6 +7491,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.26.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", @@ -7590,6 +7985,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", @@ -7709,6 +8111,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7722,6 +8144,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -7878,6 +8326,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -8185,6 +8643,54 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8317,6 +8823,23 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/app/frontend/package.json b/app/frontend/package.json index 2ec7440aa..1655f36d3 100644 --- a/app/frontend/package.json +++ b/app/frontend/package.json @@ -7,7 +7,9 @@ "build": "next build", "start": "next start", "lint": "eslint", - "test": "echo 'Skipping frontend checks in backend release pipeline' || exit 0" + "test": "echo 'Skipping frontend checks in backend release pipeline' || exit 0", + "test:unit": "vitest run", + "type-check": "tsc --noEmit -p tsconfig.test.json" }, "dependencies": { "i18next": "^26.0.1", @@ -29,6 +31,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "15.5.9", + "jsdom": "^29.1.1", "tailwindcss": "^4", "typescript": "^5", "vitest": "^3.2.3" diff --git a/app/frontend/src/__tests__/bidUpdates.test.tsx b/app/frontend/src/__tests__/bidUpdates.test.tsx new file mode 100644 index 000000000..21e4fb3f9 --- /dev/null +++ b/app/frontend/src/__tests__/bidUpdates.test.tsx @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import { applyBidUpdate, applyLocalBid } from "@/lib/bidUpdates"; +import type { MarketplaceListing } from "@/hooks/marketplaceApi"; + +function makeListing( + overrides: Partial = {}, +): MarketplaceListing { + return { + id: "1", + username: "nova", + currentBid: 1000, + buyNowPrice: null, + ownerAddress: "GBXT...2R7K", + endsAt: new Date(Date.now() + 3600_000), + createdAt: new Date(Date.now() - 3600_000), + status: "auction", + category: "brand", + bidCount: 10, + watchers: 5, + verified: false, + ...overrides, + }; +} + +describe("applyBidUpdate", () => { + it("applies an update with a higher bid and increments bidCount once", () => { + const listings = [makeListing(), makeListing({ id: "2", username: "lux" })]; + const next = applyBidUpdate(listings, { listingId: "1", newBid: 1200 }); + + expect(next).not.toBe(listings); + expect(next[0].currentBid).toBe(1200); + expect(next[0].bidCount).toBe(11); + // Other listings untouched + expect(next[1]).toBe(listings[1]); + // Input not mutated + expect(listings[0].currentBid).toBe(1000); + }); + + it("discards a duplicate delivery (same bid) without touching bidCount", () => { + const listings = [makeListing()]; + const next = applyBidUpdate(listings, { listingId: "1", newBid: 1000 }); + + expect(next).toBe(listings); + expect(next[0].bidCount).toBe(10); + }); + + it("discards a stale out-of-order delivery (lower bid)", () => { + const listings = [makeListing()]; + const next = applyBidUpdate(listings, { listingId: "1", newBid: 700 }); + + expect(next).toBe(listings); + }); + + it("ignores updates for unknown listings", () => { + const listings = [makeListing()]; + const next = applyBidUpdate(listings, { listingId: "999", newBid: 9999 }); + + expect(next).toBe(listings); + }); + + it("prefers the server's authoritative bidCount when provided", () => { + const listings = [makeListing()]; + const next = applyBidUpdate(listings, { + listingId: "1", + newBid: 1500, + bidCount: 42, + }); + + expect(next[0].bidCount).toBe(42); + }); + + it("counts only genuinely new bids across an out-of-order burst", () => { + let listings = [makeListing()]; + // Delivery order: new, duplicate, older, new + listings = applyBidUpdate(listings, { listingId: "1", newBid: 1200 }); + listings = applyBidUpdate(listings, { listingId: "1", newBid: 1200 }); + listings = applyBidUpdate(listings, { listingId: "1", newBid: 1100 }); + listings = applyBidUpdate(listings, { listingId: "1", newBid: 1300 }); + + expect(listings[0].currentBid).toBe(1300); + expect(listings[0].bidCount).toBe(12); // 10 + the 2 real bids + }); +}); + +describe("applyLocalBid", () => { + it("applies the user's own bid by username", () => { + const listings = [makeListing()]; + const next = applyLocalBid(listings, "nova", 1250); + + expect(next[0].currentBid).toBe(1250); + expect(next[0].bidCount).toBe(11); + }); + + it("does not double-count the websocket echo of the user's own bid", () => { + let listings = [makeListing()]; + listings = applyLocalBid(listings, "nova", 1250); + // The feed echoes the same bid back later + listings = applyBidUpdate(listings, { listingId: "1", newBid: 1250 }); + + expect(listings[0].currentBid).toBe(1250); + expect(listings[0].bidCount).toBe(11); + }); + + it("cannot regress the price when the user's bid raced a higher update", () => { + let listings = [makeListing()]; + // A realtime update lands first, then the user's slower bid succeeds + listings = applyBidUpdate(listings, { listingId: "1", newBid: 1400 }); + listings = applyLocalBid(listings, "nova", 1250); + + expect(listings[0].currentBid).toBe(1400); + expect(listings[0].bidCount).toBe(11); + }); + + it("ignores bids for unknown usernames", () => { + const listings = [makeListing()]; + expect(applyLocalBid(listings, "ghost", 5000)).toBe(listings); + }); +}); diff --git a/app/frontend/src/__tests__/useRealtimeUpdates.test.tsx b/app/frontend/src/__tests__/useRealtimeUpdates.test.tsx new file mode 100644 index 000000000..67318bd16 --- /dev/null +++ b/app/frontend/src/__tests__/useRealtimeUpdates.test.tsx @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MockWebSocket, type BidUpdate } from "@/hooks/useRealtimeUpdates"; +import { applyBidUpdate } from "@/lib/bidUpdates"; +import type { MarketplaceListing } from "@/hooks/marketplaceApi"; + +const LISTING: MarketplaceListing = { + id: "a", + username: "nova", + currentBid: 1000, + buyNowPrice: null, + ownerAddress: "GBXT...2R7K", + endsAt: new Date(Date.now() + 3600_000), + createdAt: new Date(Date.now() - 3600_000), + status: "auction", + category: "brand", + bidCount: 10, + watchers: 5, + verified: false, +}; + +function collectUpdates(socket: MockWebSocket): BidUpdate[] { + const updates: BidUpdate[] = []; + socket.onBidUpdate((u) => updates.push(u)); + return updates; +} + +describe("MockWebSocket", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("emits monotonically increasing bids seeded from the subscribed baseline", () => { + // Math.random() = 0.25: emit gate passes (< 0.3), stale-replay branch is + // skipped (>= 0.2), increment is a constant floor(0.25*100)+50 = 75. + vi.spyOn(Math, "random").mockReturnValue(0.25); + + const socket = new MockWebSocket(); + const updates = collectUpdates(socket); + socket.connect(); + socket.subscribe("a", LISTING.currentBid); + + vi.advanceTimersByTime(5 * 5000); + socket.disconnect(); + + expect(updates).toHaveLength(5); + expect(updates[0].newBid).toBeGreaterThan(LISTING.currentBid); + for (let i = 1; i < updates.length; i++) { + expect(updates[i].newBid).toBeGreaterThan(updates[i - 1].newBid); + } + }); + + it("replays stale bids, which the applyBidUpdate guard discards", () => { + // Math.random() = 0.1: emit gate passes (< 0.3) and every delivery takes + // the stale-replay branch (< 0.2), re-sending the baseline bid. + vi.spyOn(Math, "random").mockReturnValue(0.1); + + const socket = new MockWebSocket(); + const updates = collectUpdates(socket); + socket.connect(); + socket.subscribe("a", LISTING.currentBid); + + vi.advanceTimersByTime(3 * 5000); + socket.disconnect(); + + expect(updates).toHaveLength(3); + updates.forEach((u) => expect(u.newBid).toBe(LISTING.currentBid)); + + // Feeding the stale replays through the page's guard changes nothing: + // same array reference, bid count not inflated. + let listings = [LISTING]; + for (const update of updates) { + const next = applyBidUpdate(listings, update); + expect(next).toBe(listings); + listings = next; + } + expect(listings[0].bidCount).toBe(10); + }); + + it("stops emitting after unsubscribe", () => { + vi.spyOn(Math, "random").mockReturnValue(0.25); + + const socket = new MockWebSocket(); + const updates = collectUpdates(socket); + socket.connect(); + socket.subscribe("a", LISTING.currentBid); + vi.advanceTimersByTime(5000); + expect(updates).toHaveLength(1); + + socket.unsubscribe("a"); + vi.advanceTimersByTime(3 * 5000); + socket.disconnect(); + + expect(updates).toHaveLength(1); + }); +}); diff --git a/app/frontend/src/app/generator/page.tsx b/app/frontend/src/app/generator/page.tsx index f218128ed..1678049fe 100644 --- a/app/frontend/src/app/generator/page.tsx +++ b/app/frontend/src/app/generator/page.tsx @@ -5,7 +5,7 @@ import Link from "next/link"; import { QRPreview } from "@/components/QRPreview"; import { NetworkBadge } from "@/components/NetworkBadge"; import { useApi } from "@/hooks/useApi"; -import { get RustAcademyApiBase } from "@/lib/api"; +import { getRustAcademyApiBase } from "@/lib/api"; import { buildGeneratedLinksCsv, BulkCsvDraftRow, @@ -117,7 +117,7 @@ type BulkLinkRequestItem = { export default function Generator() { const { t } = useTranslation(); - const apiBase = useMemo(() => get RustAcademyApiBase(), []); + const apiBase = useMemo(() => getRustAcademyApiBase(), []); const { error, loading, callApi, data } = useApi(); const csvInputRef = useRef(null); diff --git a/app/frontend/src/app/layout.tsx b/app/frontend/src/app/layout.tsx index dea5905bc..e15da923b 100644 --- a/app/frontend/src/app/layout.tsx +++ b/app/frontend/src/app/layout.tsx @@ -6,7 +6,7 @@ import "./globals.css"; const siteUrl = process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") || - "https:// RustAcademy.to"; + "https://RustAcademy.to"; export const metadata: Metadata = { metadataBase: new URL(siteUrl), diff --git a/app/frontend/src/app/marketplace/page.tsx b/app/frontend/src/app/marketplace/page.tsx index b63786193..057f0e648 100644 --- a/app/frontend/src/app/marketplace/page.tsx +++ b/app/frontend/src/app/marketplace/page.tsx @@ -1,10 +1,11 @@ "use client"; import dynamic from "next/dynamic"; -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect, useMemo, useRef } from "react"; import { UsernameCard } from "@/components/UsernameCard"; import { ListingDetailModal } from "@/components/ListingDetailModal"; import { fetchListings, MarketplaceListing } from "@/hooks/marketplaceApi"; +import { applyBidUpdate, applyLocalBid } from "@/lib/bidUpdates"; import { useWatchlist } from "@/contexts/WatchlistContext"; import { useRealtimeUpdates } from "@/hooks/useRealtimeUpdates"; import Link from "next/link"; @@ -80,13 +81,20 @@ function MarketplacePageContent() { const [search, setSearch] = useState(""); const [activeCategory, setActiveCategory] = useState("all"); const [sortKey, setSortKey] = useState("ending"); - const [activeListing, setActiveListing] = useState( - null, + const [activeListingId, setActiveListingId] = useState(null); + const [detailListingId, setDetailListingId] = useState(null); + const [showWatchlistOnly, setShowWatchlistOnly] = useState(false); + + // Modals hold a listing id, not a snapshot, so realtime bid updates keep + // the open modal (current bid, minimum bid) from going stale. + const activeListing = useMemo( + () => listings.find((l) => l.id === activeListingId) ?? null, + [listings, activeListingId], ); - const [detailListing, setDetailListing] = useState( - null, + const detailListing = useMemo( + () => listings.find((l) => l.id === detailListingId) ?? null, + [listings, detailListingId], ); - const [showWatchlistOnly, setShowWatchlistOnly] = useState(false); const { watchlist, isInWatchlist, toggleWatchlist } = useWatchlist(); const { @@ -98,54 +106,60 @@ function MarketplacePageContent() { } = useRealtimeUpdates(); useEffect(() => { + let cancelled = false; fetchListings().then((data) => { + if (cancelled) return; setListings(data); setLoading(false); }); + return () => { + cancelled = true; + }; }, []); - // Subscribe to real-time updates for all listings + // Latest listings, readable from effects without being an effect dependency. + const listingsRef = useRef([]); useEffect(() => { - if (listings.length > 0) { - listings.forEach((listing) => subscribeToListing(listing.id)); - return () => { - listings.forEach((listing) => unsubscribeFromListing(listing.id)); - }; - } - }, [listings, subscribeToListing, unsubscribeFromListing]); + listingsRef.current = listings; + }, [listings]); + + // Subscribe to real-time updates for all listings. Keyed on the id set — + // not the listings array — so every bid update doesn't churn a full + // unsubscribe/resubscribe cycle (a window where updates get dropped). + const listingIdsKey = listings.map((l) => l.id).join(","); + useEffect(() => { + if (!listingIdsKey) return; + const ids = listingIdsKey.split(","); + ids.forEach((id) => + subscribeToListing( + id, + listingsRef.current.find((l) => l.id === id)?.currentBid, + ), + ); + return () => { + ids.forEach((id) => unsubscribeFromListing(id)); + }; + }, [listingIdsKey, subscribeToListing, unsubscribeFromListing]); - // Handle real-time bid updates + // Handle real-time bid updates. applyBidUpdate discards stale, duplicate, + // and out-of-order deliveries so bidCount only moves for genuinely new bids. useEffect(() => { const unsubscribe = onBidUpdate((update) => { - setListings((prev) => - prev.map((listing) => - listing.id === update.listingId - ? { - ...listing, - currentBid: Math.max(listing.currentBid, update.newBid), - bidCount: listing.bidCount + 1, - } - : listing, - ), - ); + setListings((prev) => applyBidUpdate(prev, update)); }); return unsubscribe; }, [onBidUpdate]); function handleBidSuccess(username: string, amount: number) { - setListings((prev) => - prev.map((l) => - l.username === username - ? { ...l, currentBid: amount, bidCount: l.bidCount + 1 } - : l, - ), - ); + // Same guard as realtime updates: the websocket echo of this bid won't + // double-count it, and a concurrent higher bid can't be regressed. + setListings((prev) => applyLocalBid(prev, username, amount)); } function handleOpenBid(listing: MarketplaceListing) { - setDetailListing(null); - setActiveListing(listing); + setDetailListingId(null); + setActiveListingId(listing.id); } const filtered = useMemo(() => { @@ -446,7 +460,7 @@ function MarketplacePageContent() { key={listing.id} listing={listing} onBid={handleOpenBid} - onViewDetails={setDetailListing} + onViewDetails={(l) => setDetailListingId(l.id)} /> ))} @@ -456,7 +470,7 @@ function MarketplacePageContent() { setDetailListing(null)} + onClose={() => setDetailListingId(null)} onToggleWatchlist={(listing) => toggleWatchlist(listing.id, listing.username) } @@ -466,10 +480,10 @@ function MarketplacePageContent() { {/* ── BID MODAL ─────────────────────────────── */} setActiveListing(null)} + onClose={() => setActiveListingId(null)} onBidSuccess={(username, amount) => { handleBidSuccess(username, amount); - setTimeout(() => setActiveListing(null), 2500); + setTimeout(() => setActiveListingId(null), 2500); }} /> diff --git a/app/frontend/src/app/pay/PaymentPageClient.tsx b/app/frontend/src/app/pay/PaymentPageClient.tsx index 169dac332..be0cbdb95 100644 --- a/app/frontend/src/app/pay/PaymentPageClient.tsx +++ b/app/frontend/src/app/pay/PaymentPageClient.tsx @@ -9,7 +9,7 @@ import { PaidPaymentState } from "@/components/payment-states/PaidPaymentState"; import { RefundedPaymentState } from "@/components/payment-states/RefundedPaymentState"; import { LoadingState } from "@/components/payment-states/LoadingState"; import { ErrorState } from "@/components/payment-states/ErrorState"; -import { get RustAcademyApiBase } from "@/lib/api"; +import { getRustAcademyApiBase } from "@/lib/api"; type LinkState = "ACTIVE" | "EXPIRED" | "PAID" | "REFUNDED" | "DRAFT"; @@ -64,7 +64,7 @@ function PaymentPageContent() { setError(null); try { - const apiBase = get RustAcademyApiBase(); + const apiBase = getRustAcademyApiBase(); const params = new URLSearchParams({ username, amount, diff --git a/app/frontend/src/hooks/useRealtimeUpdates.ts b/app/frontend/src/hooks/useRealtimeUpdates.ts index 197f7aba6..f1da582f6 100644 --- a/app/frontend/src/hooks/useRealtimeUpdates.ts +++ b/app/frontend/src/hooks/useRealtimeUpdates.ts @@ -8,20 +8,26 @@ type BidUpdate = { newBid: number; bidderAddress: string; timestamp: Date; + /** Authoritative total bid count, when the backend provides one. */ + bidCount?: number; }; type RealtimeUpdatesHook = { isConnected: boolean; lastUpdate: Date | null; - subscribeToListing: (listingId: string) => void; + subscribeToListing: (listingId: string, currentBid?: number) => void; unsubscribeFromListing: (listingId: string) => void; onBidUpdate: (callback: (update: BidUpdate) => void) => () => void; }; -// Mock WebSocket simulation for real-time bid updates -class MockWebSocket { +// Mock WebSocket simulation for real-time bid updates. +// Deliveries are NOT guaranteed to be fresh: like a real websocket feed, this +// occasionally replays a previous bid (duplicate / out-of-order delivery), so +// consumers must guard before applying updates (see lib/bidUpdates.ts). +export class MockWebSocket { private listeners: ((update: BidUpdate) => void)[] = []; private subscribedListings: Set = new Set(); + private lastBids: Map = new Map(); private intervalId: NodeJS.Timeout | null = null; private isConnected = false; @@ -46,8 +52,14 @@ class MockWebSocket { console.log('🔌 Disconnected from marketplace WebSocket'); } - subscribe(listingId: string) { + subscribe(listingId: string, currentBid?: number) { this.subscribedListings.add(listingId); + if (currentBid !== undefined) { + this.lastBids.set( + listingId, + Math.max(this.lastBids.get(listingId) ?? 0, currentBid), + ); + } } unsubscribe(listingId: string) { @@ -69,10 +81,18 @@ class MockWebSocket { if (subscribedArray.length === 0) return; const randomListingId = subscribedArray[Math.floor(Math.random() * subscribedArray.length)]; - - // Generate a realistic bid increase (5-20% of current bid) - const baseIncrease = Math.floor(Math.random() * 100) + 50; // 50-150 USDC increase - const newBid = Math.floor(Math.random() * 5000) + 1000 + baseIncrease; // Random base + increase + const lastBid = this.lastBids.get(randomListingId) ?? 500; + + // ~20% of deliveries replay the previous bid, simulating the duplicate / + // out-of-order messages a real feed can produce. Consumers must discard + // any update that does not raise the current bid. + const isStaleReplay = this.lastBids.has(randomListingId) && Math.random() < 0.2; + const newBid = isStaleReplay + ? lastBid + : lastBid + Math.floor(Math.random() * 100) + 50; // 50-150 USDC increase + if (!isStaleReplay) { + this.lastBids.set(randomListingId, newBid); + } const update: BidUpdate = { listingId: randomListingId, @@ -106,9 +126,12 @@ export function useRealtimeUpdates(): RealtimeUpdatesHook { }; }, []); - const subscribeToListing = useCallback((listingId: string) => { - mockWebSocket.subscribe(listingId); - }, []); + const subscribeToListing = useCallback( + (listingId: string, currentBid?: number) => { + mockWebSocket.subscribe(listingId, currentBid); + }, + [], + ); const unsubscribeFromListing = useCallback((listingId: string) => { mockWebSocket.unsubscribe(listingId); diff --git a/app/frontend/src/lib/bidUpdates.ts b/app/frontend/src/lib/bidUpdates.ts new file mode 100644 index 000000000..b67748cac --- /dev/null +++ b/app/frontend/src/lib/bidUpdates.ts @@ -0,0 +1,55 @@ +import type { MarketplaceListing } from "@/hooks/marketplaceApi"; + +export type IncomingBidUpdate = { + listingId: string; + newBid: number; + /** Authoritative total bid count, when the backend provides one. */ + bidCount?: number; +}; + +/** + * Applies a realtime bid update to a listing set, guarding against stale, + * duplicate, and out-of-order deliveries. + * + * Auction bids are strictly increasing, so an update only counts as new when + * it raises the listing's current bid. Anything else — a duplicate delivery, + * an out-of-order older bid, or the websocket echo of a bid already applied + * locally — leaves the listing untouched, including its bid count. + * + * Returns the input array (same reference) when nothing applied, so React + * state setters can skip the re-render. + */ +export function applyBidUpdate( + listings: MarketplaceListing[], + update: IncomingBidUpdate, +): MarketplaceListing[] { + const index = listings.findIndex((l) => l.id === update.listingId); + if (index === -1) return listings; + + const listing = listings[index]; + if (update.newBid <= listing.currentBid) return listings; + + const next = [...listings]; + next[index] = { + ...listing, + currentBid: update.newBid, + bidCount: update.bidCount ?? listing.bidCount + 1, + }; + return next; +} + +/** + * Applies the user's own successful bid through the same monotonic guard, so + * a bid that raced with a higher realtime update cannot regress the shown + * price, and the later websocket echo of this bid (same amount) is discarded + * instead of double-counted. + */ +export function applyLocalBid( + listings: MarketplaceListing[], + username: string, + amount: number, +): MarketplaceListing[] { + const listing = listings.find((l) => l.username === username); + if (!listing) return listings; + return applyBidUpdate(listings, { listingId: listing.id, newBid: amount }); +} diff --git a/app/frontend/src/lib/errorReporter.ts b/app/frontend/src/lib/errorReporter.ts index 390d9b318..80a838638 100644 --- a/app/frontend/src/lib/errorReporter.ts +++ b/app/frontend/src/lib/errorReporter.ts @@ -13,10 +13,11 @@ const CARD_RE = /\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/g; export function redactPII(value: unknown): unknown { if (typeof value === "string") { + // Cards before phones: PHONE_RE also matches 16-digit card numbers. return value .replace(EMAIL_RE, "[REDACTED_EMAIL]") - .replace(PHONE_RE, "[REDACTED_PHONE]") - .replace(CARD_RE, "[REDACTED_CARD]"); + .replace(CARD_RE, "[REDACTED_CARD]") + .replace(PHONE_RE, "[REDACTED_PHONE]"); } if (Array.isArray(value)) { diff --git a/app/frontend/vitest.config.ts b/app/frontend/vitest.config.ts new file mode 100644 index 000000000..14ca1f9f0 --- /dev/null +++ b/app/frontend/vitest.config.ts @@ -0,0 +1,15 @@ +import path from "path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + esbuild: { jsx: "automatic" }, + resolve: { + alias: { "@": path.resolve(__dirname, "src") }, + }, + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./vitest.setup.ts"], + include: ["src/**/*.test.{ts,tsx}", "__tests__/**/*.test.{ts,tsx}"], + }, +}); diff --git a/app/frontend/vitest.setup.ts b/app/frontend/vitest.setup.ts new file mode 100644 index 000000000..f149f27ae --- /dev/null +++ b/app/frontend/vitest.setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest";