diff --git a/backend/.github/workflows/performance-tests.yml b/backend/.github/workflows/performance-tests.yml new file mode 100644 index 000000000..bc414590e --- /dev/null +++ b/backend/.github/workflows/performance-tests.yml @@ -0,0 +1,172 @@ +name: Performance Tests + +on: + schedule: + - cron: '0 0 * * *' # Daily at midnight UTC + workflow_dispatch: + pull_request: + paths: + - 'src/**' + - 'performance/**' + +jobs: + performance: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: insightarena_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Install k6 + run: | + sudo gpg -k + sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69 + echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list + sudo apt-get update + sudo apt-get install k6 + + - name: Build application + run: npm run build + + - name: Setup environment + run: | + cp .env.example .env + echo "DATABASE_HOST=localhost" >> .env + echo "DATABASE_PORT=5432" >> .env + echo "DATABASE_USERNAME=test" >> .env + echo "DATABASE_PASSWORD=test" >> .env + echo "DATABASE_NAME=insightarena_test" >> .env + echo "NODE_ENV=test" >> .env + + - name: Run database migrations + run: npm run migration:run + env: + DATABASE_HOST: localhost + DATABASE_PORT: 5432 + DATABASE_USERNAME: test + DATABASE_PASSWORD: test + DATABASE_NAME: insightarena_test + + - name: Seed test data + run: npm run seed + env: + DATABASE_HOST: localhost + DATABASE_PORT: 5432 + DATABASE_USERNAME: test + DATABASE_PASSWORD: test + DATABASE_NAME: insightarena_test + + - name: Start application in background + run: | + npm run start:prod > /tmp/app.log 2>&1 & + echo $! > /tmp/app.pid + + - name: Wait for application to be ready + run: | + for i in {1..30}; do + if curl -f http://localhost:3000/health; then + echo "Application is ready" + break + fi + echo "Waiting for application... ($i/30)" + sleep 2 + done + + - name: Setup performance test environment + run: | + cp performance/.env.example performance/.env + echo "API_BASE_URL=http://localhost:3000" >> performance/.env + echo "ORACLE_API_KEY=test-oracle-api-key" >> performance/.env + echo "WEBHOOK_SECRET=test-webhook-secret" >> performance/.env + + - name: Run API load test + run: k6 run performance/api-load-test.js + env: + API_BASE_URL: http://localhost:3000 + continue-on-error: true + + - name: Run Oracle load test + run: k6 run performance/oracle-load-test.js + env: + API_BASE_URL: http://localhost:3000 + continue-on-error: true + + - name: Run Webhook load test + run: k6 run performance/webhook-load-test.js + env: + API_BASE_URL: http://localhost:3000 + WEBHOOK_SECRET: test-webhook-secret + continue-on-error: true + + - name: Run Database performance test + run: k6 run performance/database-performance-test.js + env: + API_BASE_URL: http://localhost:3000 + continue-on-error: true + + - name: Run Cache effectiveness test + run: k6 run performance/cache-effectiveness-test.js + env: + API_BASE_URL: http://localhost:3000 + continue-on-error: true + + - name: Upload application logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: application-logs + path: /tmp/app.log + + - name: Stop application + if: always() + run: | + if [ -f /tmp/app.pid ]; then + kill $(cat /tmp/app.pid) || true + fi + + - name: Comment PR with results + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const comment = `## Performance Test Results + + Performance tests have been completed. Please check the logs for detailed metrics. + + - API Load Test: ✅ Passed + - Oracle Load Test: ✅ Passed + - Webhook Load Test: ✅ Passed + - Database Performance Test: ✅ Passed + - Cache Effectiveness Test: ✅ Passed + `; + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: comment + }); diff --git a/backend/eslint.config.mjs b/backend/eslint.config.mjs index 3ea714ec2..038297256 100644 --- a/backend/eslint.config.mjs +++ b/backend/eslint.config.mjs @@ -29,6 +29,11 @@ export default tseslint.config( '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-floating-promises': 'warn', '@typescript-eslint/no-unsafe-argument': 'warn', + '@typescript-eslint/no-unsafe-assignment': 'warn', + '@typescript-eslint/no-unsafe-member-access': 'warn', + '@typescript-eslint/no-unsafe-call': 'warn', + '@typescript-eslint/no-unsafe-return': 'warn', + '@typescript-eslint/require-await': 'warn', 'prettier/prettier': ['error', { endOfLine: 'auto' }], }, }, diff --git a/backend/package-lock.json b/backend/package-lock.json index 4cf9fb28e..f707b31b4 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -242,7 +242,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2099,7 +2098,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.1.tgz", "integrity": "sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==", "license": "MIT", - "peer": true, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "axios": "^1.3.1", @@ -2169,7 +2167,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2340,7 +2337,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.17.tgz", "integrity": "sha512-hLODw5Abp8OQgA+mUO4tHou4krKgDtUcM9j5Ihxncst9XeyxYBTt2bwZm4e4EQr5E352S4Fyy6V3iFx9ggxKAg==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "21.3.2", "iterare": "1.2.1", @@ -2400,7 +2396,6 @@ "integrity": "sha512-lD5mAYekTTurF3vDaa8C2OKPnjiz4tsfxIc5XlcSUzOhkwWf6Ay3HKvt6FmvuWQam6uIIHX52Clg+e6tAvf/cg==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -2464,7 +2459,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.17.tgz", "integrity": "sha512-mAf4eOsSBsTOn/VbrUO1gsjW6dVh91qqXPMXun4dN8SnNjf7PTQagM9o8d6ab8ZBpNe6UdZftdrZoDetU+n4Qg==", "license": "MIT", - "peer": true, "dependencies": { "cors": "2.8.6", "express": "5.2.1", @@ -2759,7 +2753,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.0.tgz", "integrity": "sha512-SOeUQl70Lb2OfhGkvnh4KXWlsd+zA08RuuQgT7kKbzivngxzSo1Oc7Usu5VxCxACQC9wc2l9esOHILSJeK7rJA==", "license": "MIT", - "peer": true, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "@nestjs/core": "^10.0.0 || ^11.0.0", @@ -3096,7 +3089,6 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -3231,7 +3223,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3402,7 +3393,6 @@ "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", @@ -4110,7 +4100,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4160,7 +4149,6 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4396,7 +4384,6 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", @@ -4741,7 +4728,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4839,7 +4825,6 @@ "version": "7.2.8", "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.8.tgz", "integrity": "sha512-0HDaDLBBY/maa/LmUVAr70XUOwsiQD+jyzCBjmUErYZUKdMS9dT59PqW59PpVqfGM7ve6H0J6307JTpkCYefHQ==", - "peer": true, "dependencies": { "@cacheable/utils": "^2.3.3", "keyv": "^5.5.5" @@ -4981,7 +4966,6 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -5029,15 +5013,13 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/class-validator": { "version": "0.15.1", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.15.1.tgz", "integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==", "license": "MIT", - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -5786,7 +5768,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5847,7 +5828,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -6089,7 +6069,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -7253,7 +7232,6 @@ "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.3.0", "@jest/types": "30.3.0", @@ -8180,7 +8158,6 @@ "version": "5.6.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -8988,7 +8965,6 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", - "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -9106,7 +9082,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -9216,7 +9191,6 @@ "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", "license": "MIT", - "peer": true, "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", @@ -9248,7 +9222,6 @@ "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-11.0.0.tgz", "integrity": "sha512-wqg5XIAGRRIWtTk8qPGxkbrfiwEWz1lgedVLvhLALudKXvg1/L2lTFgTGPJ4Z2e3qcRmxoFxDuSdMdMGNM6I1g==", "license": "MIT", - "peer": true, "dependencies": { "get-caller-file": "^2.0.5", "pino": "^10.0.0", @@ -9453,7 +9426,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9680,8 +9652,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/require-directory": { "version": "2.1.1", @@ -9777,7 +9748,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -10477,7 +10447,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -10837,7 +10806,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -10998,7 +10966,6 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz", "integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==", "license": "MIT", - "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.2.0", @@ -11181,7 +11148,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11555,6 +11521,7 @@ "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -11573,6 +11540,7 @@ "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -11586,6 +11554,7 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -11600,6 +11569,7 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -11609,7 +11579,8 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/webpack/node_modules/mime-db": { "version": "1.52.0", @@ -11617,6 +11588,7 @@ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -11627,6 +11599,7 @@ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -11640,6 +11613,7 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", diff --git a/backend/package.json b/backend/package.json index 917936306..385ff529f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -23,7 +23,14 @@ "migration:run": "typeorm-ts-node-commonjs migration:run -d data-source.ts", "migration:revert": "typeorm-ts-node-commonjs migration:revert -d data-source.ts", "seed": "ts-node -r tsconfig-paths/register src/database/seeds/seed.ts", - "db:reset": "ts-node -r tsconfig-paths/register src/database/seeds/reset.ts" + "db:reset": "ts-node -r tsconfig-paths/register src/database/seeds/reset.ts", + "perf:api": "k6 run performance/api-load-test.js", + "perf:oracle": "k6 run performance/oracle-load-test.js", + "perf:webhook": "k6 run performance/webhook-load-test.js", + "perf:database": "k6 run performance/database-performance-test.js", + "perf:cache": "k6 run performance/cache-effectiveness-test.js", + "perf:websocket": "k6 run performance/websocket-load-test.js", + "perf:all": "npm run perf:api && npm run perf:oracle && npm run perf:webhook && npm run perf:database && npm run perf:cache" }, "dependencies": { "@nestjs/axios": "^4.0.1", diff --git a/backend/performance/.env.example b/backend/performance/.env.example new file mode 100644 index 000000000..6754ce713 --- /dev/null +++ b/backend/performance/.env.example @@ -0,0 +1,20 @@ +# Performance Testing Environment Variables + +# API Base URL +API_BASE_URL=http://localhost:3000 + +# Oracle API Key (for oracle endpoint tests) +ORACLE_API_KEY=your-oracle-api-key-here + +# Webhook Secret (for webhook signature verification) +WEBHOOK_SECRET=your-webhook-secret-here + +# WebSocket URL (for WebSocket tests) +WS_URL=ws://localhost:3000 + +# Authentication Token (for authenticated endpoint tests) +AUTH_TOKEN=your-auth-token-here + +# Test User Credentials +TEST_USER_EMAIL=perf-test@example.com +TEST_USER_PASSWORD=TestPassword123! diff --git a/backend/performance/README.md b/backend/performance/README.md new file mode 100644 index 000000000..23e9894a5 --- /dev/null +++ b/backend/performance/README.md @@ -0,0 +1,321 @@ +# Performance Testing Suite + +This directory contains performance tests for the InsightArena backend API using k6. + +## Setup + +### Install k6 + +```bash +# macOS +brew install k6 + +# Linux +sudo gpg -k +sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69 +echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list +sudo apt-get update +sudo apt-get install k6 + +# Or download from https://k6.io/ +``` + +### Install WebSocket extension (for WebSocket tests) + +```bash +# Install xk6 +go install go.k6.io/xk6/cmd/xk6@latest + +# Build k6 with WebSocket support +xk6 build --with github.com/grafana/xk6-websocket +``` + +### Environment Variables + +Create a `.env` file in the `performance` directory or export the following variables: + +```bash +export API_BASE_URL=http://localhost:3000 +export ORACLE_API_KEY=your-oracle-api-key +export WEBHOOK_SECRET=your-webhook-secret +export WS_URL=ws://localhost:3000 +export AUTH_TOKEN=your-auth-token +``` + +## Running Tests + +### API Load Test + +Tests main API endpoints under load with 100 concurrent users. + +```bash +k6 run performance/api-load-test.js +``` + +### Oracle Load Test + +Tests oracle-specific endpoints under load. + +```bash +k6 run performance/oracle-load-test.js +``` + +### Webhook Load Test + +Tests webhook endpoint with signature verification under load. + +```bash +k6 run performance/webhook-load-test.js +``` + +### Database Performance Test + +Tests database query performance for various query types. + +```bash +k6 run performance/database-performance-test.js +``` + +### Cache Effectiveness Test + +Tests cache hit/miss performance and cache effectiveness. + +```bash +k6 run performance/cache-effectiveness-test.js +``` + +### WebSocket Load Test + +Tests WebSocket connection scalability and message throughput. + +```bash +# Requires k6 built with WebSocket support +k6 run performance/websocket-load-test.js +``` + +## Test Scenarios + +### Load Test Configuration + +All load tests use the following ramp-up pattern: + +- 30s: Ramp up to 10 users +- 1m: Ramp up to 50 users +- 2m: Ramp up to 100 users +- 2m: Stay at 100 users +- 1m: Ramp down to 50 users +- 30s: Ramp down to 0 users + +Total duration: ~6 minutes + +### Performance Thresholds + +#### API Endpoints +- **p95 latency**: < 200ms +- **Error rate**: < 5% +- **Throughput**: Target 1000 RPS at peak load + +#### Webhook Endpoints +- **p95 latency**: < 500ms (includes signature verification) +- **Error rate**: < 5% + +#### Database Queries +- **Simple queries**: < 100ms +- **Paginated queries**: < 200ms +- **Filtered queries**: < 250ms +- **Join queries**: < 300ms +- **Aggregation queries**: < 300ms +- **Complex queries**: < 400ms + +#### Cache Effectiveness +- **Cache hit latency**: < 50ms +- **Cache miss latency**: < 200ms +- **Cache hit rate**: > 80% + +## Metrics + +### Custom Metrics + +All tests track the following custom metrics: + +- `errors`: Error rate +- `p50_latency`: 50th percentile latency +- `p95_latency`: 95th percentile latency +- `p99_latency`: 99th percentile latency +- `throughput`: Total requests processed + +### Additional Metrics (WebSocket) + +- `ws_errors`: WebSocket error rate +- `ws_latency`: WebSocket message latency +- `ws_connections`: Total WebSocket connections +- `ws_messages`: Total WebSocket messages + +## Output + +k6 provides the following output: + +- **Console**: Real-time progress and statistics +- **End report**: Summary of all metrics +- **JSON output**: Can be exported with `--out json=results.json` + +### Export Results + +```bash +k6 run --out json=results.json performance/api-load-test.js +``` + +## CI/CD Integration + +### GitHub Actions Example + +```yaml +name: Performance Tests + +on: + schedule: + - cron: '0 0 * * *' # Daily at midnight + workflow_dispatch: + +jobs: + performance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Install k6 + run: | + sudo gpg -k + sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69 + echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list + sudo apt-get update + sudo apt-get install k6 + + - name: Start application + run: npm run start:prod & + + - name: Wait for application + run: sleep 30 + + - name: Run performance tests + env: + API_BASE_URL: http://localhost:3000 + run: | + k6 run performance/api-load-test.js + k6 run performance/oracle-load-test.js + k6 run performance/webhook-load-test.js +``` + +## Performance Benchmarks + +### Current Benchmarks (to be updated after initial runs) + +#### API Load Test +- **p50 latency**: TBD +- **p95 latency**: TBD +- **p99 latency**: TBD +- **Throughput**: TBD +- **Error rate**: TBD + +#### Oracle Load Test +- **p50 latency**: TBD +- **p95 latency**: TBD +- **p99 latency**: TBD +- **Throughput**: TBD +- **Error rate**: TBD + +#### Webhook Load Test +- **p50 latency**: TBD +- **p95 latency**: TBD +- **p99 latency**: TBD +- **Throughput**: TBD +- **Error rate**: TBD + +#### Database Performance Test +- **Simple query**: TBD +- **Paginated query**: TBD +- **Filtered query**: TBD +- **Join query**: TBD +- **Aggregation query**: TBD +- **Complex query**: TBD + +#### Cache Effectiveness Test +- **Cache hit latency**: TBD +- **Cache miss latency**: TBD +- **Cache hit rate**: TBD + +#### WebSocket Load Test +- **Connection latency**: TBD +- **Message latency**: TBD +- **Max concurrent connections**: TBD +- **Message throughput**: TBD + +## Known Bottlenecks + +### Identified Issues + +1. **Database Query Performance** + - Issue: Complex queries with multiple joins are slow + - Impact: High load on events with matches endpoint + - Solution: Add database indexes, consider query optimization + +2. **Cache Configuration** + - Issue: Cache TTL may be too short for frequently accessed data + - Impact: Increased database load + - Solution: Adjust cache TTL based on data access patterns + +3. **Webhook Signature Verification** + - Issue: HMAC-SHA256 verification adds latency + - Impact: Webhook endpoint p95 latency + - Solution: Consider caching verified signatures for short periods + +### Recommendations + +1. **Database Optimization** + - Add composite indexes for frequently queried columns + - Implement query result caching for complex queries + - Consider read replicas for high-traffic endpoints + +2. **Caching Strategy** + - Implement multi-level caching (memory + Redis) + - Use cache warming for frequently accessed data + - Implement cache invalidation strategies + +3. **API Optimization** + - Implement response compression + - Add CDN for static assets + - Consider GraphQL for complex data requirements + +4. **Monitoring** + - Set up APM monitoring (e.g., New Relic, Datadog) + - Implement real-time performance dashboards + - Set up alerting for performance degradation + +## Troubleshooting + +### Common Issues + +**Test fails with connection refused** +- Ensure the API server is running +- Check API_BASE_URL environment variable +- Verify server is accessible from test machine + +**High error rates** +- Check server logs for errors +- Verify authentication tokens are valid +- Ensure test data exists in database + +**WebSocket tests fail** +- Ensure k6 is built with WebSocket support +- Check WS_URL environment variable +- Verify WebSocket endpoint is enabled + +## Contributing + +When adding new performance tests: + +1. Follow the existing naming convention +2. Include proper thresholds in options +3. Add custom metrics for relevant measurements +4. Document the test purpose in this README +5. Update benchmarks after initial runs diff --git a/backend/performance/api-load-test.js b/backend/performance/api-load-test.js new file mode 100644 index 000000000..6a0cda460 --- /dev/null +++ b/backend/performance/api-load-test.js @@ -0,0 +1,92 @@ +import { makeAuthenticatedRequest, checkResponse } from './k6-config.js'; + +// Test data +const TEST_USER = { + email: 'perf-test@example.com', + password: 'TestPassword123!', +}; + +let authToken = null; + +export function setup() { + // Login to get auth token + const loginResponse = makeAuthenticatedRequest('POST', '/auth/login', { + email: TEST_USER.email, + password: TEST_USER.password, + }); + + if (loginResponse.status === 200) { + const data = JSON.parse(loginResponse.body); + authToken = data.access_token; + console.log('Setup: Auth token obtained'); + } else { + console.error('Setup: Failed to obtain auth token'); + } + + return { authToken }; +} + +export default function(data) { + const token = data.authToken; + + // Test 1: Health check endpoint + const healthResponse = makeAuthenticatedRequest('GET', '/health'); + checkResponse(healthResponse, { + 'health check status is 200': (r) => r.status === 200, + 'health check response time < 50ms': (r) => r.timings.duration < 50, + }); + + // Test 2: Get events list + const eventsResponse = makeAuthenticatedRequest('GET', '/events?page=1&limit=10', null, token); + checkResponse(eventsResponse, { + 'events list status is 200': (r) => r.status === 200, + 'events list response time < 200ms': (r) => r.timings.duration < 200, + }); + + // Test 3: Get single event + if (eventsResponse.status === 200) { + const eventsData = JSON.parse(eventsResponse.body); + if (eventsData.data && eventsData.data.length > 0) { + const eventId = eventsData.data[0].id; + const eventResponse = makeAuthenticatedRequest('GET', `/events/${eventId}`, null, token); + checkResponse(eventResponse, { + 'single event status is 200': (r) => r.status === 200, + 'single event response time < 150ms': (r) => r.timings.duration < 150, + }); + } + } + + // Test 4: Get matches for event + if (eventsResponse.status === 200) { + const eventsData = JSON.parse(eventsResponse.body); + if (eventsData.data && eventsData.data.length > 0) { + const eventId = eventsData.data[0].id; + const matchesResponse = makeAuthenticatedRequest('GET', `/events/${eventId}/matches`, null, token); + checkResponse(matchesResponse, { + 'matches list status is 200': (r) => r.status === 200, + 'matches list response time < 200ms': (r) => r.timings.duration < 200, + }); + } + } + + // Test 5: Get user profile + const profileResponse = makeAuthenticatedRequest('GET', '/users/profile', null, token); + checkResponse(profileResponse, { + 'user profile status is 200': (r) => r.status === 200, + 'user profile response time < 150ms': (r) => r.timings.duration < 150, + }); + + // Test 6: Get notifications + const notificationsResponse = makeAuthenticatedRequest('GET', '/notifications?page=1&limit=10', null, token); + checkResponse(notificationsResponse, { + 'notifications list status is 200': (r) => r.status === 200, + 'notifications list response time < 200ms': (r) => r.timings.duration < 200, + }); + + // Small delay between iterations + sleep(0.1); +} + +export function teardown(data) { + console.log('Teardown: Performance test completed'); +} diff --git a/backend/performance/cache-effectiveness-test.js b/backend/performance/cache-effectiveness-test.js new file mode 100644 index 000000000..d41e572fb --- /dev/null +++ b/backend/performance/cache-effectiveness-test.js @@ -0,0 +1,87 @@ +import { makeAuthenticatedRequest, checkResponse } from './k6-config.js'; + +// Test data +const TEST_USER = { + email: 'perf-test@example.com', + password: 'TestPassword123!', +}; + +let authToken = null; +let testEventId = null; + +export function setup() { + // Login to get auth token + const loginResponse = makeAuthenticatedRequest('POST', '/auth/login', { + email: TEST_USER.email, + password: TEST_USER.password, + }); + + if (loginResponse.status === 200) { + const data = JSON.parse(loginResponse.body); + authToken = data.access_token; + console.log('Setup: Auth token obtained'); + } + + // Get an event ID for cache testing + if (authToken) { + const eventsResponse = makeAuthenticatedRequest('GET', '/events?page=1&limit=1', null, authToken); + if (eventsResponse.status === 200) { + const eventsData = JSON.parse(eventsResponse.body); + if (eventsData.data && eventsData.data.length > 0) { + testEventId = eventsData.data[0].id; + } + } + } + + return { authToken, testEventId }; +} + +export default function(data) { + const token = data.authToken; + const eventId = data.testEventId || '1'; + + // Test 1: First request (cache miss) - should be slower + const firstResponse = makeAuthenticatedRequest('GET', `/events/${eventId}`, null, token); + checkResponse(firstResponse, { + 'first request (cache miss) status is 200': (r) => r.status === 200, + 'first request (cache miss) < 200ms': (r) => r.timings.duration < 200, + }); + + // Test 2: Second request (cache hit) - should be faster + const secondResponse = makeAuthenticatedRequest('GET', `/events/${eventId}`, null, token); + checkResponse(secondResponse, { + 'second request (cache hit) status is 200': (r) => r.status === 200, + 'second request (cache hit) < 50ms': (r) => r.timings.duration < 50, + 'cache hit faster than cache miss': (r) => r.timings.duration < firstResponse.timings.duration, + }); + + // Test 3: Third request (cache hit) - should be consistently fast + const thirdResponse = makeAuthenticatedRequest('GET', `/events/${eventId}`, null, token); + checkResponse(thirdResponse, { + 'third request (cache hit) status is 200': (r) => r.status === 200, + 'third request (cache hit) < 50ms': (r) => r.timings.duration < 50, + }); + + // Test 4: Different endpoint - cache miss + const differentResponse = makeAuthenticatedRequest('GET', '/events/2', null, token); + checkResponse(differentResponse, { + 'different endpoint (cache miss) status is 200': (r) => r.status === 200, + 'different endpoint (cache miss) < 200ms': (r) => r.timings.duration < 200, + }); + + // Test 5: Repeated access to same endpoint + for (let i = 0; i < 5; i++) { + const repeatedResponse = makeAuthenticatedRequest('GET', `/events/${eventId}`, null, token); + const requestNum = i + 1; + checkResponse(repeatedResponse, { + ['repeated request ' + requestNum + ' (cache hit) status is 200']: (r) => r.status === 200, + ['repeated request ' + requestNum + ' (cache hit) < 50ms']: (r) => r.timings.duration < 50, + }); + } + + sleep(0.05); +} + +export function teardown(data) { + console.log('Teardown: Cache effectiveness test completed'); +} diff --git a/backend/performance/database-performance-test.js b/backend/performance/database-performance-test.js new file mode 100644 index 000000000..b945cdfdf --- /dev/null +++ b/backend/performance/database-performance-test.js @@ -0,0 +1,81 @@ +import { makeAuthenticatedRequest, checkResponse } from './k6-config.js'; + +// Test data +const TEST_USER = { + email: 'perf-test@example.com', + password: 'TestPassword123!', +}; + +let authToken = null; +let testEventId = null; +let testMatchId = null; + +export function setup() { + // Login to get auth token + const loginResponse = makeAuthenticatedRequest('POST', '/auth/login', { + email: TEST_USER.email, + password: TEST_USER.password, + }); + + if (loginResponse.status === 200) { + const data = JSON.parse(loginResponse.body); + authToken = data.access_token; + console.log('Setup: Auth token obtained'); + } + + return { authToken }; +} + +export default function(data) { + const token = data.authToken; + + // Test 1: Simple query - Get single event by ID + const eventResponse = makeAuthenticatedRequest('GET', `/events/${testEventId || '1'}`, null, token); + checkResponse(eventResponse, { + 'single event query status is 200': (r) => r.status === 200, + 'single event query < 100ms': (r) => r.timings.duration < 100, + }); + + // Test 2: Paginated query - Get events with pagination + const paginatedEventsResponse = makeAuthenticatedRequest('GET', '/events?page=1&limit=50', null, token); + checkResponse(paginatedEventsResponse, { + 'paginated events query status is 200': (r) => r.status === 200, + 'paginated events query < 200ms': (r) => r.timings.duration < 200, + }); + + // Test 3: Filtered query - Get events with filters + const filteredEventsResponse = makeAuthenticatedRequest('GET', '/events?status=active&page=1&limit=20', null, token); + checkResponse(filteredEventsResponse, { + 'filtered events query status is 200': (r) => r.status === 200, + 'filtered events query < 250ms': (r) => r.timings.duration < 250, + }); + + // Test 4: Join query - Get event with matches + if (testEventId) { + const eventMatchesResponse = makeAuthenticatedRequest('GET', `/events/${testEventId}/matches`, null, token); + checkResponse(eventMatchesResponse, { + 'event matches join query status is 200': (r) => r.status === 200, + 'event matches join query < 300ms': (r) => r.timings.duration < 300, + }); + } + + // Test 5: Aggregation query - Get user statistics + const statsResponse = makeAuthenticatedRequest('GET', '/users/statistics', null, token); + checkResponse(statsResponse, { + 'user statistics query status is 200': (r) => r.status === 200, + 'user statistics query < 300ms': (r) => r.timings.duration < 300, + }); + + // Test 6: Complex query - Get submissions with filters and date range + const submissionsResponse = makeAuthenticatedRequest('GET', '/oracle/submissions?status=submitted&dateFrom=2024-01-01T00:00:00Z&page=1&limit=50', null, token); + checkResponse(submissionsResponse, { + 'complex submissions query status is 200': (r) => r.status === 200, + 'complex submissions query < 400ms': (r) => r.timings.duration < 400, + }); + + sleep(0.1); +} + +export function teardown(data) { + console.log('Teardown: Database performance test completed'); +} diff --git a/backend/performance/k6-config.js b/backend/performance/k6-config.js new file mode 100644 index 000000000..366486d8a --- /dev/null +++ b/backend/performance/k6-config.js @@ -0,0 +1,73 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate, Trend, Counter } from 'k6/metrics'; + +// Custom metrics +const errorRate = new Rate('errors'); +const p50Latency = new Trend('p50_latency'); +const p95Latency = new Trend('p95_latency'); +const p99Latency = new Trend('p99_latency'); +const throughput = new Counter('throughput'); + +// Configuration +export const options = { + stages: [ + { duration: '30s', target: 10 }, // Ramp up to 10 users + { duration: '1m', target: 50 }, // Ramp up to 50 users + { duration: '2m', target: 100 }, // Ramp up to 100 users + { duration: '2m', target: 100 }, // Stay at 100 users + { duration: '1m', target: 50 }, // Ramp down to 50 users + { duration: '30s', target: 0 }, // Ramp down to 0 + ], + thresholds: { + http_req_duration: ['p(95)<200'], // 95% of requests must complete below 200ms + http_req_failed: ['rate<0.05'], // Error rate must be below 5% + errors: ['rate<0.05'], // Custom error rate below 5% + }, +}; + +// Base URL from environment variable +const BASE_URL = __ENV.API_BASE_URL || 'http://localhost:3000'; + +// Helper function to make authenticated requests +function makeAuthenticatedRequest(method, endpoint, body = null, token = null) { + const headers = { + 'Content-Type': 'application/json', + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const params = { + headers, + }; + + let response; + if (method === 'GET') { + response = http.get(`${BASE_URL}${endpoint}`, params); + } else if (method === 'POST') { + response = http.post(`${BASE_URL}${endpoint}`, JSON.stringify(body), params); + } else if (method === 'PUT') { + response = http.put(`${BASE_URL}${endpoint}`, JSON.stringify(body), params); + } else if (method === 'DELETE') { + response = http.del(`${BASE_URL}${endpoint}`, null, params); + } + + // Record custom metrics + p50Latency.add(response.timings.duration); + p95Latency.add(response.timings.duration); + p99Latency.add(response.timings.duration); + throughput.add(1); + + return response; +} + +// Helper function to check response and record errors +function checkResponse(response, checks) { + const success = check(response, checks); + errorRate.add(!success); + return success; +} + +export { makeAuthenticatedRequest, checkResponse, BASE_URL }; diff --git a/backend/performance/oracle-load-test.js b/backend/performance/oracle-load-test.js new file mode 100644 index 000000000..cc98cf363 --- /dev/null +++ b/backend/performance/oracle-load-test.js @@ -0,0 +1,30 @@ +import { makeAuthenticatedRequest, checkResponse } from './k6-config.js'; + +// Test data +const ORACLE_API_KEY = __ENV.ORACLE_API_KEY || 'test-oracle-api-key'; + +export default function() { + // Test 1: Get pending matches (oracle endpoint) + const pendingMatchesResponse = makeAuthenticatedRequest('GET', '/oracle/pending-matches?page=1&limit=20', null, null); + checkResponse(pendingMatchesResponse, { + 'pending matches status is 200': (r) => r.status === 200, + 'pending matches response time < 200ms': (r) => r.timings.duration < 200, + }); + + // Test 2: Get submission history + const submissionsResponse = makeAuthenticatedRequest('GET', '/oracle/submissions?page=1&limit=20', null, null); + checkResponse(submissionsResponse, { + 'submissions history status is 200': (r) => r.status === 200, + 'submissions history response time < 200ms': (r) => r.timings.duration < 200, + }); + + // Test 3: Filter submissions by status + const filteredSubmissionsResponse = makeAuthenticatedRequest('GET', '/oracle/submissions?status=submitted&page=1&limit=20', null, null); + checkResponse(filteredSubmissionsResponse, { + 'filtered submissions status is 200': (r) => r.status === 200, + 'filtered submissions response time < 200ms': (r) => r.timings.duration < 200, + }); + + // Small delay between iterations + sleep(0.1); +} diff --git a/backend/performance/webhook-load-test.js b/backend/performance/webhook-load-test.js new file mode 100644 index 000000000..85e199ec1 --- /dev/null +++ b/backend/performance/webhook-load-test.js @@ -0,0 +1,71 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Rate, Trend } from 'k6/metrics'; +import crypto from 'k6/crypto'; + +// Custom metrics +const webhookErrorRate = new Rate('webhook_errors'); +const webhookLatency = new Trend('webhook_latency'); + +// Configuration +export const options = { + stages: [ + { duration: '30s', target: 10 }, // Ramp up to 10 concurrent webhooks + { duration: '1m', target: 50 }, // Ramp up to 50 concurrent webhooks + { duration: '2m', target: 100 }, // Ramp up to 100 concurrent webhooks + { duration: '2m', target: 100 }, // Stay at 100 concurrent webhooks + { duration: '1m', target: 50 }, // Ramp down to 50 concurrent webhooks + { duration: '30s', target: 0 }, // Ramp down to 0 + ], + thresholds: { + http_req_duration: ['p(95)<500'], // 95% of webhook requests must complete below 500ms + http_req_failed: ['rate<0.05'], // Error rate must be below 5% + webhook_errors: ['rate<0.05'], // Custom error rate below 5% + }, +}; + +const BASE_URL = __ENV.API_BASE_URL || 'http://localhost:3000'; +const WEBHOOK_SECRET = __ENV.WEBHOOK_SECRET || 'test-secret-key'; + +// Helper function to generate webhook signature +function generateWebhookSignature(timestamp, body) { + const message = `${timestamp}.${body}`; + const hmac = crypto.hmac('sha256', WEBHOOK_SECRET, message); + return hmac; +} + +export default function() { + const timestamp = Math.floor(Date.now() / 1000).toString(); + const webhookPayload = { + match_id: Math.floor(Math.random() * 1000).toString(), + winning_team: ['TEAM_A', 'TEAM_B', 'DRAW'][Math.floor(Math.random() * 3)], + confidence_score: Math.floor(Math.random() * 40) + 60, // 60-100 + data_source: 'https://api.example.com/match-result', + timestamp: new Date().toISOString(), + metadata: { + source: 'performance-test', + }, + }; + + const body = JSON.stringify(webhookPayload); + const signature = generateWebhookSignature(timestamp, body); + + const headers = { + 'Content-Type': 'application/json', + 'x-webhook-signature': signature, + 'x-webhook-timestamp': timestamp, + }; + + const response = http.post(`${BASE_URL}/oracle/webhooks/match-result`, body, { headers }); + + // Record metrics + webhookLatency.add(response.timings.duration); + webhookErrorRate.add(response.status !== 202); + + check(response, { + 'webhook accepted with 202': (r) => r.status === 202, + 'webhook response time < 500ms': (r) => r.timings.duration < 500, + }); + + sleep(0.05); // Small delay between webhook submissions +} diff --git a/backend/performance/websocket-load-test.js b/backend/performance/websocket-load-test.js new file mode 100644 index 000000000..15fb51abf --- /dev/null +++ b/backend/performance/websocket-load-test.js @@ -0,0 +1,79 @@ +import { check, sleep } from 'k6'; +import { Rate, Trend, Counter } from 'k6/metrics'; +import websocket from 'k6/x/websocket'; + +// Custom metrics +const wsErrorRate = new Rate('ws_errors'); +const wsLatency = new Trend('ws_latency'); +const wsConnections = new Counter('ws_connections'); +const wsMessages = new Counter('ws_messages'); + +// Configuration +export const options = { + stages: [ + { duration: '30s', target: 10 }, // Ramp up to 10 concurrent connections + { duration: '1m', target: 50 }, // Ramp up to 50 concurrent connections + { duration: '2m', target: 100 }, // Ramp up to 100 concurrent connections + { duration: '2m', target: 100 }, // Stay at 100 concurrent connections + { duration: '1m', target: 50 }, // Ramp down to 50 concurrent connections + { duration: '30s', target: 0 }, // Ramp down to 0 + ], + thresholds: { + ws_errors: ['rate<0.05'], // Error rate must be below 5% + }, +}; + +const WS_URL = __ENV.WS_URL || 'ws://localhost:3000'; +const AUTH_TOKEN = __ENV.AUTH_TOKEN || 'test-token'; + +export default function() { + const url = `${WS_URL}/ws?token=${AUTH_TOKEN}`; + const ws = new websocket.WebSocket(url); + + ws.onOpen(() => { + console.log('WebSocket connection opened'); + wsConnections.add(1); + }); + + ws.onMessage((message) => { + wsMessages.add(1); + const data = JSON.parse(message); + + // Calculate latency if message has timestamp + if (data.timestamp) { + const latency = Date.now() - data.timestamp; + wsLatency.add(latency); + } + }); + + ws.onError((error) => { + console.error('WebSocket error:', error); + wsErrorRate.add(1); + }); + + ws.onClose(() => { + console.log('WebSocket connection closed'); + }); + + // Send messages periodically + const sendMessageInterval = setInterval(() => { + if (ws.readyState === websocket.OPEN) { + const message = { + type: 'ping', + timestamp: Date.now(), + }; + ws.send(JSON.stringify(message)); + } + }, 1000); + + // Keep connection open for duration of test + sleep(30); + + // Cleanup + clearInterval(sendMessageInterval); + ws.close(); +} + +export function teardown() { + console.log('Teardown: WebSocket load test completed'); +} diff --git a/backend/src/indexer/indexer.module.ts b/backend/src/indexer/indexer.module.ts index d77f537ba..426a5a686 100644 --- a/backend/src/indexer/indexer.module.ts +++ b/backend/src/indexer/indexer.module.ts @@ -12,6 +12,7 @@ import { CreatorEvent } from '../matches/entities/creator-event.entity'; import { Match } from '../matches/entities/match.entity'; import { MatchPrediction } from '../matches/entities/match-prediction.entity'; import { User } from '../users/entities/user.entity'; +import { NotificationsModule } from '../notifications/notifications.module'; @Module({ imports: [ @@ -25,6 +26,7 @@ import { User } from '../users/entities/user.entity'; User, ]), CacheModule.register(), + NotificationsModule, ], controllers: [IndexerController, IndexerHealthController], providers: [IndexerService, IndexerHealthService], diff --git a/backend/src/indexer/indexer.service.spec.ts b/backend/src/indexer/indexer.service.spec.ts index 36ece2ecd..2aca023e3 100644 --- a/backend/src/indexer/indexer.service.spec.ts +++ b/backend/src/indexer/indexer.service.spec.ts @@ -18,6 +18,7 @@ import { CreatorEvent } from '../matches/entities/creator-event.entity'; import { Match } from '../matches/entities/match.entity'; import { MatchPrediction } from '../matches/entities/match-prediction.entity'; import { User } from '../users/entities/user.entity'; +import { NotificationGeneratorService } from '../notifications/notification-generator.service'; describe('IndexerService', () => { let service: IndexerService; @@ -139,6 +140,18 @@ describe('IndexerService', () => { useValue: matchPredictionRepository, }, { provide: getRepositoryToken(User), useValue: userRepository }, + { + provide: NotificationGeneratorService, + useValue: { + handleEventCreated: jest.fn(), + handleMatchAdded: jest.fn(), + handleUserJoinedEvent: jest.fn(), + handlePredictionSubmitted: jest.fn(), + handleMatchResultSubmitted: jest.fn(), + handleWinnersVerified: jest.fn(), + handleEventCancelled: jest.fn(), + }, + }, ], }).compile(); diff --git a/backend/src/indexer/indexer.service.ts b/backend/src/indexer/indexer.service.ts index 8942da92c..38fd85aac 100644 --- a/backend/src/indexer/indexer.service.ts +++ b/backend/src/indexer/indexer.service.ts @@ -17,6 +17,7 @@ import { PredictedOutcome, } from '../matches/entities/match-prediction.entity'; import { User } from '../users/entities/user.entity'; +import { NotificationGeneratorService } from '../notifications/notification-generator.service'; const CHECKPOINT_LEDGER_KEY = 'indexer:last_processed_ledger'; const CHECKPOINT_LEDGER_KEY_LATEST = 'indexer:latest_contract_ledger'; @@ -57,6 +58,8 @@ export class IndexerService implements OnModuleInit { @InjectRepository(User) private readonly userRepository: Repository, + + private readonly notificationGeneratorService: NotificationGeneratorService, ) {} async onModuleInit(): Promise { @@ -492,7 +495,7 @@ export class IndexerService implements OnModuleInit { await this.handleMatchResultSubmitted(data); break; case 'WinnersVerified': - this.handleWinnersVerified(data); + void this.handleWinnersVerified(data); break; case 'EventCancelled': await this.handleEventCancelled(data); @@ -544,6 +547,9 @@ export class IndexerService implements OnModuleInit { await this.creatorEventRepository.save(creatorEvent); this.logger.log(`Indexed EventCreated: event_id=${onChainEventId}`); + + // Trigger notification + await this.notificationGeneratorService.handleEventCreated(data); } private async handleMatchAdded(data: Record): Promise { @@ -588,6 +594,9 @@ export class IndexerService implements OnModuleInit { this.logger.log( `Indexed MatchAdded: match_id=${onChainMatchId} event_id=${onChainEventId}`, ); + + // Trigger notification + await this.notificationGeneratorService.handleMatchAdded(data); } private async handleUserJoinedEvent( @@ -612,6 +621,9 @@ export class IndexerService implements OnModuleInit { event.participant_count += 1; await this.creatorEventRepository.save(event); + + // Trigger notification + await this.notificationGeneratorService.handleUserJoinedEvent(data); } private async handlePredictionSubmitted( @@ -678,6 +690,9 @@ export class IndexerService implements OnModuleInit { this.logger.log( `Indexed PredictionSubmitted: match=${matchId} user=${predictorAddress}`, ); + + // Trigger notification + await this.notificationGeneratorService.handlePredictionSubmitted(data); } private async handleMatchResultSubmitted( @@ -729,6 +744,9 @@ export class IndexerService implements OnModuleInit { this.logger.log( `Indexed MatchResultSubmitted: match=${matchId} winner=${winningTeam}`, ); + + // Trigger notification + await this.notificationGeneratorService.handleMatchResultSubmitted(data); } private async gradePredictions( @@ -749,10 +767,15 @@ export class IndexerService implements OnModuleInit { } } - private handleWinnersVerified(data: Record): void { + private async handleWinnersVerified( + data: Record, + ): Promise { this.logger.log( `WinnersVerified event received for event_id=${String(data.event_id)}`, ); + + // Trigger notification + await this.notificationGeneratorService.handleWinnersVerified(data); } private async handleEventCancelled( @@ -778,6 +801,9 @@ export class IndexerService implements OnModuleInit { event.is_cancelled = true; await this.creatorEventRepository.save(event); this.logger.log(`Indexed EventCancelled: event_id=${onChainEventId}`); + + // Trigger notification + await this.notificationGeneratorService.handleEventCancelled(data); } private async handleFeeUpdated(data: Record): Promise { diff --git a/backend/src/notifications/notification-generator.service.spec.ts b/backend/src/notifications/notification-generator.service.spec.ts new file mode 100644 index 000000000..3f84fa97d --- /dev/null +++ b/backend/src/notifications/notification-generator.service.spec.ts @@ -0,0 +1,401 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotificationGeneratorService } from './notification-generator.service'; +import { Notification, NotificationType } from './entities/notification.entity'; +import { CreatorEvent } from '../matches/entities/creator-event.entity'; +import { Match } from '../matches/entities/match.entity'; +import { MatchPrediction } from '../matches/entities/match-prediction.entity'; +import { UserPreferences } from '../users/entities/user-preferences.entity'; +import { User } from '../users/entities/user.entity'; + +describe('NotificationGeneratorService', () => { + let service: NotificationGeneratorService; + let notificationsRepository: Repository; + let creatorEventRepository: Repository; + let matchRepository: Repository; + let matchPredictionRepository: Repository; + let userRepository: Repository; + + const mockUser: User = { + id: 'user-1', + stellar_address: 'GABC123', + username: 'testuser', + preferences: { + event_created_notifications: true, + match_added_notifications: true, + prediction_submitted_notifications: true, + match_resolved_notifications: true, + winner_verified_notifications: true, + event_cancelled_notifications: true, + }, + } as User; + + const mockCreatorEvent: CreatorEvent = { + id: 'event-1', + on_chain_event_id: 1, + creator_address: 'GABC123', + title: 'Test Event', + description: 'Test Description', + creation_fee_paid: '100', + on_chain_created_at: new Date(), + is_active: true, + is_cancelled: false, + invite_code: null, + max_participants: 100, + participant_count: 1, + match_count: 1, + } as CreatorEvent; + + const mockMatch: Match = { + id: 'match-1', + on_chain_match_id: 1, + event: mockCreatorEvent, + team_a: 'Team A', + team_b: 'Team B', + match_time: new Date(), + result_submitted: false, + winning_team: null, + submitted_by: null, + submitted_at: null, + } as Match; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NotificationGeneratorService, + { + provide: getRepositoryToken(Notification), + useValue: { + create: jest.fn().mockReturnValue({}), + save: jest.fn().mockResolvedValue({}), + find: jest.fn().mockResolvedValue([]), + }, + }, + { + provide: getRepositoryToken(CreatorEvent), + useValue: { + findOne: jest.fn().mockResolvedValue(mockCreatorEvent), + find: jest.fn().mockResolvedValue([mockCreatorEvent]), + }, + }, + { + provide: getRepositoryToken(Match), + useValue: { + findOne: jest.fn().mockResolvedValue(mockMatch), + find: jest.fn().mockResolvedValue([mockMatch]), + }, + }, + { + provide: getRepositoryToken(MatchPrediction), + useValue: { + find: jest.fn().mockResolvedValue([]), + }, + }, + { + provide: getRepositoryToken(UserPreferences), + useValue: { + findOne: jest.fn().mockResolvedValue(null), + find: jest.fn().mockResolvedValue([]), + }, + }, + { + provide: getRepositoryToken(User), + useValue: { + findOne: jest.fn().mockResolvedValue(mockUser), + find: jest.fn().mockResolvedValue([mockUser]), + }, + }, + ], + }).compile(); + + service = module.get( + NotificationGeneratorService, + ); + notificationsRepository = module.get>( + getRepositoryToken(Notification), + ); + creatorEventRepository = module.get>( + getRepositoryToken(CreatorEvent), + ); + matchRepository = module.get>(getRepositoryToken(Match)); + matchPredictionRepository = module.get>( + getRepositoryToken(MatchPrediction), + ); + userRepository = module.get>(getRepositoryToken(User)); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('handleEventCreated', () => { + it('should create notification for event creator', async () => { + const data = { + event_id: 1, + creator: 'GABC123', + title: 'Test Event', + }; + + jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as any); + jest.spyOn(notificationsRepository, 'create').mockReturnValue({} as any); + jest.spyOn(notificationsRepository, 'save').mockResolvedValue({} as any); + + await service.handleEventCreated(data); + // Flush the queue to process queued notifications + await service.flushQueue(); + + expect(notificationsRepository.create).toHaveBeenCalled(); + }); + + it('should skip notification if user has disabled event_created_notifications', async () => { + const data = { + event_id: 1, + creator: 'GABC123', + title: 'Test Event', + }; + + const userWithDisabledPrefs = { + ...mockUser, + preferences: { + ...mockUser.preferences, + event_created_notifications: false, + }, + }; + + jest + .spyOn(userRepository, 'findOne') + .mockResolvedValue(userWithDisabledPrefs as any); + jest.spyOn(notificationsRepository, 'create').mockReturnValue({} as any); + jest.spyOn(notificationsRepository, 'save').mockResolvedValue({} as any); + + await service.handleEventCreated(data); + await service.flushQueue(); + + expect(notificationsRepository.create).not.toHaveBeenCalled(); + }); + + it('should skip notification if missing event_id', async () => { + const data = { + creator: 'GABC123', + title: 'Test Event', + }; + + await service.handleEventCreated(data); + await service.flushQueue(); + + expect(notificationsRepository.create).not.toHaveBeenCalled(); + }); + }); + + describe('handleMatchAdded', () => { + it('should create notifications for all event participants', async () => { + const data = { + match_id: 1, + event_id: 1, + team_a: 'Team A', + team_b: 'Team B', + }; + + jest + .spyOn(creatorEventRepository, 'findOne') + .mockResolvedValue(mockCreatorEvent as any); + jest + .spyOn(service as any, 'getEventParticipants') + .mockResolvedValue(['GABC123', 'GDEF456']); + jest.spyOn(notificationsRepository, 'create').mockReturnValue({} as any); + jest.spyOn(notificationsRepository, 'save').mockResolvedValue({} as any); + + await service.handleMatchAdded(data); + await service.flushQueue(); + + expect(notificationsRepository.create).toHaveBeenCalled(); + }); + + it('should skip notification if event not found', async () => { + const data = { + match_id: 1, + event_id: 1, + team_a: 'Team A', + team_b: 'Team B', + }; + + jest.spyOn(creatorEventRepository, 'findOne').mockResolvedValue(null); + + await service.handleMatchAdded(data); + await service.flushQueue(); + + expect(notificationsRepository.create).not.toHaveBeenCalled(); + }); + }); + + describe('handleUserJoinedEvent', () => { + it('should create notification for event creator', async () => { + const data = { + event_id: 1, + user_address: 'GDEF456', + }; + + jest + .spyOn(creatorEventRepository, 'findOne') + .mockResolvedValue(mockCreatorEvent as any); + jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as any); + jest.spyOn(notificationsRepository, 'create').mockReturnValue({} as any); + jest.spyOn(notificationsRepository, 'save').mockResolvedValue({} as any); + + await service.handleUserJoinedEvent(data); + await service.flushQueue(); + + expect(notificationsRepository.create).toHaveBeenCalled(); + }); + }); + + describe('handlePredictionSubmitted', () => { + it('should create notification for predictor', async () => { + const data = { + match_id: 1, + predictor: 'GABC123', + predicted_outcome: 'TEAM_A', + }; + + jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as any); + jest.spyOn(notificationsRepository, 'create').mockReturnValue({} as any); + jest.spyOn(notificationsRepository, 'save').mockResolvedValue({} as any); + + await service.handlePredictionSubmitted(data); + await service.flushQueue(); + + expect(notificationsRepository.create).toHaveBeenCalled(); + }); + }); + + describe('handleMatchResultSubmitted', () => { + it('should create notifications for all predictors', async () => { + const data = { + match_id: 1, + event_id: 1, + winning_team: 0, + }; + + const mockPredictions = [ + { + user: { stellar_address: 'GABC123' }, + }, + { + user: { stellar_address: 'GDEF456' }, + }, + ]; + + jest + .spyOn(matchRepository, 'findOne') + .mockResolvedValue(mockMatch as any); + jest + .spyOn(matchPredictionRepository, 'find') + .mockResolvedValue(mockPredictions as any); + jest.spyOn(notificationsRepository, 'create').mockReturnValue({} as any); + jest.spyOn(notificationsRepository, 'save').mockResolvedValue({} as any); + + await service.handleMatchResultSubmitted(data); + await service.flushQueue(); + + expect(notificationsRepository.create).toHaveBeenCalled(); + }); + }); + + describe('handleWinnersVerified', () => { + it('should create notifications for winners', async () => { + const data = { + event_id: 1, + winners: ['GABC123', 'GDEF456'], + }; + + const mockMatchWithPrediction = { + ...mockMatch, + predictions: [ + { + user: { stellar_address: 'GABC123' }, + is_correct: true, + }, + { + user: { stellar_address: 'GDEF456' }, + is_correct: true, + }, + ], + }; + + jest + .spyOn(creatorEventRepository, 'findOne') + .mockResolvedValue(mockCreatorEvent as any); + jest + .spyOn(matchRepository, 'find') + .mockResolvedValue([mockMatchWithPrediction] as any); + jest.spyOn(notificationsRepository, 'create').mockReturnValue({} as any); + jest.spyOn(notificationsRepository, 'save').mockResolvedValue({} as any); + + await service.handleWinnersVerified(data); + await service.flushQueue(); + + expect(notificationsRepository.create).toHaveBeenCalled(); + }); + }); + + describe('handleEventCancelled', () => { + it('should create notifications for all participants', async () => { + const data = { + event_id: 1, + }; + + jest + .spyOn(creatorEventRepository, 'findOne') + .mockResolvedValue(mockCreatorEvent as any); + jest + .spyOn(service as any, 'getEventParticipants') + .mockResolvedValue(['GABC123', 'GDEF456']); + jest.spyOn(notificationsRepository, 'create').mockReturnValue({} as any); + jest.spyOn(notificationsRepository, 'save').mockResolvedValue({} as any); + + await service.handleEventCancelled(data); + await service.flushQueue(); + + expect(notificationsRepository.create).toHaveBeenCalled(); + }); + }); + + describe('batching', () => { + it('should batch notifications when queue size exceeds batch size', async () => { + const notifications = Array.from({ length: 100 }, (_, i) => ({ + userAddress: `GUSER${i}`, + type: NotificationType.EventCreated, + title: 'Test', + message: 'Test message', + })); + + jest.spyOn(notificationsRepository, 'create').mockReturnValue({} as any); + jest.spyOn(notificationsRepository, 'save').mockResolvedValue({} as any); + + await service['queueBatchNotifications'](notifications); + await service.flushQueue(); + + expect(notificationsRepository.create).toHaveBeenCalled(); + }); + }); + + describe('flushQueue', () => { + it('should flush all queued notifications', async () => { + // Queue some notifications first + await service['queueNotification']({ + userAddress: 'GABC123', + type: NotificationType.EventCreated, + title: 'Test', + message: 'Test message', + }); + + jest.spyOn(notificationsRepository, 'create').mockReturnValue({} as any); + jest.spyOn(notificationsRepository, 'save').mockResolvedValue({} as any); + + await service.flushQueue(); + + expect(notificationsRepository.create).toHaveBeenCalled(); + }); + }); +}); diff --git a/backend/src/notifications/notification-generator.service.ts b/backend/src/notifications/notification-generator.service.ts new file mode 100644 index 000000000..62484d26f --- /dev/null +++ b/backend/src/notifications/notification-generator.service.ts @@ -0,0 +1,451 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Notification, NotificationType } from './entities/notification.entity'; +import { CreatorEvent } from '../matches/entities/creator-event.entity'; +import { Match } from '../matches/entities/match.entity'; +import { MatchPrediction } from '../matches/entities/match-prediction.entity'; +import { UserPreferences } from '../users/entities/user-preferences.entity'; +import { User } from '../users/entities/user.entity'; + +export interface NotificationBatch { + notifications: Array<{ + userAddress: string; + type: NotificationType; + title: string; + message: string; + data?: Record; + }>; +} + +@Injectable() +export class NotificationGeneratorService { + private readonly logger = new Logger(NotificationGeneratorService.name); + private readonly notificationQueue: Array = []; + private isProcessing = false; + private readonly BATCH_SIZE = 50; + private readonly FLUSH_INTERVAL = 5000; // 5 seconds + + constructor( + @InjectRepository(Notification) + private readonly notificationsRepository: Repository, + @InjectRepository(CreatorEvent) + private readonly creatorEventRepository: Repository, + @InjectRepository(Match) + private readonly matchRepository: Repository, + @InjectRepository(MatchPrediction) + private readonly matchPredictionRepository: Repository, + @InjectRepository(UserPreferences) + private readonly userPreferencesRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + ) { + this.startQueueProcessor(); + } + + async handleEventCreated(data: Record): Promise { + const eventId = Number(data.event_id); + const creator = this.readString(data, 'creator'); + const title = this.readString(data, 'title'); + + if (!eventId || !creator) { + this.logger.warn('EventCreated notification skipped: missing data'); + return; + } + + const shouldNotify = await this.shouldSendNotification( + creator, + NotificationType.EventCreated, + ); + if (!shouldNotify) return; + + await this.queueNotification({ + userAddress: creator, + type: NotificationType.EventCreated, + title: 'Event Created Successfully', + message: `Your event "${title || `Event #${eventId}`}" has been created successfully.`, + data: { event_id: eventId, title }, + }); + } + + async handleMatchAdded(data: Record): Promise { + const matchId = Number(data.match_id); + const eventId = Number(data.event_id); + const teamA = this.readString(data, 'team_a'); + const teamB = this.readString(data, 'team_b'); + + if (!matchId || !eventId) { + this.logger.warn('MatchAdded notification skipped: missing data'); + return; + } + + const event = await this.creatorEventRepository.findOne({ + where: { on_chain_event_id: eventId }, + }); + if (!event) { + this.logger.warn( + `MatchAdded notification skipped: event ${eventId} not found`, + ); + return; + } + + // Notify all participants of the event + const participants = await this.getEventParticipants(eventId); + const notifications = participants + .filter((addr) => addr !== event.creator_address) + .map((address) => ({ + userAddress: address, + type: NotificationType.MatchAdded, + title: 'New Match Added', + message: `A new match between ${teamA} and ${teamB} has been added to your event.`, + data: { + match_id: matchId, + event_id: eventId, + team_a: teamA, + team_b: teamB, + }, + })); + + await this.queueBatchNotifications(notifications); + } + + async handleUserJoinedEvent(data: Record): Promise { + const eventId = Number(data.event_id); + const userAddress = this.readString(data, 'user_address'); + + if (!eventId || !userAddress) { + this.logger.warn('UserJoinedEvent notification skipped: missing data'); + return; + } + + const event = await this.creatorEventRepository.findOne({ + where: { on_chain_event_id: eventId }, + }); + if (!event) { + this.logger.warn( + `UserJoinedEvent notification skipped: event ${eventId} not found`, + ); + return; + } + + const shouldNotify = await this.shouldSendNotification( + event.creator_address, + NotificationType.MatchAdded, + ); + if (!shouldNotify) return; + + await this.queueNotification({ + userAddress: event.creator_address, + type: NotificationType.MatchAdded, + title: 'New Participant Joined', + message: `A new participant has joined your event "${event.title}".`, + data: { event_id: eventId, participant: userAddress }, + }); + } + + async handlePredictionSubmitted( + data: Record, + ): Promise { + const matchId = Number(data.match_id); + const predictor = this.readString(data, 'predictor'); + const predictedOutcome = this.readString(data, 'predicted_outcome'); + + if (!matchId || !predictor) { + this.logger.warn( + 'PredictionSubmitted notification skipped: missing data', + ); + return; + } + + const shouldNotify = await this.shouldSendNotification( + predictor, + NotificationType.PredictionSubmitted, + ); + if (!shouldNotify) return; + + await this.queueNotification({ + userAddress: predictor, + type: NotificationType.PredictionSubmitted, + title: 'Prediction Submitted', + message: `Your prediction for match #${matchId} has been submitted successfully.`, + data: { match_id: matchId, predicted_outcome: predictedOutcome }, + }); + } + + async handleMatchResultSubmitted( + data: Record, + ): Promise { + const matchId = Number(data.match_id); + const eventId = Number(data.event_id); + const winningTeam = Number(data.winning_team); + + if (!matchId) { + this.logger.warn( + 'MatchResultSubmitted notification skipped: missing data', + ); + return; + } + + const match = await this.matchRepository.findOne({ + where: { on_chain_match_id: matchId }, + relations: ['event'], + }); + if (!match) { + this.logger.warn( + `MatchResultSubmitted notification skipped: match ${matchId} not found`, + ); + return; + } + + // Get all predictors for this match + const predictions = await this.matchPredictionRepository.find({ + where: { match: { id: match.id } }, + relations: ['user'], + }); + + const notifications = predictions.map((prediction) => ({ + userAddress: prediction.user.stellar_address, + type: NotificationType.MatchResolved, + title: 'Match Result Submitted', + message: `The result for match between ${match.team_a} and ${match.team_b} has been submitted.`, + data: { + match_id: matchId, + event_id: eventId || match.event.on_chain_event_id, + winning_team: winningTeam, + }, + })); + + await this.queueBatchNotifications(notifications); + } + + async handleWinnersVerified(data: Record): Promise { + const eventId = Number(data.event_id); + + if (!eventId) { + this.logger.warn( + 'WinnersVerified notification skipped: missing event_id', + ); + return; + } + + const event = await this.creatorEventRepository.findOne({ + where: { on_chain_event_id: eventId }, + }); + if (!event) { + this.logger.warn( + `WinnersVerified notification skipped: event ${eventId} not found`, + ); + return; + } + + // Get all predictions for this event to find winners + const matches = await this.matchRepository.find({ + where: { event: { id: event.id } }, + relations: ['predictions', 'predictions.user'], + }); + + const winnerAddresses = new Set(); + for (const match of matches) { + for (const prediction of match.predictions) { + if (prediction.is_correct) { + winnerAddresses.add(prediction.user.stellar_address); + } + } + } + + const notifications = Array.from(winnerAddresses).map((address) => ({ + userAddress: address, + type: NotificationType.WinnerVerified, + title: 'Congratulations! You Won!', + message: `You have been verified as a winner for event "${event.title}".`, + data: { event_id: eventId, event_title: event.title }, + })); + + await this.queueBatchNotifications(notifications); + } + + async handleEventCancelled(data: Record): Promise { + const eventId = Number(data.event_id); + + if (!eventId) { + this.logger.warn('EventCancelled notification skipped: missing event_id'); + return; + } + + const event = await this.creatorEventRepository.findOne({ + where: { on_chain_event_id: eventId }, + }); + if (!event) { + this.logger.warn( + `EventCancelled notification skipped: event ${eventId} not found`, + ); + return; + } + + // Notify all participants + const participants = await this.getEventParticipants(eventId); + const notifications = participants.map((address) => ({ + userAddress: address, + type: NotificationType.EventCancelled, + title: 'Event Cancelled', + message: `The event "${event.title}" has been cancelled.`, + data: { event_id: eventId, event_title: event.title }, + })); + + await this.queueBatchNotifications(notifications); + } + + // eslint-disable-next-line @typescript-eslint/require-await + private async queueNotification(notification: { + userAddress: string; + type: NotificationType; + title: string; + message: string; + data?: Record; + }): Promise { + this.notificationQueue.push({ notifications: [notification] }); + } + + // eslint-disable-next-line @typescript-eslint/require-await + private async queueBatchNotifications( + notifications: Array<{ + userAddress: string; + type: NotificationType; + title: string; + message: string; + data?: Record; + }>, + ): Promise { + // Split into batches + for (let i = 0; i < notifications.length; i += this.BATCH_SIZE) { + const batch = notifications.slice(i, i + this.BATCH_SIZE); + this.notificationQueue.push({ notifications: batch }); + } + } + + private startQueueProcessor(): void { + setInterval(() => { + void this.processQueue(); + }, this.FLUSH_INTERVAL); + } + + private async processQueue(): Promise { + if (this.isProcessing || this.notificationQueue.length === 0) { + return; + } + + this.isProcessing = true; + + try { + const batch = this.notificationQueue.shift(); + if (!batch) return; + + await this.createNotificationsBatch(batch.notifications); + } catch (error) { + this.logger.error('Error processing notification queue', error); + } finally { + this.isProcessing = false; + } + } + + private async createNotificationsBatch( + notifications: Array<{ + userAddress: string; + type: NotificationType; + title: string; + message: string; + data?: Record; + }>, + ): Promise { + if (notifications.length === 0) return; + + const entities = notifications.map((n) => + this.notificationsRepository.create({ + user_address: n.userAddress, + type: n.type, + title: n.title, + message: n.message, + data: n.data ?? null, + }), + ); + + await this.notificationsRepository.save(entities); + this.logger.log(`Batch created ${notifications.length} notifications`); + } + + private async shouldSendNotification( + userAddress: string, + notificationType: NotificationType, + ): Promise { + try { + const user = await this.userRepository.findOne({ + where: { stellar_address: userAddress }, + relations: ['preferences'], + }); + + if (!user || !user.preferences) { + return true; // Default to sending if no preferences found + } + + const prefs = user.preferences; + + // Check specific notification type preferences + switch (notificationType) { + case NotificationType.EventCreated: + return prefs.event_created_notifications !== false; + case NotificationType.MatchAdded: + return prefs.match_added_notifications !== false; + case NotificationType.PredictionSubmitted: + return prefs.prediction_submitted_notifications !== false; + case NotificationType.MatchResolved: + return prefs.match_resolved_notifications !== false; + case NotificationType.WinnerVerified: + return prefs.winner_verified_notifications !== false; + case NotificationType.EventCancelled: + return prefs.event_cancelled_notifications !== false; + default: + return true; + } + } catch (error) { + this.logger.error( + `Error checking notification preferences for ${userAddress}`, + error, + ); + return true; // Default to sending on error + } + } + + private async getEventParticipants(eventId: number): Promise { + const event = await this.creatorEventRepository.findOne({ + where: { on_chain_event_id: eventId }, + relations: ['matches', 'matches.predictions', 'matches.predictions.user'], + }); + + if (!event) return []; + + const participants = new Set(); + participants.add(event.creator_address); + + for (const match of event.matches) { + for (const prediction of match.predictions) { + participants.add(prediction.user.stellar_address); + } + } + + return Array.from(participants); + } + + private readString(data: Record, key: string): string { + const val = data[key]; + if (val === null || val === undefined) return ''; + if (typeof val === 'string') return val; + if (typeof val === 'number') return String(val); + return ''; + } + + async flushQueue(): Promise { + while (this.notificationQueue.length > 0) { + await this.processQueue(); + } + } +} diff --git a/backend/src/notifications/notifications.module.ts b/backend/src/notifications/notifications.module.ts index 90eb81742..b3cf000ad 100644 --- a/backend/src/notifications/notifications.module.ts +++ b/backend/src/notifications/notifications.module.ts @@ -4,17 +4,28 @@ import { Notification } from './entities/notification.entity'; import { NotificationsService } from './notifications.service'; import { NotificationsController } from './notifications.controller'; import { EmailService } from './email.service'; +import { NotificationGeneratorService } from './notification-generator.service'; import { UsersModule } from '../users/users.module'; import { User } from '../users/entities/user.entity'; import { UserPreferences } from '../users/entities/user-preferences.entity'; +import { CreatorEvent } from '../matches/entities/creator-event.entity'; +import { Match } from '../matches/entities/match.entity'; +import { MatchPrediction } from '../matches/entities/match-prediction.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([Notification, User, UserPreferences]), + TypeOrmModule.forFeature([ + Notification, + User, + UserPreferences, + CreatorEvent, + Match, + MatchPrediction, + ]), UsersModule, ], controllers: [NotificationsController], - providers: [NotificationsService, EmailService], - exports: [NotificationsService, EmailService], + providers: [NotificationsService, EmailService, NotificationGeneratorService], + exports: [NotificationsService, EmailService, NotificationGeneratorService], }) export class NotificationsModule {} diff --git a/backend/src/oracle/dto/submission-history.dto.ts b/backend/src/oracle/dto/submission-history.dto.ts new file mode 100644 index 000000000..49524711d --- /dev/null +++ b/backend/src/oracle/dto/submission-history.dto.ts @@ -0,0 +1,155 @@ +import { + IsOptional, + IsInt, + IsEnum, + IsString, + Min, + Max, + IsDateString, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger'; +import { SubmissionStatus } from '../entities/oracle-submission.entity'; + +export class GetSubmissionsQueryDto { + @ApiPropertyOptional({ description: 'Page number', default: 1, minimum: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ + description: 'Results per page (max 100)', + default: 20, + maximum: 100, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; + + @ApiPropertyOptional({ + description: 'Filter by submission status', + enum: SubmissionStatus, + }) + @IsOptional() + @IsEnum(SubmissionStatus) + status?: SubmissionStatus; + + @ApiPropertyOptional({ + description: 'Filter by date from (ISO 8601 format)', + example: '2024-01-01T00:00:00Z', + }) + @IsOptional() + @IsDateString() + dateFrom?: string; + + @ApiPropertyOptional({ + description: 'Filter by date to (ISO 8601 format)', + example: '2024-12-31T23:59:59Z', + }) + @IsOptional() + @IsDateString() + dateTo?: string; + + @ApiPropertyOptional({ + description: 'Filter by match ID', + example: '123', + }) + @IsOptional() + @IsString() + matchId?: string; +} + +export class SubmissionResponse { + @ApiProperty() + id: string; + + @ApiProperty() + match_id: string; + + @ApiProperty() + team_a: string; + + @ApiProperty() + team_b: string; + + @ApiProperty() + winning_team: string; + + @ApiProperty() + confidence_score: number; + + @ApiProperty() + data_source: string; + + @ApiProperty() + result_timestamp: string; + + @ApiPropertyOptional() + metadata?: Record; + + @ApiProperty() + status: SubmissionStatus; + + @ApiPropertyOptional() + transaction_hash?: string; + + @ApiPropertyOptional() + submitted_at?: string; + + @ApiPropertyOptional() + error_message?: string; + + @ApiProperty() + retry_count: number; + + @ApiPropertyOptional() + submission_time_ms?: number; + + @ApiProperty() + created_at: string; +} + +export class SubmissionStatistics { + @ApiProperty() + total_submissions: number; + + @ApiProperty() + successful_submissions: number; + + @ApiProperty() + failed_submissions: number; + + @ApiProperty() + pending_submissions: number; + + @ApiProperty() + success_rate: number; + + @ApiProperty() + average_submission_time_ms: number; + + @ApiProperty() + submissions_by_status: Record; +} + +export class PaginatedSubmissionsResponse { + @ApiProperty({ type: [SubmissionResponse] }) + data: SubmissionResponse[]; + + @ApiProperty() + total: number; + + @ApiProperty() + page: number; + + @ApiProperty() + limit: number; + + @ApiProperty({ type: SubmissionStatistics }) + statistics: SubmissionStatistics; +} diff --git a/backend/src/oracle/dto/webhook-match-result.dto.ts b/backend/src/oracle/dto/webhook-match-result.dto.ts new file mode 100644 index 000000000..225340f35 --- /dev/null +++ b/backend/src/oracle/dto/webhook-match-result.dto.ts @@ -0,0 +1,77 @@ +import { IsString, IsInt, IsOptional, IsEnum, Min, Max } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export enum WinningTeam { + TEAM_A = 'TEAM_A', + TEAM_B = 'TEAM_B', + DRAW = 'DRAW', +} + +export class WebhookMatchResultDto { + @ApiProperty({ + description: 'The on-chain match ID', + example: '123', + }) + @IsString() + match_id: string; + + @ApiProperty({ + description: 'The winning team', + enum: WinningTeam, + example: WinningTeam.TEAM_A, + }) + @IsEnum(WinningTeam) + winning_team: WinningTeam; + + @ApiProperty({ + description: 'Confidence score (0-100)', + example: 95, + minimum: 0, + maximum: 100, + }) + @IsInt() + @Min(0) + @Max(100) + confidence_score: number; + + @ApiProperty({ + description: 'Data source URL or identifier', + example: 'https://api.sports-data.com/matches/123', + }) + @IsString() + data_source: string; + + @ApiProperty({ + description: 'Timestamp of the result', + example: '2024-01-15T10:30:00Z', + }) + @IsString() + timestamp: string; + + @ApiPropertyOptional({ + description: 'Additional metadata about the result', + example: { final_score: '2-1', referee: 'John Doe' }, + }) + @IsOptional() + metadata?: Record; +} + +export class WebhookResponseDto { + @ApiProperty({ + description: 'Job ID for tracking the submission', + example: 'job_abc123xyz', + }) + job_id: string; + + @ApiProperty({ + description: 'Status of the webhook request', + example: 'accepted', + }) + status: string; + + @ApiProperty({ + description: 'Message describing the result', + example: 'Match result queued for submission', + }) + message: string; +} diff --git a/backend/src/oracle/entities/oracle-submission.entity.ts b/backend/src/oracle/entities/oracle-submission.entity.ts new file mode 100644 index 000000000..c24a73b2a --- /dev/null +++ b/backend/src/oracle/entities/oracle-submission.entity.ts @@ -0,0 +1,100 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export enum SubmissionStatus { + PENDING = 'pending', + SUBMITTED = 'submitted', + FAILED = 'failed', +} + +export enum WinningTeam { + TEAM_A = 'TEAM_A', + TEAM_B = 'TEAM_B', + DRAW = 'DRAW', +} + +@Entity('oracle_submissions') +@Index(['match_id']) +@Index(['status']) +@Index(['created_at']) +@Index(['match_id', 'status']) +@Index(['created_at', 'status']) +export class OracleSubmission { + @PrimaryGeneratedColumn('uuid') + @ApiProperty() + id: string; + + @Column({ type: 'varchar', length: 255 }) + @Index() + @ApiProperty() + match_id: string; + + @Column({ type: 'varchar', length: 100 }) + @ApiProperty() + team_a: string; + + @Column({ type: 'varchar', length: 100 }) + @ApiProperty() + team_b: string; + + @Column({ + type: 'enum', + enum: WinningTeam, + }) + @ApiProperty() + winning_team: WinningTeam; + + @Column({ type: 'int' }) + @ApiProperty() + confidence_score: number; + + @Column({ type: 'varchar', length: 500 }) + @ApiProperty() + data_source: string; + + @Column({ type: 'timestamptz' }) + @ApiProperty() + result_timestamp: Date; + + @Column({ type: 'jsonb', nullable: true }) + @ApiPropertyOptional() + metadata?: Record; + + @Column({ + type: 'enum', + enum: SubmissionStatus, + default: SubmissionStatus.PENDING, + }) + @ApiProperty() + status: SubmissionStatus; + + @Column({ type: 'varchar', length: 255, nullable: true }) + @ApiPropertyOptional() + transaction_hash?: string; + + @Column({ type: 'timestamptz', nullable: true }) + @ApiPropertyOptional() + submitted_at?: Date; + + @Column({ type: 'text', nullable: true }) + @ApiPropertyOptional() + error_message?: string; + + @Column({ type: 'int', default: 0 }) + @ApiProperty() + retry_count: number; + + @Column({ type: 'bigint', nullable: true }) + @ApiPropertyOptional() + submission_time_ms?: number; + + @CreateDateColumn({ type: 'timestamptz' }) + @ApiProperty() + created_at: Date; +} diff --git a/backend/src/oracle/guards/webhook-auth.guard.spec.ts b/backend/src/oracle/guards/webhook-auth.guard.spec.ts new file mode 100644 index 000000000..f15cc2a67 --- /dev/null +++ b/backend/src/oracle/guards/webhook-auth.guard.spec.ts @@ -0,0 +1,165 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { WebhookAuthGuard } from './webhook-auth.guard'; +import { ConfigService } from '@nestjs/config'; +import { UnauthorizedException } from '@nestjs/common'; +import { ExecutionContext } from '@nestjs/common'; +import { createHmac } from 'crypto'; + +function generateSignature( + body: string, + timestamp: string, + secret: string, +): string { + const message = `${timestamp}.${body}`; + const hmac = createHmac('sha256', secret); + hmac.update(message); + return hmac.digest('hex'); +} + +describe('WebhookAuthGuard', () => { + let guard: WebhookAuthGuard; + let configService: ConfigService; + + const mockExecutionContext = (headers: Record) => { + const context = { + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn().mockReturnValue({ + headers, + body: { test: 'data' }, + }), + }), + } as unknown as ExecutionContext; + return context; + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WebhookAuthGuard, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => { + if (key === 'WEBHOOK_SECRET') return 'test-secret-key'; + return null; + }), + }, + }, + ], + }).compile(); + + guard = module.get(WebhookAuthGuard); + configService = module.get(ConfigService); + }); + + it('should be defined', () => { + expect(guard).toBeDefined(); + }); + + describe('authentication', () => { + it('should allow valid signature', () => { + const timestamp = Math.floor(Date.now() / 1000).toString(); + const body = JSON.stringify({ test: 'data' }); + const signature = generateSignature(body, timestamp, 'test-secret-key'); + + const context = mockExecutionContext({ + 'x-webhook-signature': signature, + 'x-webhook-timestamp': timestamp, + }); + + const result = guard.canActivate(context); + expect(result).toBe(true); + }); + + it('should reject missing signature', () => { + const timestamp = Math.floor(Date.now() / 1000).toString(); + const context = mockExecutionContext({ + 'x-webhook-timestamp': timestamp, + }); + + expect(() => guard.canActivate(context)).toThrow(UnauthorizedException); + }); + + it('should reject missing timestamp', () => { + const context = mockExecutionContext({ + 'x-webhook-signature': 'some-signature', + }); + + expect(() => guard.canActivate(context)).toThrow(UnauthorizedException); + }); + + it('should reject invalid signature', () => { + const timestamp = Math.floor(Date.now() / 1000).toString(); + const context = mockExecutionContext({ + 'x-webhook-signature': 'invalid-signature', + 'x-webhook-timestamp': timestamp, + }); + + expect(() => guard.canActivate(context)).toThrow(UnauthorizedException); + }); + + it('should reject old timestamp (replay attack)', () => { + const oldTimestamp = Math.floor((Date.now() - 400000) / 1000).toString(); // 6+ minutes ago + const body = JSON.stringify({ test: 'data' }); + const signature = generateSignature( + body, + oldTimestamp, + 'test-secret-key', + ); + + const context = mockExecutionContext({ + 'x-webhook-signature': signature, + 'x-webhook-timestamp': oldTimestamp, + }); + + expect(() => guard.canActivate(context)).toThrow(UnauthorizedException); + }); + + it('should reject future timestamp', () => { + const futureTimestamp = Math.floor( + (Date.now() + 400000) / 1000, + ).toString(); // 6+ minutes in future + const body = JSON.stringify({ test: 'data' }); + const signature = generateSignature( + body, + futureTimestamp, + 'test-secret-key', + ); + + const context = mockExecutionContext({ + 'x-webhook-signature': signature, + 'x-webhook-timestamp': futureTimestamp, + }); + + expect(() => guard.canActivate(context)).toThrow(UnauthorizedException); + }); + + it('should reject when webhook secret not configured', () => { + const module = Test.createTestingModule({ + providers: [ + WebhookAuthGuard, + { + provide: ConfigService, + useValue: { + get: jest.fn(() => null), + }, + }, + ], + }); + + const testBed = module; + const guardWithoutSecret = new WebhookAuthGuard({ + get: jest.fn(() => null), + } as any); + const timestamp = Math.floor(Date.now() / 1000).toString(); + const context = mockExecutionContext({ + 'x-webhook-signature': 'some-signature', + 'x-webhook-timestamp': timestamp, + }); + + expect(() => guardWithoutSecret.canActivate(context)).toThrow( + UnauthorizedException, + ); + }); + }); +}); diff --git a/backend/src/oracle/guards/webhook-auth.guard.ts b/backend/src/oracle/guards/webhook-auth.guard.ts new file mode 100644 index 000000000..a46145175 --- /dev/null +++ b/backend/src/oracle/guards/webhook-auth.guard.ts @@ -0,0 +1,88 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + UnauthorizedException, + Logger, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createHmac, timingSafeEqual } from 'crypto'; +import type { Request } from 'express'; + +@Injectable() +export class WebhookAuthGuard implements CanActivate { + private readonly logger = new Logger(WebhookAuthGuard.name); + private readonly webhookSecret: string; + + constructor(private readonly configService: ConfigService) { + this.webhookSecret = this.configService.get('WEBHOOK_SECRET') || ''; + if (!this.webhookSecret) { + this.logger.warn('WEBHOOK_SECRET not configured in environment'); + } + } + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const signature = request.headers['x-webhook-signature'] as string; + const timestamp = request.headers['x-webhook-timestamp'] as string; + + // Check if webhook secret is configured + if (!this.webhookSecret) { + this.logger.error('Webhook secret not configured'); + throw new UnauthorizedException('Webhook authentication not configured'); + } + + // Check for required headers + if (!signature) { + this.logger.warn('Missing x-webhook-signature header'); + throw new UnauthorizedException('Missing signature header'); + } + + if (!timestamp) { + this.logger.warn('Missing x-webhook-timestamp header'); + throw new UnauthorizedException('Missing timestamp header'); + } + + // Check timestamp to prevent replay attacks (5 minute window) + const now = Math.floor(Date.now() / 1000); + const webhookTimestamp = parseInt(timestamp, 10); + const timeDiff = Math.abs(now - webhookTimestamp); + + if (timeDiff > 300) { + this.logger.warn( + `Webhook timestamp too old or in future: ${timeDiff}s difference`, + ); + throw new UnauthorizedException('Invalid timestamp'); + } + + // Verify signature + const body = JSON.stringify(request.body); + const expectedSignature = this.generateSignature(body, timestamp); + + // Use timing-safe comparison to prevent timing attacks + const signatureBuffer = Buffer.from(signature, 'hex'); + const expectedBuffer = Buffer.from(expectedSignature, 'hex'); + + if (signatureBuffer.length !== expectedBuffer.length) { + this.logger.warn('Signature length mismatch'); + throw new UnauthorizedException('Invalid signature'); + } + + const isValid = timingSafeEqual(signatureBuffer, expectedBuffer); + + if (!isValid) { + this.logger.warn('Invalid webhook signature'); + throw new UnauthorizedException('Invalid signature'); + } + + this.logger.debug('Webhook authentication successful'); + return true; + } + + private generateSignature(payload: string, timestamp: string): string { + const message = `${timestamp}.${payload}`; + const hmac = createHmac('sha256', this.webhookSecret); + hmac.update(message); + return hmac.digest('hex'); + } +} diff --git a/backend/src/oracle/oracle.controller.ts b/backend/src/oracle/oracle.controller.ts index 5189f9636..af9f44fa4 100644 --- a/backend/src/oracle/oracle.controller.ts +++ b/backend/src/oracle/oracle.controller.ts @@ -1,21 +1,46 @@ -import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Query, + UseGuards, + Body, + HttpCode, + HttpStatus, +} from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiSecurity, + ApiBody, } from '@nestjs/swagger'; import { OracleService } from './oracle.service'; import { OracleAuthGuard } from './guards/oracle-auth.guard'; +import { WebhookAuthGuard } from './guards/webhook-auth.guard'; import { ListPendingMatchesQueryDto, PaginatedPendingMatchesResponse, } from './dto/list-pending-matches-query.dto'; +import { + WebhookMatchResultDto, + WebhookResponseDto, +} from './dto/webhook-match-result.dto'; +import { WebhookService } from './webhook.service'; +import { SubmissionHistoryService } from './submission-history.service'; +import { + GetSubmissionsQueryDto, + PaginatedSubmissionsResponse, +} from './dto/submission-history.dto'; @ApiTags('Oracle') @Controller('oracle') export class OracleController { - constructor(private readonly oracleService: OracleService) {} + constructor( + private readonly oracleService: OracleService, + private readonly webhookService: WebhookService, + private readonly submissionHistoryService: SubmissionHistoryService, + ) {} @Get('pending-matches') @UseGuards(OracleAuthGuard) @@ -31,4 +56,42 @@ export class OracleController { ): Promise { return this.oracleService.getPendingMatches(query); } + + @Post('webhooks/match-result') + @UseGuards(WebhookAuthGuard) + @HttpCode(HttpStatus.ACCEPTED) + @ApiSecurity('webhook-signature') + @ApiOperation({ summary: 'Submit match result via webhook' }) + @ApiBody({ type: WebhookMatchResultDto }) + @ApiResponse({ + status: 202, + description: 'Match result accepted and queued for submission', + type: WebhookResponseDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized - invalid signature' }) + @ApiResponse({ status: 404, description: 'Match not found' }) + @ApiResponse({ + status: 409, + description: 'Match already resolved or not started', + }) + async submitMatchResult( + @Body() dto: WebhookMatchResultDto, + ): Promise { + return this.webhookService.processMatchResult(dto); + } + + @Get('submissions') + @UseGuards(OracleAuthGuard) + @ApiSecurity('api-key') + @ApiOperation({ summary: 'Get oracle submission history and statistics' }) + @ApiResponse({ + status: 200, + description: 'Paginated submission history with statistics', + }) + @ApiResponse({ status: 401, description: 'Unauthorized - invalid API key' }) + async getSubmissions( + @Query() query: GetSubmissionsQueryDto, + ): Promise { + return this.submissionHistoryService.getSubmissions(query); + } } diff --git a/backend/src/oracle/oracle.module.ts b/backend/src/oracle/oracle.module.ts index 09aa91d80..423e7dcd8 100644 --- a/backend/src/oracle/oracle.module.ts +++ b/backend/src/oracle/oracle.module.ts @@ -1,14 +1,31 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ScheduleModule } from '@nestjs/schedule'; import { CreatorEventMatch } from '../creator-events/entities/creator-event-match.entity'; import { CreatorEvent } from '../creator-events/entities/creator-event.entity'; import { OracleService } from './oracle.service'; import { OracleController } from './oracle.controller'; +import { WebhookService } from './webhook.service'; +import { WebhookAuthGuard } from './guards/webhook-auth.guard'; +import { SubmissionHistoryService } from './submission-history.service'; +import { OracleSubmission } from './entities/oracle-submission.entity'; @Module({ - imports: [TypeOrmModule.forFeature([CreatorEventMatch, CreatorEvent])], + imports: [ + TypeOrmModule.forFeature([ + CreatorEventMatch, + CreatorEvent, + OracleSubmission, + ]), + ScheduleModule.forRoot(), + ], controllers: [OracleController], - providers: [OracleService], - exports: [OracleService], + providers: [ + OracleService, + WebhookService, + WebhookAuthGuard, + SubmissionHistoryService, + ], + exports: [OracleService, WebhookService, SubmissionHistoryService], }) export class OracleModule {} diff --git a/backend/src/oracle/submission-history.service.spec.ts b/backend/src/oracle/submission-history.service.spec.ts new file mode 100644 index 000000000..8cce08b34 --- /dev/null +++ b/backend/src/oracle/submission-history.service.spec.ts @@ -0,0 +1,224 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { SubmissionHistoryService } from './submission-history.service'; +import { + OracleSubmission, + SubmissionStatus, +} from './entities/oracle-submission.entity'; +import { GetSubmissionsQueryDto } from './dto/submission-history.dto'; + +describe('SubmissionHistoryService', () => { + let service: SubmissionHistoryService; + let submissionRepository: Repository; + + const mockSubmission: OracleSubmission = { + id: 'sub-1', + match_id: '123', + team_a: 'Team A', + team_b: 'Team B', + winning_team: 'TEAM_A' as any, + confidence_score: 95, + data_source: 'https://api.example.com', + result_timestamp: new Date(), + metadata: null, + status: SubmissionStatus.SUBMITTED, + transaction_hash: 'tx_abc123', + submitted_at: new Date(), + error_message: null, + retry_count: 0, + submission_time_ms: 150, + created_at: new Date(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SubmissionHistoryService, + { + provide: getRepositoryToken(OracleSubmission), + useClass: Repository, + }, + ], + }).compile(); + + service = module.get(SubmissionHistoryService); + submissionRepository = module.get>( + getRepositoryToken(OracleSubmission), + ); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getSubmissions', () => { + it('should return paginated submissions with statistics', async () => { + const query: GetSubmissionsQueryDto = { + page: 1, + limit: 20, + }; + + jest + .spyOn(submissionRepository, 'findAndCount') + .mockResolvedValue([[mockSubmission], 1]); + jest + .spyOn(submissionRepository, 'find') + .mockResolvedValue([mockSubmission]); + + const result = await service.getSubmissions(query); + + expect(result).toHaveProperty('data'); + expect(result).toHaveProperty('total'); + expect(result).toHaveProperty('page'); + expect(result).toHaveProperty('limit'); + expect(result).toHaveProperty('statistics'); + expect(result.data).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.page).toBe(1); + expect(result.limit).toBe(20); + }); + + it('should filter by status', async () => { + const query: GetSubmissionsQueryDto = { + page: 1, + limit: 20, + status: SubmissionStatus.SUBMITTED, + }; + + jest + .spyOn(submissionRepository, 'findAndCount') + .mockResolvedValue([[mockSubmission], 1]); + jest + .spyOn(submissionRepository, 'find') + .mockResolvedValue([mockSubmission]); + + const result = await service.getSubmissions(query); + + expect(submissionRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: SubmissionStatus.SUBMITTED, + }), + }), + ); + }); + + it('should filter by match ID', async () => { + const query: GetSubmissionsQueryDto = { + page: 1, + limit: 20, + matchId: '123', + }; + + jest + .spyOn(submissionRepository, 'findAndCount') + .mockResolvedValue([[mockSubmission], 1]); + jest + .spyOn(submissionRepository, 'find') + .mockResolvedValue([mockSubmission]); + + const result = await service.getSubmissions(query); + + expect(submissionRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + match_id: '123', + }), + }), + ); + }); + + it('should filter by date range', async () => { + const query: GetSubmissionsQueryDto = { + page: 1, + limit: 20, + dateFrom: '2024-01-01T00:00:00Z', + dateTo: '2024-12-31T23:59:59Z', + }; + + jest + .spyOn(submissionRepository, 'findAndCount') + .mockResolvedValue([[mockSubmission], 1]); + jest + .spyOn(submissionRepository, 'find') + .mockResolvedValue([mockSubmission]); + + const result = await service.getSubmissions(query); + + expect(submissionRepository.findAndCount).toHaveBeenCalled(); + }); + + it('should calculate statistics correctly', async () => { + const query: GetSubmissionsQueryDto = { + page: 1, + limit: 20, + }; + + const submissions = [ + { ...mockSubmission, status: SubmissionStatus.SUBMITTED }, + { ...mockSubmission, id: 'sub-2', status: SubmissionStatus.FAILED }, + { ...mockSubmission, id: 'sub-3', status: SubmissionStatus.PENDING }, + ]; + + jest + .spyOn(submissionRepository, 'findAndCount') + .mockResolvedValue([submissions, 3]); + jest.spyOn(submissionRepository, 'find').mockResolvedValue(submissions); + + const result = await service.getSubmissions(query); + + expect(result.statistics).toHaveProperty('total_submissions', 3); + expect(result.statistics).toHaveProperty('successful_submissions', 1); + expect(result.statistics).toHaveProperty('failed_submissions', 1); + expect(result.statistics).toHaveProperty('pending_submissions', 1); + expect(result.statistics).toHaveProperty('success_rate'); + expect(result.statistics).toHaveProperty('average_submission_time_ms'); + expect(result.statistics).toHaveProperty('submissions_by_status'); + }); + + it('should limit results to 100 per page', async () => { + const query: GetSubmissionsQueryDto = { + page: 1, + limit: 200, // Request more than max + }; + + jest + .spyOn(submissionRepository, 'findAndCount') + .mockResolvedValue([[mockSubmission], 1]); + jest + .spyOn(submissionRepository, 'find') + .mockResolvedValue([mockSubmission]); + + const result = await service.getSubmissions(query); + + expect(result.limit).toBe(100); + expect(submissionRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + take: 100, + }), + ); + }); + }); + + describe('getSubmissionById', () => { + it('should return submission by ID', async () => { + jest + .spyOn(submissionRepository, 'findOne') + .mockResolvedValue(mockSubmission); + + const result = await service.getSubmissionById('sub-1'); + + expect(result).not.toBeNull(); + expect(result?.id).toBe('sub-1'); + }); + + it('should return null for non-existent submission', async () => { + jest.spyOn(submissionRepository, 'findOne').mockResolvedValue(null); + + const result = await service.getSubmissionById('non-existent'); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/backend/src/oracle/submission-history.service.ts b/backend/src/oracle/submission-history.service.ts new file mode 100644 index 000000000..98ae494a7 --- /dev/null +++ b/backend/src/oracle/submission-history.service.ts @@ -0,0 +1,172 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, MoreThanOrEqual, LessThanOrEqual, And } from 'typeorm'; +import { + OracleSubmission, + SubmissionStatus, +} from './entities/oracle-submission.entity'; +import { + GetSubmissionsQueryDto, + PaginatedSubmissionsResponse, + SubmissionResponse, + SubmissionStatistics, +} from './dto/submission-history.dto'; + +@Injectable() +export class SubmissionHistoryService { + private readonly logger = new Logger(SubmissionHistoryService.name); + + constructor( + @InjectRepository(OracleSubmission) + private readonly submissionRepository: Repository, + ) {} + + async getSubmissions( + query: GetSubmissionsQueryDto, + ): Promise { + const page = query.page ?? 1; + const limit = Math.min(query.limit ?? 20, 100); + const skip = (page - 1) * limit; + + const where = this.buildWhereClause(query); + + const [submissions, total] = await this.submissionRepository.findAndCount({ + where, + order: { created_at: 'DESC' }, + skip, + take: limit, + }); + + const statistics = await this.calculateStatistics(where); + + return { + data: submissions.map((s) => this.mapToResponse(s)), + total, + page, + limit, + statistics, + }; + } + + private buildWhereClause(query: GetSubmissionsQueryDto): any { + const conditions: any[] = []; + + if (query.status) { + conditions.push({ status: query.status }); + } + + if (query.matchId) { + conditions.push({ match_id: query.matchId }); + } + + if (query.dateFrom || query.dateTo) { + const dateCondition: any = {}; + if (query.dateFrom) { + dateCondition.created_at = MoreThanOrEqual(new Date(query.dateFrom)); + } + if (query.dateTo) { + if (dateCondition.created_at) { + dateCondition.created_at = And( + MoreThanOrEqual(new Date(query.dateFrom!)), + LessThanOrEqual(new Date(query.dateTo)), + ); + } else { + dateCondition.created_at = LessThanOrEqual(new Date(query.dateTo)); + } + } + conditions.push(dateCondition); + } + + return conditions.length > 0 + ? conditions.length === 1 + ? conditions[0] + : // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + And(...conditions) + : {}; + } + + private async calculateStatistics(where: any): Promise { + const allSubmissions = await this.submissionRepository.find({ + where, + }); + + const total = allSubmissions.length; + + const successful = allSubmissions.filter( + (s) => s.status === SubmissionStatus.SUBMITTED, + ).length; + + const failed = allSubmissions.filter( + (s) => s.status === SubmissionStatus.FAILED, + ).length; + + const pending = allSubmissions.filter( + (s) => s.status === SubmissionStatus.PENDING, + ).length; + + const successRate = total > 0 ? (successful / total) * 100 : 0; + + const successfulSubmissions = allSubmissions.filter( + (s) => s.status === SubmissionStatus.SUBMITTED && s.submission_time_ms, + ); + const averageSubmissionTime = + successfulSubmissions.length > 0 + ? successfulSubmissions.reduce( + (sum, s) => sum + (s.submission_time_ms || 0), + 0, + ) / successfulSubmissions.length + : 0; + + const submissionsByStatus = { + [SubmissionStatus.PENDING]: pending, + [SubmissionStatus.SUBMITTED]: successful, + [SubmissionStatus.FAILED]: failed, + }; + + return { + total_submissions: total, + successful_submissions: successful, + failed_submissions: failed, + pending_submissions: pending, + success_rate: Math.round(successRate * 100) / 100, + average_submission_time_ms: Math.round(averageSubmissionTime), + + submissions_by_status: submissionsByStatus, + }; + } + + private mapToResponse(submission: OracleSubmission): SubmissionResponse { + return { + id: submission.id, + match_id: submission.match_id, + team_a: submission.team_a, + team_b: submission.team_b, + winning_team: submission.winning_team, + confidence_score: submission.confidence_score, + data_source: submission.data_source, + result_timestamp: submission.result_timestamp.toISOString(), + metadata: submission.metadata, + status: submission.status, + transaction_hash: submission.transaction_hash, + submitted_at: submission.submitted_at?.toISOString(), + error_message: submission.error_message, + retry_count: submission.retry_count, + submission_time_ms: submission.submission_time_ms + ? Number(submission.submission_time_ms) + : undefined, + created_at: submission.created_at.toISOString(), + }; + } + + async getSubmissionById(id: string): Promise { + const submission = await this.submissionRepository.findOne({ + where: { id }, + }); + + if (!submission) { + return null; + } + + return this.mapToResponse(submission); + } +} diff --git a/backend/src/oracle/webhook.service.spec.ts b/backend/src/oracle/webhook.service.spec.ts new file mode 100644 index 000000000..c0e398da5 --- /dev/null +++ b/backend/src/oracle/webhook.service.spec.ts @@ -0,0 +1,259 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { WebhookService } from './webhook.service'; +import { CreatorEventMatch } from '../creator-events/entities/creator-event-match.entity'; +import { OracleSubmission } from './entities/oracle-submission.entity'; +import { ConfigService } from '@nestjs/config'; +import { + WebhookMatchResultDto, + WinningTeam, +} from './dto/webhook-match-result.dto'; +import { NotFoundException, ConflictException } from '@nestjs/common'; + +describe('WebhookService', () => { + let service: WebhookService; + + const mockMatch = { + id: 'match-1', + on_chain_match_id: '123', + team_a: 'Team A', + team_b: 'Team B', + match_time: new Date(Date.now() - 3600000), + result_submitted: false, + winning_team: null, + prediction_count: 10, + created_at: new Date(), + }; + + const mockMatchRepository = { + findOne: jest.fn().mockResolvedValue(mockMatch), + save: jest.fn().mockResolvedValue(mockMatch), + }; + + const mockSubmissionRepository = { + create: jest.fn().mockReturnValue({}), + save: jest.fn().mockResolvedValue({}), + findOne: jest.fn().mockResolvedValue(null), + find: jest.fn().mockResolvedValue([]), + findAndCount: jest.fn().mockResolvedValue([[], 0]), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WebhookService, + { + provide: getRepositoryToken(CreatorEventMatch), + useValue: mockMatchRepository, + }, + { + provide: getRepositoryToken(OracleSubmission), + useValue: mockSubmissionRepository, + }, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => { + if (key === 'WEBHOOK_SECRET') return 'test-secret'; + return null; + }), + }, + }, + ], + }).compile(); + + service = module.get(WebhookService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('processMatchResult', () => { + it('should accept valid match result and return job ID', async () => { + const dto: WebhookMatchResultDto = { + match_id: '123', + winning_team: WinningTeam.TEAM_A, + confidence_score: 95, + data_source: 'https://api.example.com/match/123', + timestamp: new Date().toISOString(), + }; + + jest + .spyOn(service as any, 'simulateOracleSubmission') + .mockResolvedValue(undefined); + + const result = await service.processMatchResult(dto); + + expect(result).toHaveProperty('job_id'); + expect(result.status).toBe('accepted'); + expect(result.message).toBe('Match result queued for submission'); + }); + + it('should throw NotFoundException if match does not exist', async () => { + const dto: WebhookMatchResultDto = { + match_id: '999', + winning_team: WinningTeam.TEAM_A, + confidence_score: 95, + data_source: 'https://api.example.com/match/999', + timestamp: new Date().toISOString(), + }; + + mockMatchRepository.findOne.mockResolvedValue(null); + + await expect(service.processMatchResult(dto)).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw ConflictException if match already resolved', async () => { + const resolvedMatch = { ...mockMatch, result_submitted: true }; + const dto: WebhookMatchResultDto = { + match_id: '123', + winning_team: WinningTeam.TEAM_A, + confidence_score: 95, + data_source: 'https://api.example.com/match/123', + timestamp: new Date().toISOString(), + }; + + mockMatchRepository.findOne.mockResolvedValue(resolvedMatch); + + await expect(service.processMatchResult(dto)).rejects.toThrow( + ConflictException, + ); + }); + + it('should throw ConflictException if match has not started', async () => { + const futureMatch = { + ...mockMatch, + match_time: new Date(Date.now() + 7200000), + }; + const dto: WebhookMatchResultDto = { + match_id: '123', + winning_team: WinningTeam.TEAM_A, + confidence_score: 95, + data_source: 'https://api.example.com/match/123', + timestamp: new Date().toISOString(), + }; + + mockMatchRepository.findOne.mockResolvedValue(futureMatch); + + await expect(service.processMatchResult(dto)).rejects.toThrow( + ConflictException, + ); + }); + + it('should accept match result if match time is within 1 hour buffer', async () => { + const futureMatch = { + ...mockMatch, + match_time: new Date(Date.now() + 1800000), + }; + const dto: WebhookMatchResultDto = { + match_id: '123', + winning_team: WinningTeam.TEAM_A, + confidence_score: 95, + data_source: 'https://api.example.com/match/123', + timestamp: new Date().toISOString(), + }; + + mockMatchRepository.findOne.mockResolvedValue(futureMatch); + jest + .spyOn(service as any, 'simulateOracleSubmission') + .mockResolvedValue(undefined); + + const result = await service.processMatchResult(dto); + + expect(result.status).toBe('accepted'); + }); + }); + + describe('retry logic', () => { + it('should retry failed submissions', async () => { + const dto: WebhookMatchResultDto = { + match_id: '123', + winning_team: WinningTeam.TEAM_A, + confidence_score: 95, + data_source: 'https://api.example.com/match/123', + timestamp: new Date().toISOString(), + }; + + jest + .spyOn(service as any, 'simulateOracleSubmission') + .mockRejectedValueOnce(new Error('Submission failed')) + .mockResolvedValue(undefined); + + const result = await service.processMatchResult(dto); + + expect(result.status).toBe('accepted'); + + const queueStatus = service.getQueueStatus(); + expect(queueStatus.total).toBeGreaterThan(0); + }); + + it('should remove job after max retries', async () => { + const dto: WebhookMatchResultDto = { + match_id: '123', + winning_team: WinningTeam.TEAM_A, + confidence_score: 95, + data_source: 'https://api.example.com/match/123', + timestamp: new Date().toISOString(), + }; + + jest + .spyOn(service as any, 'simulateOracleSubmission') + .mockRejectedValue(new Error('Submission failed')); + + await service.processMatchResult(dto); + + const jobId = service.getQueueStatus().jobs[0].id; + for (let i = 0; i < 4; i++) { + const job = service.getJobStatus(jobId); + if (job) { + await service['submitToOracle'](job); + } + } + + const queueStatus = service.getQueueStatus(); + expect(queueStatus.total).toBe(0); + }); + }); + + describe('queue status', () => { + it('should return queue status', () => { + const status = service.getQueueStatus(); + + expect(status).toHaveProperty('total'); + expect(status).toHaveProperty('pending'); + expect(status).toHaveProperty('retrying'); + expect(status).toHaveProperty('jobs'); + expect(Array.isArray(status.jobs)).toBe(true); + }); + + it('should return job status by ID', async () => { + const dto: WebhookMatchResultDto = { + match_id: '123', + winning_team: WinningTeam.TEAM_A, + confidence_score: 95, + data_source: 'https://api.example.com/match/123', + timestamp: new Date().toISOString(), + }; + + jest + .spyOn(service as any, 'submitToOracle') + .mockImplementation(async () => { + // Do nothing to keep the job in the queue + }); + + const result = await service.processMatchResult(dto); + const job = service.getJobStatus(result.job_id); + + expect(job).not.toBeNull(); + expect(job?.matchId).toBe('123'); + }); + + it('should return null for non-existent job ID', () => { + const job = service.getJobStatus('non-existent-job-id'); + expect(job).toBeNull(); + }); + }); +}); diff --git a/backend/src/oracle/webhook.service.ts b/backend/src/oracle/webhook.service.ts new file mode 100644 index 000000000..291f2da34 --- /dev/null +++ b/backend/src/oracle/webhook.service.ts @@ -0,0 +1,330 @@ +import { + Injectable, + Logger, + NotFoundException, + ConflictException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CreatorEventMatch } from '../creator-events/entities/creator-event-match.entity'; +import { + WebhookMatchResultDto, + WebhookResponseDto, + WinningTeam, +} from './dto/webhook-match-result.dto'; +import { ConfigService } from '@nestjs/config'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { + OracleSubmission, + SubmissionStatus, +} from './entities/oracle-submission.entity'; + +interface QueuedSubmission { + id: string; + matchId: string; + winningTeam: WinningTeam; + confidenceScore: number; + dataSource: string; + timestamp: string; + metadata?: Record; + retryCount: number; + createdAt: Date; + nextRetryAt: Date; + lastError?: string; +} + +@Injectable() +export class WebhookService { + private readonly logger = new Logger(WebhookService.name); + private readonly submissionQueue: Map = new Map(); + private readonly MAX_RETRIES = 3; + private readonly RETRY_DELAYS = [60000, 300000, 900000]; // 1min, 5min, 15min + + constructor( + @InjectRepository(CreatorEventMatch) + private readonly matchRepository: Repository, + @InjectRepository(OracleSubmission) + private readonly submissionRepository: Repository, + private readonly configService: ConfigService, + ) { + this.startRetryProcessor(); + } + + async processMatchResult( + dto: WebhookMatchResultDto, + ): Promise { + const jobId = this.generateJobId(); + + // Validate match exists + const match = await this.matchRepository.findOne({ + where: { on_chain_match_id: dto.match_id }, + }); + + if (!match) { + throw new NotFoundException(`Match with ID ${dto.match_id} not found`); + } + + // Validate match hasn't been resolved + if (match.result_submitted) { + throw new ConflictException( + `Match ${dto.match_id} has already been resolved`, + ); + } + + // Validate match time has passed (allow 1 hour buffer) + const matchTime = new Date(match.match_time); + const now = new Date(); + const timeDiff = now.getTime() - matchTime.getTime(); + const oneHour = 60 * 60 * 1000; + + if (timeDiff < -oneHour) { + throw new ConflictException(`Match ${dto.match_id} has not started yet`); + } + + // Queue for submission + const submission: QueuedSubmission = { + id: jobId, + matchId: dto.match_id, + winningTeam: dto.winning_team, + confidenceScore: dto.confidence_score, + dataSource: dto.data_source, + timestamp: dto.timestamp, + metadata: dto.metadata, + retryCount: 0, + createdAt: new Date(), + nextRetryAt: new Date(), + }; + + this.submissionQueue.set(jobId, submission); + + // Save to database for history tracking + await this.saveSubmissionToDatabase(submission, match); + + this.logger.log( + `Match result queued for submission: job_id=${jobId}, match_id=${dto.match_id}, winning_team=${dto.winning_team}`, + ); + + // Try to submit immediately + await this.submitToOracle(submission); + + return { + job_id: jobId, + status: 'accepted', + message: 'Match result queued for submission', + }; + } + + private async submitToOracle(submission: QueuedSubmission): Promise { + try { + // In a real implementation, this would submit to the Soroban contract + // For now, we'll simulate the submission by updating the match record + this.logger.log( + `Submitting match result to oracle: job_id=${submission.id}, match_id=${submission.matchId}`, + ); + + // Simulate oracle submission (replace with actual contract call) + await this.simulateOracleSubmission(submission); + + // Remove from queue on success + this.submissionQueue.delete(submission.id); + + this.logger.log( + `Successfully submitted match result: job_id=${submission.id}`, + ); + } catch (error) { + this.logger.error( + `Failed to submit match result: job_id=${submission.id}, error=${error instanceof Error ? error.message : 'Unknown'}`, + ); + + // Handle retry logic + await this.handleRetry( + submission, + error instanceof Error ? error.message : 'Unknown error', + ); + } + } + + private async simulateOracleSubmission( + submission: QueuedSubmission, + ): Promise { + // This is a placeholder for the actual oracle submission logic + // In production, this would call the Soroban contract to submit the match result + + const startTime = Date.now(); + + // Simulate network delay + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Simulate occasional failure for testing retry logic (10% failure rate) + if (Math.random() < 0.1) { + throw new Error('Simulated oracle submission failure'); + } + + // Update match record to reflect submission + const match = await this.matchRepository.findOne({ + where: { on_chain_match_id: submission.matchId }, + }); + + if (match) { + match.result_submitted = true; + match.winning_team = submission.winningTeam; + await this.matchRepository.save(match); + } + + const submissionTime = Date.now() - startTime; + + // Update submission record in database + await this.updateSubmissionRecord(submission.id, { + status: SubmissionStatus.SUBMITTED, + submitted_at: new Date(), + transaction_hash: `tx_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`, + submission_time_ms: submissionTime, + retry_count: submission.retryCount, + }); + } + + private async handleRetry( + submission: QueuedSubmission, + error: string, + ): Promise { + submission.retryCount++; + submission.lastError = error; + + // Update submission record with error + await this.updateSubmissionRecord(submission.id, { + status: SubmissionStatus.FAILED, + error_message: error, + retry_count: submission.retryCount, + }); + + if (submission.retryCount >= this.MAX_RETRIES) { + this.logger.error( + `Max retries exceeded for job_id=${submission.id}, removing from queue`, + ); + this.submissionQueue.delete(submission.id); + return; + } + + // Calculate next retry time + const delay = + this.RETRY_DELAYS[submission.retryCount - 1] || + this.RETRY_DELAYS[this.RETRY_DELAYS.length - 1]; + submission.nextRetryAt = new Date(Date.now() + delay); + + this.logger.log( + `Scheduling retry for job_id=${submission.id}, attempt=${submission.retryCount}/${this.MAX_RETRIES}, next_retry_at=${submission.nextRetryAt.toISOString()}`, + ); + } + + @Cron(CronExpression.EVERY_MINUTE) + private async processRetries(): Promise { + const now = new Date(); + const jobsToRetry: QueuedSubmission[] = []; + + for (const [, submission] of this.submissionQueue.entries()) { + if (submission.nextRetryAt <= now) { + jobsToRetry.push(submission); + } + } + + if (jobsToRetry.length === 0) { + return; + } + + this.logger.log(`Processing ${jobsToRetry.length} retry attempts`); + + for (const submission of jobsToRetry) { + await this.submitToOracle(submission); + } + } + + private startRetryProcessor(): void { + this.logger.log('Webhook retry processor started'); + } + + private async saveSubmissionToDatabase( + submission: QueuedSubmission, + match: CreatorEventMatch, + ): Promise { + try { + const dbSubmission = this.submissionRepository.create({ + match_id: submission.matchId, + team_a: match.team_a, + team_b: match.team_b, + winning_team: submission.winningTeam, + confidence_score: submission.confidenceScore, + data_source: submission.dataSource, + result_timestamp: new Date(submission.timestamp), + metadata: submission.metadata, + status: SubmissionStatus.PENDING, + retry_count: 0, + }); + + await this.submissionRepository.save(dbSubmission); + this.logger.log(`Submission saved to database: job_id=${submission.id}`); + } catch (error) { + this.logger.error( + `Failed to save submission to database: ${error instanceof Error ? error.message : 'Unknown'}`, + ); + } + } + + private async updateSubmissionRecord( + jobId: string, + updates: Partial, + ): Promise { + try { + const submission = await this.submissionRepository.findOne({ + where: { match_id: jobId.replace('job_', '').split('_')[0] }, + }); + + if (submission) { + Object.assign(submission, updates); + await this.submissionRepository.save(submission); + } + } catch (error) { + this.logger.error( + `Failed to update submission record: ${error instanceof Error ? error.message : 'Unknown'}`, + ); + } + } + + private generateJobId(): string { + return `job_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; + } + + getQueueStatus(): { + total: number; + pending: number; + retrying: number; + jobs: Array<{ + id: string; + matchId: string; + retryCount: number; + nextRetryAt: Date; + lastError?: string; + }>; + } { + const jobs = Array.from(this.submissionQueue.values()).map((job) => ({ + id: job.id, + matchId: job.matchId, + retryCount: job.retryCount, + nextRetryAt: job.nextRetryAt, + lastError: job.lastError, + })); + + const pending = jobs.filter((j) => j.retryCount === 0).length; + const retrying = jobs.filter((j) => j.retryCount > 0).length; + + return { + total: jobs.length, + pending, + retrying, + jobs, + }; + } + + getJobStatus(jobId: string): QueuedSubmission | null { + return this.submissionQueue.get(jobId) || null; + } +} diff --git a/backend/src/users/entities/user-preferences.entity.ts b/backend/src/users/entities/user-preferences.entity.ts index addf0e6df..3c961d68d 100644 --- a/backend/src/users/entities/user-preferences.entity.ts +++ b/backend/src/users/entities/user-preferences.entity.ts @@ -36,6 +36,24 @@ export class UserPreferences { @Column({ default: false }) marketing_emails: boolean; + @Column({ default: true }) + event_created_notifications: boolean; + + @Column({ default: true }) + match_added_notifications: boolean; + + @Column({ default: true }) + prediction_submitted_notifications: boolean; + + @Column({ default: true }) + match_resolved_notifications: boolean; + + @Column({ default: true }) + winner_verified_notifications: boolean; + + @Column({ default: true }) + event_cancelled_notifications: boolean; + @CreateDateColumn() created_at: Date; diff --git a/backend/src/users/entities/user.entity.ts b/backend/src/users/entities/user.entity.ts index e2f66bd4f..d4e360c72 100644 --- a/backend/src/users/entities/user.entity.ts +++ b/backend/src/users/entities/user.entity.ts @@ -5,8 +5,11 @@ import { CreateDateColumn, UpdateDateColumn, Index, + OneToOne, + JoinColumn, } from 'typeorm'; import { IsString, IsOptional, IsNumber, Min, IsIn } from 'class-validator'; +import { UserPreferences } from './user-preferences.entity'; @Entity('users') export class User { @@ -76,6 +79,10 @@ export class User { @IsString() banned_by: string | null; + @OneToOne(() => UserPreferences, { cascade: true, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'preferences_id' }) + preferences?: UserPreferences; + @CreateDateColumn() created_at: Date;