From 198fc783d70f087ef138ba65ff14cc91f563bfda Mon Sep 17 00:00:00 2001 From: gideonodekina Date: Sun, 29 Mar 2026 23:28:04 +0100 Subject: [PATCH 1/2] feat: implement bulk market creation with transaction support --- backend/package-lock.json | 46 +++- .../achievements/achievements.controller.ts | 27 +++ .../src/achievements/achievements.module.ts | 21 ++ .../achievements/achievements.service.spec.ts | 112 +++++++++ .../src/achievements/achievements.service.ts | 215 ++++++++++++++++++ .../dto/achievement-response.dto.ts | 28 +++ .../entities/achievement.entity.ts | 45 ++++ .../entities/user-achievement.entity.ts | 35 +++ .../analytics/analytics.controller.spec.ts | 9 + backend/src/analytics/analytics.controller.ts | 19 +- backend/src/analytics/analytics.module.ts | 4 +- .../analytics.service.history.spec.ts | 127 +++++++++++ .../src/analytics/analytics.service.spec.ts | 9 + backend/src/analytics/analytics.service.ts | 82 ++++++- .../src/analytics/dto/market-history.dto.ts | 32 +++ .../entities/market-history.entity.ts | 40 ++++ backend/src/app.module.ts | 4 +- .../markets/dto/bulk-create-markets.dto.ts | 19 ++ backend/src/markets/dto/market-report.dto.ts | 70 ++++++ backend/src/markets/markets.controller.ts | 38 +++- .../src/markets/markets.service.bulk.spec.ts | 155 +++++++++++++ backend/src/markets/markets.service.spec.ts | 21 +- backend/src/markets/markets.service.ts | 143 +++++++++++- 23 files changed, 1279 insertions(+), 22 deletions(-) create mode 100644 backend/src/achievements/achievements.controller.ts create mode 100644 backend/src/achievements/achievements.module.ts create mode 100644 backend/src/achievements/achievements.service.spec.ts create mode 100644 backend/src/achievements/achievements.service.ts create mode 100644 backend/src/achievements/dto/achievement-response.dto.ts create mode 100644 backend/src/achievements/entities/achievement.entity.ts create mode 100644 backend/src/achievements/entities/user-achievement.entity.ts create mode 100644 backend/src/analytics/analytics.service.history.spec.ts create mode 100644 backend/src/analytics/dto/market-history.dto.ts create mode 100644 backend/src/analytics/entities/market-history.entity.ts create mode 100644 backend/src/markets/dto/bulk-create-markets.dto.ts create mode 100644 backend/src/markets/dto/market-report.dto.ts create mode 100644 backend/src/markets/markets.service.bulk.spec.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 9f691ac3..77de6c3b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -240,6 +240,7 @@ "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", @@ -2082,6 +2083,7 @@ "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", @@ -2139,6 +2141,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2309,6 +2312,7 @@ "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", @@ -2368,6 +2372,7 @@ "integrity": "sha512-lD5mAYekTTurF3vDaa8C2OKPnjiz4tsfxIc5XlcSUzOhkwWf6Ay3HKvt6FmvuWQam6uIIHX52Clg+e6tAvf/cg==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -2431,6 +2436,7 @@ "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", @@ -2725,6 +2731,7 @@ "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", @@ -3061,6 +3068,7 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -3195,6 +3203,7 @@ "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" } @@ -3365,6 +3374,7 @@ "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", @@ -4072,6 +4082,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4121,6 +4132,7 @@ "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", @@ -4356,6 +4368,7 @@ "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", @@ -4700,6 +4713,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4929,6 +4943,7 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -4976,13 +4991,15 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "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", @@ -5731,6 +5748,7 @@ "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", @@ -5791,6 +5809,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -6032,6 +6051,7 @@ "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", @@ -7170,6 +7190,7 @@ "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.3.0", "@jest/types": "30.3.0", @@ -8906,6 +8927,7 @@ "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", @@ -9023,6 +9045,7 @@ "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", @@ -9132,6 +9155,7 @@ "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", @@ -9163,6 +9187,7 @@ "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", @@ -9367,6 +9392,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9593,7 +9619,8 @@ "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" + "license": "Apache-2.0", + "peer": true }, "node_modules/require-directory": { "version": "2.1.1", @@ -9689,6 +9716,7 @@ "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" } @@ -10388,6 +10416,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -10747,6 +10776,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -10907,6 +10937,7 @@ "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", @@ -11089,6 +11120,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11462,7 +11494,6 @@ "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -11481,7 +11512,6 @@ "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -11495,7 +11525,6 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -11510,7 +11539,6 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } @@ -11520,8 +11548,7 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/webpack/node_modules/mime-db": { "version": "1.52.0", @@ -11529,7 +11556,6 @@ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -11540,7 +11566,6 @@ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -11554,7 +11579,6 @@ "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/src/achievements/achievements.controller.ts b/backend/src/achievements/achievements.controller.ts new file mode 100644 index 00000000..6409edd0 --- /dev/null +++ b/backend/src/achievements/achievements.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, Param, HttpCode, HttpStatus } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Public } from '../common/decorators/public.decorator'; +import { AchievementsService } from './achievements.service'; +import { AchievementResponseDto } from './dto/achievement-response.dto'; + +@ApiTags('Achievements') +@Controller('users/:address/achievements') +export class AchievementsController { + constructor(private readonly achievementsService: AchievementsService) { } + + @Get() + @Public() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Get user achievements and badges' }) + @ApiResponse({ + status: 200, + description: 'List of achievements with unlock status', + type: [AchievementResponseDto], + }) + @ApiResponse({ status: 404, description: 'User not found' }) + async getUserAchievements( + @Param('address') address: string, + ): Promise { + return this.achievementsService.getUserAchievements(address); + } +} diff --git a/backend/src/achievements/achievements.module.ts b/backend/src/achievements/achievements.module.ts new file mode 100644 index 00000000..2908b4ac --- /dev/null +++ b/backend/src/achievements/achievements.module.ts @@ -0,0 +1,21 @@ +import { Module, OnModuleInit } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Achievement } from './entities/achievement.entity'; +import { UserAchievement } from './entities/user-achievement.entity'; +import { AchievementsService } from './achievements.service'; +import { AchievementsController } from './achievements.controller'; +import { User } from '../users/entities/user.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Achievement, UserAchievement, User])], + providers: [AchievementsService], + controllers: [AchievementsController], + exports: [AchievementsService], +}) +export class AchievementsModule implements OnModuleInit { + constructor(private readonly achievementsService: AchievementsService) { } + + async onModuleInit(): Promise { + await this.achievementsService.initializeAchievements(); + } +} diff --git a/backend/src/achievements/achievements.service.spec.ts b/backend/src/achievements/achievements.service.spec.ts new file mode 100644 index 00000000..7225a85f --- /dev/null +++ b/backend/src/achievements/achievements.service.spec.ts @@ -0,0 +1,112 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AchievementsService } from './achievements.service'; +import { Achievement, AchievementType } from './entities/achievement.entity'; +import { UserAchievement } from './entities/user-achievement.entity'; +import { User } from '../users/entities/user.entity'; + +describe('AchievementsService', () => { + let service: AchievementsService; + let achievementsRepository: jest.Mocked>; + let userAchievementsRepository: jest.Mocked>; + let usersRepository: jest.Mocked>; + + const mockUser = { + id: 'user-1', + stellar_address: 'GABC123', + total_predictions: 10, + correct_predictions: 9, + total_staked_stroops: '5000000', + reputation_score: 600, + } as User; + + beforeEach(async () => { + achievementsRepository = { + count: jest.fn().mockResolvedValue(0), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + } as any; + + userAchievementsRepository = { + find: jest.fn(), + findOne: jest.fn(), + save: jest.fn(), + } as any; + + usersRepository = { + findOne: jest.fn().mockResolvedValue(mockUser), + } as any; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AchievementsService, + { + provide: getRepositoryToken(Achievement), + useValue: achievementsRepository, + }, + { + provide: getRepositoryToken(UserAchievement), + useValue: userAchievementsRepository, + }, + { + provide: getRepositoryToken(User), + useValue: usersRepository, + }, + ], + }).compile(); + + service = module.get(AchievementsService); + }); + + it('should initialize achievements on first call', async () => { + await service.initializeAchievements(); + expect(achievementsRepository.save).toHaveBeenCalled(); + }); + + it('should check and unlock achievements for user', async () => { + const mockAchievement = { + id: 'ach-1', + type: AchievementType.FIRST_PREDICTION, + title: 'First Step', + } as Achievement; + + achievementsRepository.findOne.mockResolvedValue(mockAchievement); + userAchievementsRepository.findOne.mockResolvedValue(null); + + await service.checkAndUnlockAchievements(mockUser); + + expect(userAchievementsRepository.save).toHaveBeenCalled(); + }); + + it('should get user achievements', async () => { + const mockAchievements = [ + { + id: 'ach-1', + type: AchievementType.FIRST_PREDICTION, + title: 'First Step', + description: 'Make your first prediction', + icon_url: null, + reward_points: 10, + }, + ] as Achievement[]; + + const mockUserAchievements = [ + { + achievement: mockAchievements[0], + is_unlocked: true, + unlocked_at: new Date(), + }, + ] as UserAchievement[]; + + usersRepository.findOne.mockResolvedValue(mockUser); + userAchievementsRepository.find.mockResolvedValue(mockUserAchievements); + achievementsRepository.find.mockResolvedValue(mockAchievements); + + const result = await service.getUserAchievements(mockUser.stellar_address); + + expect(result).toHaveLength(1); + expect(result[0].is_unlocked).toBe(true); + }); +}); diff --git a/backend/src/achievements/achievements.service.ts b/backend/src/achievements/achievements.service.ts new file mode 100644 index 00000000..153550b0 --- /dev/null +++ b/backend/src/achievements/achievements.service.ts @@ -0,0 +1,215 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Achievement, AchievementType } from './entities/achievement.entity'; +import { UserAchievement } from './entities/user-achievement.entity'; +import { User } from '../users/entities/user.entity'; +import { AchievementResponseDto } from './dto/achievement-response.dto'; + +@Injectable() +export class AchievementsService { + private readonly logger = new Logger(AchievementsService.name); + + constructor( + @InjectRepository(Achievement) + private readonly achievementsRepository: Repository, + @InjectRepository(UserAchievement) + private readonly userAchievementsRepository: Repository, + @InjectRepository(User) + private readonly usersRepository: Repository, + ) { } + + async initializeAchievements(): Promise { + const count = await this.achievementsRepository.count(); + if (count > 0) return; + + const achievements = [ + { + type: AchievementType.FIRST_PREDICTION, + title: 'First Step', + description: 'Make your first prediction', + reward_points: 10, + }, + { + type: AchievementType.CORRECT_PREDICTIONS_10, + title: 'Rising Star', + description: 'Get 10 correct predictions', + reward_points: 50, + }, + { + type: AchievementType.CORRECT_PREDICTIONS_50, + title: 'Seasoned Predictor', + description: 'Get 50 correct predictions', + reward_points: 150, + }, + { + type: AchievementType.CORRECT_PREDICTIONS_100, + title: 'Master Predictor', + description: 'Get 100 correct predictions', + reward_points: 300, + }, + { + type: AchievementType.ACCURACY_75, + title: 'Accurate Mind', + description: 'Achieve 75% prediction accuracy', + reward_points: 100, + }, + { + type: AchievementType.ACCURACY_90, + title: 'Legendary Accuracy', + description: 'Achieve 90% prediction accuracy', + reward_points: 250, + }, + { + type: AchievementType.TOTAL_STAKED_1M, + title: 'High Roller', + description: 'Stake 1,000,000 stroops total', + reward_points: 75, + }, + { + type: AchievementType.TOTAL_STAKED_10M, + title: 'Whale Predictor', + description: 'Stake 10,000,000 stroops total', + reward_points: 200, + }, + { + type: AchievementType.REPUTATION_500, + title: 'Respected Voice', + description: 'Reach 500 reputation score', + reward_points: 100, + }, + { + type: AchievementType.REPUTATION_1000, + title: 'Community Legend', + description: 'Reach 1000 reputation score', + reward_points: 300, + }, + ]; + + for (const achievement of achievements) { + await this.achievementsRepository.save(achievement); + } + + this.logger.log(`Initialized ${achievements.length} achievements`); + } + + async checkAndUnlockAchievements(user: User): Promise { + const fullUser = await this.usersRepository.findOne({ + where: { id: user.id }, + }); + + if (!fullUser) return; + + const achievementsToCheck = [ + { + type: AchievementType.FIRST_PREDICTION, + condition: fullUser.total_predictions >= 1, + }, + { + type: AchievementType.CORRECT_PREDICTIONS_10, + condition: fullUser.correct_predictions >= 10, + }, + { + type: AchievementType.CORRECT_PREDICTIONS_50, + condition: fullUser.correct_predictions >= 50, + }, + { + type: AchievementType.CORRECT_PREDICTIONS_100, + condition: fullUser.correct_predictions >= 100, + }, + { + type: AchievementType.ACCURACY_75, + condition: + fullUser.total_predictions > 0 && + (fullUser.correct_predictions / fullUser.total_predictions) * 100 >= + 75, + }, + { + type: AchievementType.ACCURACY_90, + condition: + fullUser.total_predictions > 0 && + (fullUser.correct_predictions / fullUser.total_predictions) * 100 >= + 90, + }, + { + type: AchievementType.TOTAL_STAKED_1M, + condition: BigInt(fullUser.total_staked_stroops) >= BigInt(1000000), + }, + { + type: AchievementType.TOTAL_STAKED_10M, + condition: BigInt(fullUser.total_staked_stroops) >= BigInt(10000000), + }, + { + type: AchievementType.REPUTATION_500, + condition: fullUser.reputation_score >= 500, + }, + { + type: AchievementType.REPUTATION_1000, + condition: fullUser.reputation_score >= 1000, + }, + ]; + + for (const { type, condition } of achievementsToCheck) { + if (!condition) continue; + + const achievement = await this.achievementsRepository.findOne({ + where: { type }, + }); + + if (!achievement) continue; + + const existing = await this.userAchievementsRepository.findOne({ + where: { user: { id: user.id }, achievement: { id: achievement.id } }, + }); + + if (!existing) { + await this.userAchievementsRepository.save({ + user, + achievement, + is_unlocked: true, + unlocked_at: new Date(), + }); + + this.logger.log( + `Unlocked achievement "${achievement.title}" for user ${user.id}`, + ); + } + } + } + + async getUserAchievements( + userAddress: string, + ): Promise { + const user = await this.usersRepository.findOne({ + where: { stellar_address: userAddress }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + const userAchievements = await this.userAchievementsRepository.find({ + where: { user: { id: user.id } }, + relations: ['achievement'], + }); + + const allAchievements = await this.achievementsRepository.find(); + + return allAchievements.map((achievement) => { + const userAchievement = userAchievements.find( + (ua) => ua.achievement.id === achievement.id, + ); + + return { + id: achievement.id, + type: achievement.type, + title: achievement.title, + description: achievement.description, + icon_url: achievement.icon_url, + reward_points: achievement.reward_points, + is_unlocked: !!userAchievement?.is_unlocked, + unlocked_at: userAchievement?.unlocked_at || null, + }; + }); + } +} diff --git a/backend/src/achievements/dto/achievement-response.dto.ts b/backend/src/achievements/dto/achievement-response.dto.ts new file mode 100644 index 00000000..dce2b19b --- /dev/null +++ b/backend/src/achievements/dto/achievement-response.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { AchievementType } from '../entities/achievement.entity'; + +export class AchievementResponseDto { + @ApiProperty() + id: string; + + @ApiProperty({ enum: AchievementType }) + type: AchievementType; + + @ApiProperty() + title: string; + + @ApiProperty() + description: string; + + @ApiProperty({ nullable: true }) + icon_url: string | null; + + @ApiProperty() + reward_points: number; + + @ApiProperty() + is_unlocked: boolean; + + @ApiProperty({ nullable: true }) + unlocked_at: Date | null; +} diff --git a/backend/src/achievements/entities/achievement.entity.ts b/backend/src/achievements/entities/achievement.entity.ts new file mode 100644 index 00000000..87049ace --- /dev/null +++ b/backend/src/achievements/entities/achievement.entity.ts @@ -0,0 +1,45 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +export enum AchievementType { + FIRST_PREDICTION = 'first_prediction', + CORRECT_PREDICTIONS_10 = 'correct_predictions_10', + CORRECT_PREDICTIONS_50 = 'correct_predictions_50', + CORRECT_PREDICTIONS_100 = 'correct_predictions_100', + ACCURACY_75 = 'accuracy_75', + ACCURACY_90 = 'accuracy_90', + TOTAL_STAKED_1M = 'total_staked_1m', + TOTAL_STAKED_10M = 'total_staked_10m', + REPUTATION_500 = 'reputation_500', + REPUTATION_1000 = 'reputation_1000', +} + +@Entity('achievements') +@Index(['type']) +export class Achievement { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'enum', enum: AchievementType }) + type: AchievementType; + + @Column() + title: string; + + @Column() + description: string; + + @Column({ nullable: true }) + icon_url: string; + + @Column({ default: 0 }) + reward_points: number; + + @CreateDateColumn() + created_at: Date; +} diff --git a/backend/src/achievements/entities/user-achievement.entity.ts b/backend/src/achievements/entities/user-achievement.entity.ts new file mode 100644 index 00000000..36bd958c --- /dev/null +++ b/backend/src/achievements/entities/user-achievement.entity.ts @@ -0,0 +1,35 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + Index, + JoinColumn, + Unique, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { Achievement } from './achievement.entity'; + +@Entity('user_achievements') +@Unique('UQ_user_achievement', ['user', 'achievement']) +@Index(['user']) +@Index(['achievement']) +export class UserAchievement { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE', eager: false }) + @JoinColumn({ name: 'userId' }) + user: User; + + @ManyToOne(() => Achievement, { onDelete: 'CASCADE', eager: false }) + @JoinColumn({ name: 'achievementId' }) + achievement: Achievement; + + @Column({ default: false }) + is_unlocked: boolean; + + @CreateDateColumn() + unlocked_at: Date; +} diff --git a/backend/src/analytics/analytics.controller.spec.ts b/backend/src/analytics/analytics.controller.spec.ts index 49b6e3a3..ec6eba1e 100644 --- a/backend/src/analytics/analytics.controller.spec.ts +++ b/backend/src/analytics/analytics.controller.spec.ts @@ -6,6 +6,7 @@ import { Market } from '../markets/entities/market.entity'; import { Prediction } from '../predictions/entities/prediction.entity'; import { User } from '../users/entities/user.entity'; import { ActivityLog } from './entities/activity-log.entity'; +import { MarketHistory } from './entities/market-history.entity'; import { AnalyticsController } from './analytics.controller'; import { AnalyticsService } from './analytics.service'; @@ -126,6 +127,14 @@ describe('AnalyticsController', () => { findAndCount: jest.fn(), }, }, + { + provide: getRepositoryToken(MarketHistory), + useValue: { + find: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }, + }, ], }).compile(); diff --git a/backend/src/analytics/analytics.controller.ts b/backend/src/analytics/analytics.controller.ts index b1580d07..9f40f0a7 100644 --- a/backend/src/analytics/analytics.controller.ts +++ b/backend/src/analytics/analytics.controller.ts @@ -11,11 +11,12 @@ import { User } from '../users/entities/user.entity'; import { AnalyticsService } from './analytics.service'; import { DashboardKpisDto } from './dto/dashboard-kpis.dto'; import { MarketAnalyticsDto } from './dto/market-analytics.dto'; +import { MarketHistoryResponseDto } from './dto/market-history.dto'; @ApiTags('Analytics') @Controller('analytics') export class AnalyticsController { - constructor(private readonly analyticsService: AnalyticsService) {} + constructor(private readonly analyticsService: AnalyticsService) { } @Get('dashboard') @ApiBearerAuth() @@ -46,4 +47,20 @@ export class AnalyticsController { ): Promise { return this.analyticsService.getMarketAnalytics(id); } + + @Get('markets/:id/history') + @Public() + @ApiOperation({ summary: 'Get historical data for a market over time' }) + @ApiResponse({ + status: 200, + description: + 'Market history with prediction volume, pool size, and participant growth', + type: MarketHistoryResponseDto, + }) + @ApiResponse({ status: 404, description: 'Market not found' }) + async getMarketHistory( + @Param('id') id: string, + ): Promise { + return this.analyticsService.getMarketHistory(id); + } } diff --git a/backend/src/analytics/analytics.module.ts b/backend/src/analytics/analytics.module.ts index eede5d93..ffc5b756 100644 --- a/backend/src/analytics/analytics.module.ts +++ b/backend/src/analytics/analytics.module.ts @@ -7,6 +7,7 @@ import { User } from '../users/entities/user.entity'; import { AnalyticsController } from './analytics.controller'; import { AnalyticsService } from './analytics.service'; import { ActivityLog } from './entities/activity-log.entity'; +import { MarketHistory } from './entities/market-history.entity'; @Module({ imports: [ @@ -16,10 +17,11 @@ import { ActivityLog } from './entities/activity-log.entity'; LeaderboardEntry, Market, ActivityLog, + MarketHistory, ]), ], controllers: [AnalyticsController], providers: [AnalyticsService], exports: [AnalyticsService], }) -export class AnalyticsModule {} +export class AnalyticsModule { } diff --git a/backend/src/analytics/analytics.service.history.spec.ts b/backend/src/analytics/analytics.service.history.spec.ts new file mode 100644 index 00000000..8ba17a68 --- /dev/null +++ b/backend/src/analytics/analytics.service.history.spec.ts @@ -0,0 +1,127 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AnalyticsService } from './analytics.service'; +import { Market } from '../markets/entities/market.entity'; +import { MarketHistory } from './entities/market-history.entity'; +import { User } from '../users/entities/user.entity'; +import { Prediction } from '../predictions/entities/prediction.entity'; +import { LeaderboardEntry } from '../leaderboard/entities/leaderboard-entry.entity'; +import { ActivityLog } from './entities/activity-log.entity'; + +describe('AnalyticsService - Market History', () => { + let service: AnalyticsService; + let marketHistoryRepository: jest.Mocked>; + let marketsRepository: jest.Mocked>; + let predictionsRepository: jest.Mocked>; + + const mockMarket = { + id: 'market-1', + on_chain_market_id: 'market_123', + title: 'Test Market', + outcome_options: ['YES', 'NO'], + participant_count: 10, + total_pool_stroops: '5000000', + created_at: new Date(), + } as Market; + + beforeEach(async () => { + marketHistoryRepository = { + find: jest.fn(), + create: jest.fn(), + save: jest.fn(), + } as any; + + marketsRepository = { + findOne: jest.fn().mockResolvedValue(mockMarket), + } as any; + + predictionsRepository = { + find: jest.fn().mockResolvedValue([]), + } as any; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AnalyticsService, + { + provide: getRepositoryToken(User), + useValue: {}, + }, + { + provide: getRepositoryToken(Prediction), + useValue: predictionsRepository, + }, + { + provide: getRepositoryToken(LeaderboardEntry), + useValue: {}, + }, + { + provide: getRepositoryToken(Market), + useValue: marketsRepository, + }, + { + provide: getRepositoryToken(ActivityLog), + useValue: {}, + }, + { + provide: getRepositoryToken(MarketHistory), + useValue: marketHistoryRepository, + }, + ], + }).compile(); + + service = module.get(AnalyticsService); + }); + + it('should get market history', async () => { + const mockHistory = [ + { + recorded_at: new Date(), + prediction_volume: 5, + pool_size_stroops: '2500000', + participant_count: 5, + outcome_probabilities: ['50.00', '50.00'], + }, + { + recorded_at: new Date(Date.now() + 1000), + prediction_volume: 10, + pool_size_stroops: '5000000', + participant_count: 10, + outcome_probabilities: ['60.00', '40.00'], + }, + ] as MarketHistory[]; + + marketHistoryRepository.find.mockResolvedValue(mockHistory); + + const result = await service.getMarketHistory('market-1'); + + expect(result.market_id).toBe('market-1'); + expect(result.title).toBe('Test Market'); + expect(result.history).toHaveLength(2); + expect(result.history[0].prediction_volume).toBe(5); + }); + + it('should record market snapshot', async () => { + marketHistoryRepository.create.mockReturnValue({ + market: mockMarket, + recorded_at: new Date(), + prediction_volume: 10, + pool_size_stroops: '5000000', + participant_count: 10, + outcome_probabilities: ['50.00', '50.00'], + } as MarketHistory); + + marketHistoryRepository.save.mockResolvedValue({} as MarketHistory); + + await service.recordMarketSnapshot(mockMarket); + + expect(marketHistoryRepository.create).toHaveBeenCalled(); + expect(marketHistoryRepository.save).toHaveBeenCalled(); + }); + + it('should throw NotFoundException for non-existent market', async () => { + marketsRepository.findOne.mockResolvedValue(null); + + await expect(service.getMarketHistory('invalid-id')).rejects.toThrow(); + }); +}); diff --git a/backend/src/analytics/analytics.service.spec.ts b/backend/src/analytics/analytics.service.spec.ts index a892fdff..2b9e7ea7 100644 --- a/backend/src/analytics/analytics.service.spec.ts +++ b/backend/src/analytics/analytics.service.spec.ts @@ -11,6 +11,7 @@ import { Prediction } from '../predictions/entities/prediction.entity'; import { LeaderboardEntry } from '../leaderboard/entities/leaderboard-entry.entity'; import { Market } from '../markets/entities/market.entity'; import { ActivityLog } from './entities/activity-log.entity'; +import { MarketHistory } from './entities/market-history.entity'; describe('predictorTierFromReputation', () => { it('maps thresholds to tier labels', () => { @@ -99,6 +100,14 @@ describe('AnalyticsService', () => { findAndCount: jest.fn(), }, }, + { + provide: getRepositoryToken(MarketHistory), + useValue: { + find: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }, + }, ], }).compile(); diff --git a/backend/src/analytics/analytics.service.ts b/backend/src/analytics/analytics.service.ts index bee80d89..44405925 100644 --- a/backend/src/analytics/analytics.service.ts +++ b/backend/src/analytics/analytics.service.ts @@ -6,11 +6,13 @@ import { Market } from '../markets/entities/market.entity'; import { Prediction } from '../predictions/entities/prediction.entity'; import { User } from '../users/entities/user.entity'; import { ActivityLog } from './entities/activity-log.entity'; +import { MarketHistory } from './entities/market-history.entity'; import { DashboardKpisDto } from './dto/dashboard-kpis.dto'; import { MarketAnalyticsDto, OutcomeDistributionDto, } from './dto/market-analytics.dto'; +import { MarketHistoryResponseDto } from './dto/market-history.dto'; /** Tier thresholds: Bronze < 200, Silver < 500, Gold < 1000, Platinum ≥ 1000 */ export function predictorTierFromReputation(reputationScore: number): string { @@ -40,7 +42,9 @@ export class AnalyticsService { private readonly marketsRepository: Repository, @InjectRepository(ActivityLog) private readonly activityLogsRepository: Repository, - ) {} + @InjectRepository(MarketHistory) + private readonly marketHistoryRepository: Repository, + ) { } async logActivity( userId: string, @@ -177,4 +181,78 @@ export class AnalyticsService { time_remaining_seconds: timeRemainingSeconds, }; } -} + + /** + * Get historical data for a market: prediction volume, pool size, participant growth over time + */ + async getMarketHistory(marketId: string): Promise { + const market = await this.marketsRepository.findOne({ + where: [{ id: marketId }, { on_chain_market_id: marketId }], + }); + + if (!market) { + throw new NotFoundException(`Market "${marketId}" not found`); + } + + const history = await this.marketHistoryRepository.find({ + where: { market: { id: market.id } }, + order: { recorded_at: 'ASC' }, + }); + + const historyPoints = history.map((h) => ({ + timestamp: h.recorded_at, + prediction_volume: h.prediction_volume, + pool_size_stroops: h.pool_size_stroops, + participant_count: h.participant_count, + outcome_probabilities: h.outcome_probabilities + ? h.outcome_probabilities.map((p) => parseFloat(p)) + : null, + })); + + this.logger.log( + `Market history retrieved for "${market.title}" (${market.id}) - ${historyPoints.length} data points`, + ); + + return { + market_id: market.id, + title: market.title, + history: historyPoints, + generated_at: new Date(), + }; + } + + /** + * Record market snapshot for historical tracking + */ + async recordMarketSnapshot(market: Market): Promise { + const predictions = await this.predictionsRepository.find({ + where: { market: { id: market.id } }, + }); + + const outcomeCounts = new Map(); + market.outcome_options.forEach((outcome) => { + outcomeCounts.set(outcome, 0); + }); + + predictions.forEach((prediction) => { + const currentCount = outcomeCounts.get(prediction.chosen_outcome) || 0; + outcomeCounts.set(prediction.chosen_outcome, currentCount + 1); + }); + + const total = predictions.length; + const probabilities = Array.from(outcomeCounts.values()).map((count) => + total > 0 ? ((count / total) * 100).toFixed(2) : '0.00', + ); + + const snapshot = this.marketHistoryRepository.create({ + market, + recorded_at: new Date(), + prediction_volume: total, + pool_size_stroops: market.total_pool_stroops, + participant_count: market.participant_count, + outcome_probabilities: probabilities, + }); + + await this.marketHistoryRepository.save(snapshot); + } +} \ No newline at end of file diff --git a/backend/src/analytics/dto/market-history.dto.ts b/backend/src/analytics/dto/market-history.dto.ts new file mode 100644 index 00000000..2bf644b8 --- /dev/null +++ b/backend/src/analytics/dto/market-history.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class MarketHistoryPointDto { + @ApiProperty() + timestamp: Date; + + @ApiProperty() + prediction_volume: number; + + @ApiProperty() + pool_size_stroops: string; + + @ApiProperty() + participant_count: number; + + @ApiProperty({ type: [Number], nullable: true }) + outcome_probabilities: number[] | null; +} + +export class MarketHistoryResponseDto { + @ApiProperty() + market_id: string; + + @ApiProperty() + title: string; + + @ApiProperty({ type: [MarketHistoryPointDto] }) + history: MarketHistoryPointDto[]; + + @ApiProperty() + generated_at: Date; +} diff --git a/backend/src/analytics/entities/market-history.entity.ts b/backend/src/analytics/entities/market-history.entity.ts new file mode 100644 index 00000000..c80fa868 --- /dev/null +++ b/backend/src/analytics/entities/market-history.entity.ts @@ -0,0 +1,40 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + Index, + JoinColumn, +} from 'typeorm'; +import { Market } from '../../markets/entities/market.entity'; + +@Entity('market_history') +@Index(['market', 'recorded_at']) +@Index(['market']) +export class MarketHistory { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => Market, { onDelete: 'CASCADE', eager: false }) + @JoinColumn({ name: 'marketId' }) + market: Market; + + @Column({ type: 'timestamptz' }) + recorded_at: Date; + + @Column({ default: 0 }) + prediction_volume: number; + + @Column({ type: 'bigint', default: '0' }) + pool_size_stroops: string; + + @Column({ default: 0 }) + participant_count: number; + + @Column('simple-array', { nullable: true }) + outcome_probabilities: string[]; + + @CreateDateColumn() + created_at: Date; +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 11710652..d9dc11b1 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -12,6 +12,7 @@ import { AppService } from './app.service'; import { AuthModule } from './auth/auth.module'; import { CommonModule } from './common/common.module'; import { AdminModule } from './admin/admin.module'; +import { AchievementsModule } from './achievements/achievements.module'; import { JwtAuthGuard } from './common/guards/jwt-auth.guard'; import { RolesGuard } from './common/guards/roles.guard'; import { CompetitionsModule } from './competitions/competitions.module'; @@ -77,6 +78,7 @@ import { UsersModule } from './users/users.module'; NotificationsModule, SorobanModule, AdminModule, + AchievementsModule, CommonModule, AnalyticsModule, ], @@ -98,4 +100,4 @@ import { UsersModule } from './users/users.module'; }, ], }) -export class AppModule {} +export class AppModule { } diff --git a/backend/src/markets/dto/bulk-create-markets.dto.ts b/backend/src/markets/dto/bulk-create-markets.dto.ts new file mode 100644 index 00000000..6871695e --- /dev/null +++ b/backend/src/markets/dto/bulk-create-markets.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsArray, ArrayMinSize, ArrayMaxSize, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { CreateMarketDto } from './create-market.dto'; + +export class BulkCreateMarketsDto { + @ApiProperty({ + description: 'Array of market DTOs to create (max 10)', + type: [CreateMarketDto], + minItems: 1, + maxItems: 10, + }) + @IsArray() + @ArrayMinSize(1, { message: 'At least 1 market is required' }) + @ArrayMaxSize(10, { message: 'Maximum 10 markets per request' }) + @ValidateNested({ each: true }) + @Type(() => CreateMarketDto) + markets: CreateMarketDto[]; +} diff --git a/backend/src/markets/dto/market-report.dto.ts b/backend/src/markets/dto/market-report.dto.ts new file mode 100644 index 00000000..2b53d1d7 --- /dev/null +++ b/backend/src/markets/dto/market-report.dto.ts @@ -0,0 +1,70 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class PredictionOutcomeDto { + @ApiProperty() + outcome: string; + + @ApiProperty() + count: number; + + @ApiProperty() + percentage: number; + + @ApiProperty() + total_staked_stroops: string; +} + +export class MarketEventDto { + @ApiProperty() + timestamp: Date; + + @ApiProperty() + event_type: string; + + @ApiProperty() + description: string; +} + +export class MarketReportDto { + @ApiProperty() + market_id: string; + + @ApiProperty() + title: string; + + @ApiProperty() + description: string; + + @ApiProperty() + category: string; + + @ApiProperty() + created_at: Date; + + @ApiProperty() + end_time: Date; + + @ApiProperty() + resolution_time: Date; + + @ApiProperty() + is_resolved: boolean; + + @ApiProperty({ nullable: true }) + resolved_outcome: string | null; + + @ApiProperty() + total_participants: number; + + @ApiProperty() + total_pool_stroops: string; + + @ApiProperty({ type: [PredictionOutcomeDto] }) + outcome_distribution: PredictionOutcomeDto[]; + + @ApiProperty({ type: [MarketEventDto] }) + timeline: MarketEventDto[]; + + @ApiProperty() + generated_at: Date; +} diff --git a/backend/src/markets/markets.controller.ts b/backend/src/markets/markets.controller.ts index f0048540..5e5f94b6 100644 --- a/backend/src/markets/markets.controller.ts +++ b/backend/src/markets/markets.controller.ts @@ -10,6 +10,7 @@ import { HttpStatus, UseGuards, } from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; import { BanGuard } from '../common/guards/ban.guard'; import { PredictionStatsDto } from './dto/prediction-stats.dto'; import { @@ -23,6 +24,7 @@ import { Market } from './entities/market.entity'; import { Comment } from './entities/comment.entity'; import { MarketTemplate } from './entities/market-template.entity'; import { CreateMarketDto } from './dto/create-market.dto'; +import { BulkCreateMarketsDto } from './dto/bulk-create-markets.dto'; import { CreateCommentDto } from './dto/create-comment.dto'; import { ListMarketsDto, @@ -37,7 +39,7 @@ import { User } from '../users/entities/user.entity'; @ApiTags('Markets') @Controller('markets') export class MarketsController { - constructor(private readonly marketsService: MarketsService) {} + constructor(private readonly marketsService: MarketsService) { } @Get('templates') @Public() @@ -81,6 +83,26 @@ export class MarketsController { return this.marketsService.create(dto, user); } + @Post('bulk') + @UseGuards(BanGuard) + @Throttle({ default: { limit: 5, ttl: 60000 } }) + @HttpCode(HttpStatus.CREATED) + @ApiBearerAuth() + @ApiOperation({ summary: 'Bulk create prediction markets (max 10 per request)' }) + @ApiResponse({ + status: 201, + description: 'Markets created', + type: [Market], + }) + @ApiResponse({ status: 400, description: 'Validation error or exceeds limit' }) + @ApiResponse({ status: 502, description: 'Soroban contract call failed' }) + async bulkCreateMarkets( + @Body() dto: BulkCreateMarketsDto, + @CurrentUser() user: User, + ): Promise { + return this.marketsService.createBulk(dto.markets, user); + } + @Get() @Public() @ApiOperation({ summary: 'List and filter markets with pagination' }) @@ -149,4 +171,16 @@ export class MarketsController { async getComments(@Param('id') id: string): Promise { return this.marketsService.getComments(id); } -} + + @Get(':id/report') + @Public() + @ApiOperation({ summary: 'Generate detailed market report with anonymized predictions' }) + @ApiResponse({ + status: 200, + description: 'Market report with outcome distribution and timeline', + }) + @ApiResponse({ status: 404, description: 'Market not found' }) + async getMarketReport(@Param('id') id: string): Promise { + return this.marketsService.generateMarketReport(id); + } +} \ No newline at end of file diff --git a/backend/src/markets/markets.service.bulk.spec.ts b/backend/src/markets/markets.service.bulk.spec.ts new file mode 100644 index 00000000..52bd8481 --- /dev/null +++ b/backend/src/markets/markets.service.bulk.spec.ts @@ -0,0 +1,155 @@ +import { BadRequestException, BadGatewayException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { MarketsService } from './markets.service'; +import { Market } from './entities/market.entity'; +import { Comment } from './entities/comment.entity'; +import { MarketTemplate } from './entities/market-template.entity'; +import { SorobanService } from '../soroban/soroban.service'; +import { UsersService } from '../users/users.service'; +import { User } from '../users/entities/user.entity'; +import { CreateMarketDto } from './dto/create-market.dto'; + +describe('MarketsService - Bulk Creation', () => { + let service: MarketsService; + let marketsRepository: jest.Mocked>; + let dataSource: jest.Mocked; + let sorobanService: jest.Mocked; + + const mockUser = { + id: 'user-1', + stellar_address: 'GABC123', + } as User; + + const makeCreateDto = (): CreateMarketDto => ({ + title: 'Test Market', + description: 'Test description for market', + category: 'Crypto' as any, + outcome_options: ['YES', 'NO'], + end_time: new Date(Date.now() + 60_000).toISOString(), + resolution_time: new Date(Date.now() + 120_000).toISOString(), + creator_fee_bps: 100, + min_stake_stroops: '1000', + max_stake_stroops: '1000000', + is_public: true, + }); + + beforeEach(async () => { + marketsRepository = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + } as any; + + const mockQueryRunner = { + connect: jest.fn(), + startTransaction: jest.fn(), + commitTransaction: jest.fn(), + rollbackTransaction: jest.fn(), + release: jest.fn(), + manager: { + create: jest.fn(), + save: jest.fn(), + }, + }; + + dataSource = { + createQueryRunner: jest.fn().mockReturnValue(mockQueryRunner), + } as any; + + sorobanService = { + createMarket: jest.fn().mockResolvedValue({ + market_id: 'market_123', + tx_hash: 'tx_hash_123', + }), + } as any; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MarketsService, + { + provide: getRepositoryToken(Market), + useValue: marketsRepository, + }, + { + provide: getRepositoryToken(Comment), + useValue: {}, + }, + { + provide: getRepositoryToken(MarketTemplate), + useValue: {}, + }, + { + provide: UsersService, + useValue: {}, + }, + { + provide: SorobanService, + useValue: sorobanService, + }, + { + provide: DataSource, + useValue: dataSource, + }, + ], + }).compile(); + + service = module.get(MarketsService); + }); + + it('should reject bulk creation with more than 10 markets', async () => { + const dtos = Array(11).fill(makeCreateDto()); + + // The validation happens at DTO level via class-validator + // So we just verify the service accepts up to 10 + const result = await service.createBulk(dtos.slice(0, 10), mockUser); + expect(result).toHaveLength(10); + }); + + it('should reject if any market has past end_time', async () => { + const dto = makeCreateDto(); + dto.end_time = new Date(Date.now() - 1000).toISOString(); + + await expect(service.createBulk([dto], mockUser)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should create multiple markets in transaction', async () => { + const dtos = [makeCreateDto(), makeCreateDto()]; + const mockQueryRunner = dataSource.createQueryRunner(); + + mockQueryRunner.manager.create.mockImplementation((entity, data) => ({ + ...data, + id: 'market-' + Math.random(), + })); + + mockQueryRunner.manager.save.mockResolvedValue({ + id: 'market-123', + on_chain_market_id: 'market_123', + }); + + const result = await service.createBulk(dtos, mockUser); + + expect(mockQueryRunner.startTransaction).toHaveBeenCalled(); + expect(mockQueryRunner.commitTransaction).toHaveBeenCalled(); + expect(result).toHaveLength(2); + }); + + it('should rollback transaction on Soroban failure', async () => { + const dtos = [makeCreateDto()]; + const mockQueryRunner = dataSource.createQueryRunner(); + + sorobanService.createMarket.mockRejectedValueOnce( + new Error('Soroban error'), + ); + + await expect(service.createBulk(dtos, mockUser)).rejects.toThrow( + BadGatewayException, + ); + + expect(mockQueryRunner.rollbackTransaction).toHaveBeenCalled(); + }); +}); diff --git a/backend/src/markets/markets.service.spec.ts b/backend/src/markets/markets.service.spec.ts index c6136104..713d5899 100644 --- a/backend/src/markets/markets.service.spec.ts +++ b/backend/src/markets/markets.service.spec.ts @@ -1,7 +1,7 @@ import { BadRequestException, ConflictException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Repository, DataSource } from 'typeorm'; import { SorobanService } from '../soroban/soroban.service'; import { UsersService } from '../users/users.service'; import { User } from '../users/entities/user.entity'; @@ -21,6 +21,7 @@ describe('MarketsService', () => { let sorobanService: jest.Mocked< Pick >; + let dataSource: jest.Mocked; const mockUser = { id: 'user-1', @@ -53,6 +54,20 @@ describe('MarketsService', () => { resolveMarket: jest.fn(), }; + dataSource = { + createQueryRunner: jest.fn().mockReturnValue({ + connect: jest.fn(), + startTransaction: jest.fn(), + commitTransaction: jest.fn(), + rollbackTransaction: jest.fn(), + release: jest.fn(), + manager: { + create: jest.fn(), + save: jest.fn(), + }, + }), + } as any; + const module: TestingModule = await Test.createTestingModule({ providers: [ MarketsService, @@ -76,6 +91,10 @@ describe('MarketsService', () => { provide: SorobanService, useValue: sorobanService, }, + { + provide: DataSource, + useValue: dataSource, + }, ], }).compile(); diff --git a/backend/src/markets/markets.service.ts b/backend/src/markets/markets.service.ts index 51f0d64e..02c154ef 100644 --- a/backend/src/markets/markets.service.ts +++ b/backend/src/markets/markets.service.ts @@ -8,7 +8,7 @@ import { } from '@nestjs/common'; import { PredictionStatsDto } from './dto/prediction-stats.dto'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Repository, DataSource } from 'typeorm'; import { Market } from './entities/market.entity'; import { Comment } from './entities/comment.entity'; import { MarketTemplate } from './entities/market-template.entity'; @@ -36,7 +36,8 @@ export class MarketsService { private readonly marketTemplatesRepository: Repository, private readonly usersService: UsersService, private readonly sorobanService: SorobanService, - ) {} + private readonly dataSource: DataSource, + ) { } /** * Get prediction statistics for a market - anonymous outcome counts only @@ -71,6 +72,80 @@ export class MarketsService { return this.createMarket(dto, user); } + /** + * Bulk create markets with transaction support. + * Validates all markets before creating any. + * Rolls back all if any creation fails. + */ + async createBulk(dtos: CreateMarketDto[], user: User): Promise { + // Validate all DTOs first + for (const dto of dtos) { + const endTime = new Date(dto.end_time); + if (endTime <= new Date()) { + throw new BadRequestException('end_time must be in the future'); + } + } + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const createdMarkets: Market[] = []; + + for (const dto of dtos) { + // Call Soroban contract + let onChainMarketId: string; + try { + const result = await this.sorobanService.createMarket( + dto.title, + dto.description, + dto.category, + dto.outcome_options, + dto.end_time, + dto.resolution_time, + ); + onChainMarketId = result.market_id; + } catch (err) { + this.logger.error('Soroban createMarket failed', err); + throw new BadGatewayException('Failed to create market on Soroban'); + } + + // Create market entity + const market = queryRunner.manager.create(Market, { + on_chain_market_id: onChainMarketId, + creator: user, + title: dto.title, + description: dto.description, + category: dto.category, + outcome_options: dto.outcome_options, + end_time: new Date(dto.end_time), + resolution_time: new Date(dto.resolution_time), + is_public: dto.is_public, + is_resolved: false, + is_cancelled: false, + total_pool_stroops: '0', + participant_count: 0, + }); + + const saved = await queryRunner.manager.save(market); + createdMarkets.push(saved); + this.logger.log( + `Bulk created market "${dto.title}" with on_chain_id: ${onChainMarketId}`, + ); + } + + await queryRunner.commitTransaction(); + return createdMarkets; + } catch (err) { + await queryRunner.rollbackTransaction(); + this.logger.error('Bulk market creation failed, rolling back', err); + throw err; + } finally { + await queryRunner.release(); + } + } + async createMarket(dto: CreateMarketDto, user: User): Promise { const endTime = new Date(dto.end_time); if (endTime <= new Date()) { @@ -352,4 +427,66 @@ export class MarketsService { order: { category: 'ASC', title: 'ASC' }, }); } -} + + /** + * Generate a detailed market report with anonymized predictions + */ + async generateMarketReport(marketId: string): Promise { + const market = await this.findByIdOrOnChainId(marketId); + + // Get prediction stats (anonymized) + const stats = await this.getPredictionStats(marketId); + + const outcomeDistribution = stats.map((stat) => ({ + outcome: stat.outcome, + count: stat.count, + percentage: + market.participant_count > 0 + ? ((stat.count / market.participant_count) * 100).toFixed(2) + : '0.00', + total_staked_stroops: stat.total_staked_stroops, + })); + + // Build timeline of events + const timeline = [ + { + timestamp: market.created_at, + event_type: 'market_created', + description: `Market "${market.title}" was created`, + }, + { + timestamp: market.end_time, + event_type: 'market_ended', + description: 'Market ended - no more predictions accepted', + }, + ]; + + if (market.is_resolved) { + timeline.push({ + timestamp: new Date(), // Use current time as resolution was just checked + event_type: 'market_resolved', + description: `Market resolved with outcome: ${market.resolved_outcome}`, + }); + } + + return { + market_id: market.id, + title: market.title, + description: market.description, + category: market.category, + created_at: market.created_at, + end_time: market.end_time, + resolution_time: market.resolution_time, + is_resolved: market.is_resolved, + resolved_outcome: market.resolved_outcome || null, + total_participants: market.participant_count, + total_pool_stroops: market.total_pool_stroops, + outcome_distribution: outcomeDistribution, + timeline: timeline.sort( + (a, b) => + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + ), + generated_at: new Date(), + }; + } +} \ No newline at end of file From 2b3c6937d63abe755233d1fb8ab85d17c2e50379 Mon Sep 17 00:00:00 2001 From: gideonodekina Date: Mon, 30 Mar 2026 00:05:33 +0100 Subject: [PATCH 2/2] fix: resolve eslint unsafe type errors in test files --- .../achievements/achievements.controller.ts | 32 +- .../src/achievements/achievements.module.ts | 16 +- .../achievements/achievements.service.spec.ts | 204 ++++----- .../src/achievements/achievements.service.ts | 392 +++++++++--------- .../dto/achievement-response.dto.ts | 32 +- .../entities/achievement.entity.ts | 58 +-- .../entities/user-achievement.entity.ts | 40 +- backend/src/analytics/analytics.controller.ts | 2 +- backend/src/analytics/analytics.module.ts | 2 +- .../analytics.service.history.spec.ts | 226 +++++----- backend/src/analytics/analytics.service.ts | 4 +- .../src/analytics/dto/market-history.dto.ts | 36 +- .../entities/market-history.entity.ts | 48 +-- backend/src/app.module.ts | 2 +- .../markets/dto/bulk-create-markets.dto.ts | 31 +- backend/src/markets/dto/market-report.dto.ts | 84 ++-- backend/src/markets/markets.controller.ts | 17 +- .../src/markets/markets.service.bulk.spec.ts | 283 ++++++------- backend/src/markets/markets.service.ts | 4 +- backend/src/users/users.service.spec.ts | 42 +- 20 files changed, 799 insertions(+), 756 deletions(-) diff --git a/backend/src/achievements/achievements.controller.ts b/backend/src/achievements/achievements.controller.ts index 6409edd0..0cd8a3bf 100644 --- a/backend/src/achievements/achievements.controller.ts +++ b/backend/src/achievements/achievements.controller.ts @@ -7,21 +7,21 @@ import { AchievementResponseDto } from './dto/achievement-response.dto'; @ApiTags('Achievements') @Controller('users/:address/achievements') export class AchievementsController { - constructor(private readonly achievementsService: AchievementsService) { } + constructor(private readonly achievementsService: AchievementsService) {} - @Get() - @Public() - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Get user achievements and badges' }) - @ApiResponse({ - status: 200, - description: 'List of achievements with unlock status', - type: [AchievementResponseDto], - }) - @ApiResponse({ status: 404, description: 'User not found' }) - async getUserAchievements( - @Param('address') address: string, - ): Promise { - return this.achievementsService.getUserAchievements(address); - } + @Get() + @Public() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Get user achievements and badges' }) + @ApiResponse({ + status: 200, + description: 'List of achievements with unlock status', + type: [AchievementResponseDto], + }) + @ApiResponse({ status: 404, description: 'User not found' }) + async getUserAchievements( + @Param('address') address: string, + ): Promise { + return this.achievementsService.getUserAchievements(address); + } } diff --git a/backend/src/achievements/achievements.module.ts b/backend/src/achievements/achievements.module.ts index 2908b4ac..3d23d946 100644 --- a/backend/src/achievements/achievements.module.ts +++ b/backend/src/achievements/achievements.module.ts @@ -7,15 +7,15 @@ import { AchievementsController } from './achievements.controller'; import { User } from '../users/entities/user.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Achievement, UserAchievement, User])], - providers: [AchievementsService], - controllers: [AchievementsController], - exports: [AchievementsService], + imports: [TypeOrmModule.forFeature([Achievement, UserAchievement, User])], + providers: [AchievementsService], + controllers: [AchievementsController], + exports: [AchievementsService], }) export class AchievementsModule implements OnModuleInit { - constructor(private readonly achievementsService: AchievementsService) { } + constructor(private readonly achievementsService: AchievementsService) {} - async onModuleInit(): Promise { - await this.achievementsService.initializeAchievements(); - } + async onModuleInit(): Promise { + await this.achievementsService.initializeAchievements(); + } } diff --git a/backend/src/achievements/achievements.service.spec.ts b/backend/src/achievements/achievements.service.spec.ts index 7225a85f..ff48b1f3 100644 --- a/backend/src/achievements/achievements.service.spec.ts +++ b/backend/src/achievements/achievements.service.spec.ts @@ -7,106 +7,106 @@ import { UserAchievement } from './entities/user-achievement.entity'; import { User } from '../users/entities/user.entity'; describe('AchievementsService', () => { - let service: AchievementsService; - let achievementsRepository: jest.Mocked>; - let userAchievementsRepository: jest.Mocked>; - let usersRepository: jest.Mocked>; - - const mockUser = { - id: 'user-1', - stellar_address: 'GABC123', - total_predictions: 10, - correct_predictions: 9, - total_staked_stroops: '5000000', - reputation_score: 600, - } as User; - - beforeEach(async () => { - achievementsRepository = { - count: jest.fn().mockResolvedValue(0), - save: jest.fn(), - find: jest.fn(), - findOne: jest.fn(), - } as any; - - userAchievementsRepository = { - find: jest.fn(), - findOne: jest.fn(), - save: jest.fn(), - } as any; - - usersRepository = { - findOne: jest.fn().mockResolvedValue(mockUser), - } as any; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - AchievementsService, - { - provide: getRepositoryToken(Achievement), - useValue: achievementsRepository, - }, - { - provide: getRepositoryToken(UserAchievement), - useValue: userAchievementsRepository, - }, - { - provide: getRepositoryToken(User), - useValue: usersRepository, - }, - ], - }).compile(); - - service = module.get(AchievementsService); - }); - - it('should initialize achievements on first call', async () => { - await service.initializeAchievements(); - expect(achievementsRepository.save).toHaveBeenCalled(); - }); - - it('should check and unlock achievements for user', async () => { - const mockAchievement = { - id: 'ach-1', - type: AchievementType.FIRST_PREDICTION, - title: 'First Step', - } as Achievement; - - achievementsRepository.findOne.mockResolvedValue(mockAchievement); - userAchievementsRepository.findOne.mockResolvedValue(null); - - await service.checkAndUnlockAchievements(mockUser); - - expect(userAchievementsRepository.save).toHaveBeenCalled(); - }); - - it('should get user achievements', async () => { - const mockAchievements = [ - { - id: 'ach-1', - type: AchievementType.FIRST_PREDICTION, - title: 'First Step', - description: 'Make your first prediction', - icon_url: null, - reward_points: 10, - }, - ] as Achievement[]; - - const mockUserAchievements = [ - { - achievement: mockAchievements[0], - is_unlocked: true, - unlocked_at: new Date(), - }, - ] as UserAchievement[]; - - usersRepository.findOne.mockResolvedValue(mockUser); - userAchievementsRepository.find.mockResolvedValue(mockUserAchievements); - achievementsRepository.find.mockResolvedValue(mockAchievements); - - const result = await service.getUserAchievements(mockUser.stellar_address); - - expect(result).toHaveLength(1); - expect(result[0].is_unlocked).toBe(true); - }); + let service: AchievementsService; + let achievementsRepository: jest.Mocked>; + let userAchievementsRepository: jest.Mocked>; + let usersRepository: jest.Mocked>; + + const mockUser = { + id: 'user-1', + stellar_address: 'GABC123', + total_predictions: 10, + correct_predictions: 9, + total_staked_stroops: '5000000', + reputation_score: 600, + } as User; + + beforeEach(async () => { + achievementsRepository = { + count: jest.fn().mockResolvedValue(0), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + } as any; + + userAchievementsRepository = { + find: jest.fn(), + findOne: jest.fn(), + save: jest.fn(), + } as any; + + usersRepository = { + findOne: jest.fn().mockResolvedValue(mockUser), + } as any; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AchievementsService, + { + provide: getRepositoryToken(Achievement), + useValue: achievementsRepository, + }, + { + provide: getRepositoryToken(UserAchievement), + useValue: userAchievementsRepository, + }, + { + provide: getRepositoryToken(User), + useValue: usersRepository, + }, + ], + }).compile(); + + service = module.get(AchievementsService); + }); + + it('should initialize achievements on first call', async () => { + await service.initializeAchievements(); + expect(achievementsRepository.save).toHaveBeenCalled(); + }); + + it('should check and unlock achievements for user', async () => { + const mockAchievement = { + id: 'ach-1', + type: AchievementType.FIRST_PREDICTION, + title: 'First Step', + } as Achievement; + + achievementsRepository.findOne.mockResolvedValue(mockAchievement); + userAchievementsRepository.findOne.mockResolvedValue(null); + + await service.checkAndUnlockAchievements(mockUser); + + expect(userAchievementsRepository.save).toHaveBeenCalled(); + }); + + it('should get user achievements', async () => { + const mockAchievements = [ + { + id: 'ach-1', + type: AchievementType.FIRST_PREDICTION, + title: 'First Step', + description: 'Make your first prediction', + icon_url: null, + reward_points: 10, + }, + ] as Achievement[]; + + const mockUserAchievements = [ + { + achievement: mockAchievements[0], + is_unlocked: true, + unlocked_at: new Date(), + }, + ] as UserAchievement[]; + + usersRepository.findOne.mockResolvedValue(mockUser); + userAchievementsRepository.find.mockResolvedValue(mockUserAchievements); + achievementsRepository.find.mockResolvedValue(mockAchievements); + + const result = await service.getUserAchievements(mockUser.stellar_address); + + expect(result).toHaveLength(1); + expect(result[0].is_unlocked).toBe(true); + }); }); diff --git a/backend/src/achievements/achievements.service.ts b/backend/src/achievements/achievements.service.ts index 153550b0..58792e09 100644 --- a/backend/src/achievements/achievements.service.ts +++ b/backend/src/achievements/achievements.service.ts @@ -8,208 +8,208 @@ import { AchievementResponseDto } from './dto/achievement-response.dto'; @Injectable() export class AchievementsService { - private readonly logger = new Logger(AchievementsService.name); - - constructor( - @InjectRepository(Achievement) - private readonly achievementsRepository: Repository, - @InjectRepository(UserAchievement) - private readonly userAchievementsRepository: Repository, - @InjectRepository(User) - private readonly usersRepository: Repository, - ) { } - - async initializeAchievements(): Promise { - const count = await this.achievementsRepository.count(); - if (count > 0) return; - - const achievements = [ - { - type: AchievementType.FIRST_PREDICTION, - title: 'First Step', - description: 'Make your first prediction', - reward_points: 10, - }, - { - type: AchievementType.CORRECT_PREDICTIONS_10, - title: 'Rising Star', - description: 'Get 10 correct predictions', - reward_points: 50, - }, - { - type: AchievementType.CORRECT_PREDICTIONS_50, - title: 'Seasoned Predictor', - description: 'Get 50 correct predictions', - reward_points: 150, - }, - { - type: AchievementType.CORRECT_PREDICTIONS_100, - title: 'Master Predictor', - description: 'Get 100 correct predictions', - reward_points: 300, - }, - { - type: AchievementType.ACCURACY_75, - title: 'Accurate Mind', - description: 'Achieve 75% prediction accuracy', - reward_points: 100, - }, - { - type: AchievementType.ACCURACY_90, - title: 'Legendary Accuracy', - description: 'Achieve 90% prediction accuracy', - reward_points: 250, - }, - { - type: AchievementType.TOTAL_STAKED_1M, - title: 'High Roller', - description: 'Stake 1,000,000 stroops total', - reward_points: 75, - }, - { - type: AchievementType.TOTAL_STAKED_10M, - title: 'Whale Predictor', - description: 'Stake 10,000,000 stroops total', - reward_points: 200, - }, - { - type: AchievementType.REPUTATION_500, - title: 'Respected Voice', - description: 'Reach 500 reputation score', - reward_points: 100, - }, - { - type: AchievementType.REPUTATION_1000, - title: 'Community Legend', - description: 'Reach 1000 reputation score', - reward_points: 300, - }, - ]; - - for (const achievement of achievements) { - await this.achievementsRepository.save(achievement); - } - - this.logger.log(`Initialized ${achievements.length} achievements`); + private readonly logger = new Logger(AchievementsService.name); + + constructor( + @InjectRepository(Achievement) + private readonly achievementsRepository: Repository, + @InjectRepository(UserAchievement) + private readonly userAchievementsRepository: Repository, + @InjectRepository(User) + private readonly usersRepository: Repository, + ) {} + + async initializeAchievements(): Promise { + const count = await this.achievementsRepository.count(); + if (count > 0) return; + + const achievements = [ + { + type: AchievementType.FIRST_PREDICTION, + title: 'First Step', + description: 'Make your first prediction', + reward_points: 10, + }, + { + type: AchievementType.CORRECT_PREDICTIONS_10, + title: 'Rising Star', + description: 'Get 10 correct predictions', + reward_points: 50, + }, + { + type: AchievementType.CORRECT_PREDICTIONS_50, + title: 'Seasoned Predictor', + description: 'Get 50 correct predictions', + reward_points: 150, + }, + { + type: AchievementType.CORRECT_PREDICTIONS_100, + title: 'Master Predictor', + description: 'Get 100 correct predictions', + reward_points: 300, + }, + { + type: AchievementType.ACCURACY_75, + title: 'Accurate Mind', + description: 'Achieve 75% prediction accuracy', + reward_points: 100, + }, + { + type: AchievementType.ACCURACY_90, + title: 'Legendary Accuracy', + description: 'Achieve 90% prediction accuracy', + reward_points: 250, + }, + { + type: AchievementType.TOTAL_STAKED_1M, + title: 'High Roller', + description: 'Stake 1,000,000 stroops total', + reward_points: 75, + }, + { + type: AchievementType.TOTAL_STAKED_10M, + title: 'Whale Predictor', + description: 'Stake 10,000,000 stroops total', + reward_points: 200, + }, + { + type: AchievementType.REPUTATION_500, + title: 'Respected Voice', + description: 'Reach 500 reputation score', + reward_points: 100, + }, + { + type: AchievementType.REPUTATION_1000, + title: 'Community Legend', + description: 'Reach 1000 reputation score', + reward_points: 300, + }, + ]; + + for (const achievement of achievements) { + await this.achievementsRepository.save(achievement); } - async checkAndUnlockAchievements(user: User): Promise { - const fullUser = await this.usersRepository.findOne({ - where: { id: user.id }, + this.logger.log(`Initialized ${achievements.length} achievements`); + } + + async checkAndUnlockAchievements(user: User): Promise { + const fullUser = await this.usersRepository.findOne({ + where: { id: user.id }, + }); + + if (!fullUser) return; + + const achievementsToCheck = [ + { + type: AchievementType.FIRST_PREDICTION, + condition: fullUser.total_predictions >= 1, + }, + { + type: AchievementType.CORRECT_PREDICTIONS_10, + condition: fullUser.correct_predictions >= 10, + }, + { + type: AchievementType.CORRECT_PREDICTIONS_50, + condition: fullUser.correct_predictions >= 50, + }, + { + type: AchievementType.CORRECT_PREDICTIONS_100, + condition: fullUser.correct_predictions >= 100, + }, + { + type: AchievementType.ACCURACY_75, + condition: + fullUser.total_predictions > 0 && + (fullUser.correct_predictions / fullUser.total_predictions) * 100 >= + 75, + }, + { + type: AchievementType.ACCURACY_90, + condition: + fullUser.total_predictions > 0 && + (fullUser.correct_predictions / fullUser.total_predictions) * 100 >= + 90, + }, + { + type: AchievementType.TOTAL_STAKED_1M, + condition: BigInt(fullUser.total_staked_stroops) >= BigInt(1000000), + }, + { + type: AchievementType.TOTAL_STAKED_10M, + condition: BigInt(fullUser.total_staked_stroops) >= BigInt(10000000), + }, + { + type: AchievementType.REPUTATION_500, + condition: fullUser.reputation_score >= 500, + }, + { + type: AchievementType.REPUTATION_1000, + condition: fullUser.reputation_score >= 1000, + }, + ]; + + for (const { type, condition } of achievementsToCheck) { + if (!condition) continue; + + const achievement = await this.achievementsRepository.findOne({ + where: { type }, + }); + + if (!achievement) continue; + + const existing = await this.userAchievementsRepository.findOne({ + where: { user: { id: user.id }, achievement: { id: achievement.id } }, + }); + + if (!existing) { + await this.userAchievementsRepository.save({ + user, + achievement, + is_unlocked: true, + unlocked_at: new Date(), }); - if (!fullUser) return; - - const achievementsToCheck = [ - { - type: AchievementType.FIRST_PREDICTION, - condition: fullUser.total_predictions >= 1, - }, - { - type: AchievementType.CORRECT_PREDICTIONS_10, - condition: fullUser.correct_predictions >= 10, - }, - { - type: AchievementType.CORRECT_PREDICTIONS_50, - condition: fullUser.correct_predictions >= 50, - }, - { - type: AchievementType.CORRECT_PREDICTIONS_100, - condition: fullUser.correct_predictions >= 100, - }, - { - type: AchievementType.ACCURACY_75, - condition: - fullUser.total_predictions > 0 && - (fullUser.correct_predictions / fullUser.total_predictions) * 100 >= - 75, - }, - { - type: AchievementType.ACCURACY_90, - condition: - fullUser.total_predictions > 0 && - (fullUser.correct_predictions / fullUser.total_predictions) * 100 >= - 90, - }, - { - type: AchievementType.TOTAL_STAKED_1M, - condition: BigInt(fullUser.total_staked_stroops) >= BigInt(1000000), - }, - { - type: AchievementType.TOTAL_STAKED_10M, - condition: BigInt(fullUser.total_staked_stroops) >= BigInt(10000000), - }, - { - type: AchievementType.REPUTATION_500, - condition: fullUser.reputation_score >= 500, - }, - { - type: AchievementType.REPUTATION_1000, - condition: fullUser.reputation_score >= 1000, - }, - ]; - - for (const { type, condition } of achievementsToCheck) { - if (!condition) continue; - - const achievement = await this.achievementsRepository.findOne({ - where: { type }, - }); - - if (!achievement) continue; - - const existing = await this.userAchievementsRepository.findOne({ - where: { user: { id: user.id }, achievement: { id: achievement.id } }, - }); - - if (!existing) { - await this.userAchievementsRepository.save({ - user, - achievement, - is_unlocked: true, - unlocked_at: new Date(), - }); - - this.logger.log( - `Unlocked achievement "${achievement.title}" for user ${user.id}`, - ); - } - } + this.logger.log( + `Unlocked achievement "${achievement.title}" for user ${user.id}`, + ); + } } + } - async getUserAchievements( - userAddress: string, - ): Promise { - const user = await this.usersRepository.findOne({ - where: { stellar_address: userAddress }, - }); - - if (!user) { - throw new NotFoundException('User not found'); - } - - const userAchievements = await this.userAchievementsRepository.find({ - where: { user: { id: user.id } }, - relations: ['achievement'], - }); + async getUserAchievements( + userAddress: string, + ): Promise { + const user = await this.usersRepository.findOne({ + where: { stellar_address: userAddress }, + }); - const allAchievements = await this.achievementsRepository.find(); - - return allAchievements.map((achievement) => { - const userAchievement = userAchievements.find( - (ua) => ua.achievement.id === achievement.id, - ); - - return { - id: achievement.id, - type: achievement.type, - title: achievement.title, - description: achievement.description, - icon_url: achievement.icon_url, - reward_points: achievement.reward_points, - is_unlocked: !!userAchievement?.is_unlocked, - unlocked_at: userAchievement?.unlocked_at || null, - }; - }); + if (!user) { + throw new NotFoundException('User not found'); } + + const userAchievements = await this.userAchievementsRepository.find({ + where: { user: { id: user.id } }, + relations: ['achievement'], + }); + + const allAchievements = await this.achievementsRepository.find(); + + return allAchievements.map((achievement) => { + const userAchievement = userAchievements.find( + (ua) => ua.achievement.id === achievement.id, + ); + + return { + id: achievement.id, + type: achievement.type, + title: achievement.title, + description: achievement.description, + icon_url: achievement.icon_url, + reward_points: achievement.reward_points, + is_unlocked: !!userAchievement?.is_unlocked, + unlocked_at: userAchievement?.unlocked_at || null, + }; + }); + } } diff --git a/backend/src/achievements/dto/achievement-response.dto.ts b/backend/src/achievements/dto/achievement-response.dto.ts index dce2b19b..b0bca369 100644 --- a/backend/src/achievements/dto/achievement-response.dto.ts +++ b/backend/src/achievements/dto/achievement-response.dto.ts @@ -2,27 +2,27 @@ import { ApiProperty } from '@nestjs/swagger'; import { AchievementType } from '../entities/achievement.entity'; export class AchievementResponseDto { - @ApiProperty() - id: string; + @ApiProperty() + id: string; - @ApiProperty({ enum: AchievementType }) - type: AchievementType; + @ApiProperty({ enum: AchievementType }) + type: AchievementType; - @ApiProperty() - title: string; + @ApiProperty() + title: string; - @ApiProperty() - description: string; + @ApiProperty() + description: string; - @ApiProperty({ nullable: true }) - icon_url: string | null; + @ApiProperty({ nullable: true }) + icon_url: string | null; - @ApiProperty() - reward_points: number; + @ApiProperty() + reward_points: number; - @ApiProperty() - is_unlocked: boolean; + @ApiProperty() + is_unlocked: boolean; - @ApiProperty({ nullable: true }) - unlocked_at: Date | null; + @ApiProperty({ nullable: true }) + unlocked_at: Date | null; } diff --git a/backend/src/achievements/entities/achievement.entity.ts b/backend/src/achievements/entities/achievement.entity.ts index 87049ace..8e813ce6 100644 --- a/backend/src/achievements/entities/achievement.entity.ts +++ b/backend/src/achievements/entities/achievement.entity.ts @@ -1,45 +1,45 @@ import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - Index, + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, } from 'typeorm'; export enum AchievementType { - FIRST_PREDICTION = 'first_prediction', - CORRECT_PREDICTIONS_10 = 'correct_predictions_10', - CORRECT_PREDICTIONS_50 = 'correct_predictions_50', - CORRECT_PREDICTIONS_100 = 'correct_predictions_100', - ACCURACY_75 = 'accuracy_75', - ACCURACY_90 = 'accuracy_90', - TOTAL_STAKED_1M = 'total_staked_1m', - TOTAL_STAKED_10M = 'total_staked_10m', - REPUTATION_500 = 'reputation_500', - REPUTATION_1000 = 'reputation_1000', + FIRST_PREDICTION = 'first_prediction', + CORRECT_PREDICTIONS_10 = 'correct_predictions_10', + CORRECT_PREDICTIONS_50 = 'correct_predictions_50', + CORRECT_PREDICTIONS_100 = 'correct_predictions_100', + ACCURACY_75 = 'accuracy_75', + ACCURACY_90 = 'accuracy_90', + TOTAL_STAKED_1M = 'total_staked_1m', + TOTAL_STAKED_10M = 'total_staked_10m', + REPUTATION_500 = 'reputation_500', + REPUTATION_1000 = 'reputation_1000', } @Entity('achievements') @Index(['type']) export class Achievement { - @PrimaryGeneratedColumn('uuid') - id: string; + @PrimaryGeneratedColumn('uuid') + id: string; - @Column({ type: 'enum', enum: AchievementType }) - type: AchievementType; + @Column({ type: 'enum', enum: AchievementType }) + type: AchievementType; - @Column() - title: string; + @Column() + title: string; - @Column() - description: string; + @Column() + description: string; - @Column({ nullable: true }) - icon_url: string; + @Column({ nullable: true }) + icon_url: string; - @Column({ default: 0 }) - reward_points: number; + @Column({ default: 0 }) + reward_points: number; - @CreateDateColumn() - created_at: Date; + @CreateDateColumn() + created_at: Date; } diff --git a/backend/src/achievements/entities/user-achievement.entity.ts b/backend/src/achievements/entities/user-achievement.entity.ts index 36bd958c..fe7e6492 100644 --- a/backend/src/achievements/entities/user-achievement.entity.ts +++ b/backend/src/achievements/entities/user-achievement.entity.ts @@ -1,12 +1,12 @@ import { - Entity, - PrimaryGeneratedColumn, - Column, - ManyToOne, - CreateDateColumn, - Index, - JoinColumn, - Unique, + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + Index, + JoinColumn, + Unique, } from 'typeorm'; import { User } from '../../users/entities/user.entity'; import { Achievement } from './achievement.entity'; @@ -16,20 +16,20 @@ import { Achievement } from './achievement.entity'; @Index(['user']) @Index(['achievement']) export class UserAchievement { - @PrimaryGeneratedColumn('uuid') - id: string; + @PrimaryGeneratedColumn('uuid') + id: string; - @ManyToOne(() => User, { onDelete: 'CASCADE', eager: false }) - @JoinColumn({ name: 'userId' }) - user: User; + @ManyToOne(() => User, { onDelete: 'CASCADE', eager: false }) + @JoinColumn({ name: 'userId' }) + user: User; - @ManyToOne(() => Achievement, { onDelete: 'CASCADE', eager: false }) - @JoinColumn({ name: 'achievementId' }) - achievement: Achievement; + @ManyToOne(() => Achievement, { onDelete: 'CASCADE', eager: false }) + @JoinColumn({ name: 'achievementId' }) + achievement: Achievement; - @Column({ default: false }) - is_unlocked: boolean; + @Column({ default: false }) + is_unlocked: boolean; - @CreateDateColumn() - unlocked_at: Date; + @CreateDateColumn() + unlocked_at: Date; } diff --git a/backend/src/analytics/analytics.controller.ts b/backend/src/analytics/analytics.controller.ts index 9f40f0a7..2351a820 100644 --- a/backend/src/analytics/analytics.controller.ts +++ b/backend/src/analytics/analytics.controller.ts @@ -16,7 +16,7 @@ import { MarketHistoryResponseDto } from './dto/market-history.dto'; @ApiTags('Analytics') @Controller('analytics') export class AnalyticsController { - constructor(private readonly analyticsService: AnalyticsService) { } + constructor(private readonly analyticsService: AnalyticsService) {} @Get('dashboard') @ApiBearerAuth() diff --git a/backend/src/analytics/analytics.module.ts b/backend/src/analytics/analytics.module.ts index ffc5b756..4452f626 100644 --- a/backend/src/analytics/analytics.module.ts +++ b/backend/src/analytics/analytics.module.ts @@ -24,4 +24,4 @@ import { MarketHistory } from './entities/market-history.entity'; providers: [AnalyticsService], exports: [AnalyticsService], }) -export class AnalyticsModule { } +export class AnalyticsModule {} diff --git a/backend/src/analytics/analytics.service.history.spec.ts b/backend/src/analytics/analytics.service.history.spec.ts index 8ba17a68..2b8ded25 100644 --- a/backend/src/analytics/analytics.service.history.spec.ts +++ b/backend/src/analytics/analytics.service.history.spec.ts @@ -10,118 +10,118 @@ import { LeaderboardEntry } from '../leaderboard/entities/leaderboard-entry.enti import { ActivityLog } from './entities/activity-log.entity'; describe('AnalyticsService - Market History', () => { - let service: AnalyticsService; - let marketHistoryRepository: jest.Mocked>; - let marketsRepository: jest.Mocked>; - let predictionsRepository: jest.Mocked>; - - const mockMarket = { - id: 'market-1', - on_chain_market_id: 'market_123', - title: 'Test Market', - outcome_options: ['YES', 'NO'], + let service: AnalyticsService; + let marketHistoryRepository: jest.Mocked>; + let marketsRepository: jest.Mocked>; + let predictionsRepository: jest.Mocked>; + + const mockMarket = { + id: 'market-1', + on_chain_market_id: 'market_123', + title: 'Test Market', + outcome_options: ['YES', 'NO'], + participant_count: 10, + total_pool_stroops: '5000000', + created_at: new Date(), + } as Market; + + beforeEach(async () => { + marketHistoryRepository = { + find: jest.fn(), + create: jest.fn(), + save: jest.fn(), + } as any; + + marketsRepository = { + findOne: jest.fn().mockResolvedValue(mockMarket), + } as any; + + predictionsRepository = { + find: jest.fn().mockResolvedValue([]), + } as any; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AnalyticsService, + { + provide: getRepositoryToken(User), + useValue: {}, + }, + { + provide: getRepositoryToken(Prediction), + useValue: predictionsRepository, + }, + { + provide: getRepositoryToken(LeaderboardEntry), + useValue: {}, + }, + { + provide: getRepositoryToken(Market), + useValue: marketsRepository, + }, + { + provide: getRepositoryToken(ActivityLog), + useValue: {}, + }, + { + provide: getRepositoryToken(MarketHistory), + useValue: marketHistoryRepository, + }, + ], + }).compile(); + + service = module.get(AnalyticsService); + }); + + it('should get market history', async () => { + const mockHistory = [ + { + recorded_at: new Date(), + prediction_volume: 5, + pool_size_stroops: '2500000', + participant_count: 5, + outcome_probabilities: ['50.00', '50.00'], + }, + { + recorded_at: new Date(Date.now() + 1000), + prediction_volume: 10, + pool_size_stroops: '5000000', participant_count: 10, - total_pool_stroops: '5000000', - created_at: new Date(), - } as Market; - - beforeEach(async () => { - marketHistoryRepository = { - find: jest.fn(), - create: jest.fn(), - save: jest.fn(), - } as any; - - marketsRepository = { - findOne: jest.fn().mockResolvedValue(mockMarket), - } as any; - - predictionsRepository = { - find: jest.fn().mockResolvedValue([]), - } as any; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - AnalyticsService, - { - provide: getRepositoryToken(User), - useValue: {}, - }, - { - provide: getRepositoryToken(Prediction), - useValue: predictionsRepository, - }, - { - provide: getRepositoryToken(LeaderboardEntry), - useValue: {}, - }, - { - provide: getRepositoryToken(Market), - useValue: marketsRepository, - }, - { - provide: getRepositoryToken(ActivityLog), - useValue: {}, - }, - { - provide: getRepositoryToken(MarketHistory), - useValue: marketHistoryRepository, - }, - ], - }).compile(); - - service = module.get(AnalyticsService); - }); - - it('should get market history', async () => { - const mockHistory = [ - { - recorded_at: new Date(), - prediction_volume: 5, - pool_size_stroops: '2500000', - participant_count: 5, - outcome_probabilities: ['50.00', '50.00'], - }, - { - recorded_at: new Date(Date.now() + 1000), - prediction_volume: 10, - pool_size_stroops: '5000000', - participant_count: 10, - outcome_probabilities: ['60.00', '40.00'], - }, - ] as MarketHistory[]; - - marketHistoryRepository.find.mockResolvedValue(mockHistory); - - const result = await service.getMarketHistory('market-1'); - - expect(result.market_id).toBe('market-1'); - expect(result.title).toBe('Test Market'); - expect(result.history).toHaveLength(2); - expect(result.history[0].prediction_volume).toBe(5); - }); - - it('should record market snapshot', async () => { - marketHistoryRepository.create.mockReturnValue({ - market: mockMarket, - recorded_at: new Date(), - prediction_volume: 10, - pool_size_stroops: '5000000', - participant_count: 10, - outcome_probabilities: ['50.00', '50.00'], - } as MarketHistory); - - marketHistoryRepository.save.mockResolvedValue({} as MarketHistory); - - await service.recordMarketSnapshot(mockMarket); - - expect(marketHistoryRepository.create).toHaveBeenCalled(); - expect(marketHistoryRepository.save).toHaveBeenCalled(); - }); - - it('should throw NotFoundException for non-existent market', async () => { - marketsRepository.findOne.mockResolvedValue(null); - - await expect(service.getMarketHistory('invalid-id')).rejects.toThrow(); - }); + outcome_probabilities: ['60.00', '40.00'], + }, + ] as MarketHistory[]; + + marketHistoryRepository.find.mockResolvedValue(mockHistory); + + const result = await service.getMarketHistory('market-1'); + + expect(result.market_id).toBe('market-1'); + expect(result.title).toBe('Test Market'); + expect(result.history).toHaveLength(2); + expect(result.history[0].prediction_volume).toBe(5); + }); + + it('should record market snapshot', async () => { + marketHistoryRepository.create.mockReturnValue({ + market: mockMarket, + recorded_at: new Date(), + prediction_volume: 10, + pool_size_stroops: '5000000', + participant_count: 10, + outcome_probabilities: ['50.00', '50.00'], + } as MarketHistory); + + marketHistoryRepository.save.mockResolvedValue({} as MarketHistory); + + await service.recordMarketSnapshot(mockMarket); + + expect(marketHistoryRepository.create).toHaveBeenCalled(); + expect(marketHistoryRepository.save).toHaveBeenCalled(); + }); + + it('should throw NotFoundException for non-existent market', async () => { + marketsRepository.findOne.mockResolvedValue(null); + + await expect(service.getMarketHistory('invalid-id')).rejects.toThrow(); + }); }); diff --git a/backend/src/analytics/analytics.service.ts b/backend/src/analytics/analytics.service.ts index 44405925..87af4253 100644 --- a/backend/src/analytics/analytics.service.ts +++ b/backend/src/analytics/analytics.service.ts @@ -44,7 +44,7 @@ export class AnalyticsService { private readonly activityLogsRepository: Repository, @InjectRepository(MarketHistory) private readonly marketHistoryRepository: Repository, - ) { } + ) {} async logActivity( userId: string, @@ -255,4 +255,4 @@ export class AnalyticsService { await this.marketHistoryRepository.save(snapshot); } -} \ No newline at end of file +} diff --git a/backend/src/analytics/dto/market-history.dto.ts b/backend/src/analytics/dto/market-history.dto.ts index 2bf644b8..faaf8e5d 100644 --- a/backend/src/analytics/dto/market-history.dto.ts +++ b/backend/src/analytics/dto/market-history.dto.ts @@ -1,32 +1,32 @@ import { ApiProperty } from '@nestjs/swagger'; export class MarketHistoryPointDto { - @ApiProperty() - timestamp: Date; + @ApiProperty() + timestamp: Date; - @ApiProperty() - prediction_volume: number; + @ApiProperty() + prediction_volume: number; - @ApiProperty() - pool_size_stroops: string; + @ApiProperty() + pool_size_stroops: string; - @ApiProperty() - participant_count: number; + @ApiProperty() + participant_count: number; - @ApiProperty({ type: [Number], nullable: true }) - outcome_probabilities: number[] | null; + @ApiProperty({ type: [Number], nullable: true }) + outcome_probabilities: number[] | null; } export class MarketHistoryResponseDto { - @ApiProperty() - market_id: string; + @ApiProperty() + market_id: string; - @ApiProperty() - title: string; + @ApiProperty() + title: string; - @ApiProperty({ type: [MarketHistoryPointDto] }) - history: MarketHistoryPointDto[]; + @ApiProperty({ type: [MarketHistoryPointDto] }) + history: MarketHistoryPointDto[]; - @ApiProperty() - generated_at: Date; + @ApiProperty() + generated_at: Date; } diff --git a/backend/src/analytics/entities/market-history.entity.ts b/backend/src/analytics/entities/market-history.entity.ts index c80fa868..8cf2ff95 100644 --- a/backend/src/analytics/entities/market-history.entity.ts +++ b/backend/src/analytics/entities/market-history.entity.ts @@ -1,11 +1,11 @@ import { - Entity, - PrimaryGeneratedColumn, - Column, - ManyToOne, - CreateDateColumn, - Index, - JoinColumn, + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + Index, + JoinColumn, } from 'typeorm'; import { Market } from '../../markets/entities/market.entity'; @@ -13,28 +13,28 @@ import { Market } from '../../markets/entities/market.entity'; @Index(['market', 'recorded_at']) @Index(['market']) export class MarketHistory { - @PrimaryGeneratedColumn('uuid') - id: string; + @PrimaryGeneratedColumn('uuid') + id: string; - @ManyToOne(() => Market, { onDelete: 'CASCADE', eager: false }) - @JoinColumn({ name: 'marketId' }) - market: Market; + @ManyToOne(() => Market, { onDelete: 'CASCADE', eager: false }) + @JoinColumn({ name: 'marketId' }) + market: Market; - @Column({ type: 'timestamptz' }) - recorded_at: Date; + @Column({ type: 'timestamptz' }) + recorded_at: Date; - @Column({ default: 0 }) - prediction_volume: number; + @Column({ default: 0 }) + prediction_volume: number; - @Column({ type: 'bigint', default: '0' }) - pool_size_stroops: string; + @Column({ type: 'bigint', default: '0' }) + pool_size_stroops: string; - @Column({ default: 0 }) - participant_count: number; + @Column({ default: 0 }) + participant_count: number; - @Column('simple-array', { nullable: true }) - outcome_probabilities: string[]; + @Column('simple-array', { nullable: true }) + outcome_probabilities: string[]; - @CreateDateColumn() - created_at: Date; + @CreateDateColumn() + created_at: Date; } diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index d9dc11b1..861e67f1 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -100,4 +100,4 @@ import { UsersModule } from './users/users.module'; }, ], }) -export class AppModule { } +export class AppModule {} diff --git a/backend/src/markets/dto/bulk-create-markets.dto.ts b/backend/src/markets/dto/bulk-create-markets.dto.ts index 6871695e..a8572c17 100644 --- a/backend/src/markets/dto/bulk-create-markets.dto.ts +++ b/backend/src/markets/dto/bulk-create-markets.dto.ts @@ -1,19 +1,24 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsArray, ArrayMinSize, ArrayMaxSize, ValidateNested } from 'class-validator'; +import { + IsArray, + ArrayMinSize, + ArrayMaxSize, + ValidateNested, +} from 'class-validator'; import { Type } from 'class-transformer'; import { CreateMarketDto } from './create-market.dto'; export class BulkCreateMarketsDto { - @ApiProperty({ - description: 'Array of market DTOs to create (max 10)', - type: [CreateMarketDto], - minItems: 1, - maxItems: 10, - }) - @IsArray() - @ArrayMinSize(1, { message: 'At least 1 market is required' }) - @ArrayMaxSize(10, { message: 'Maximum 10 markets per request' }) - @ValidateNested({ each: true }) - @Type(() => CreateMarketDto) - markets: CreateMarketDto[]; + @ApiProperty({ + description: 'Array of market DTOs to create (max 10)', + type: [CreateMarketDto], + minItems: 1, + maxItems: 10, + }) + @IsArray() + @ArrayMinSize(1, { message: 'At least 1 market is required' }) + @ArrayMaxSize(10, { message: 'Maximum 10 markets per request' }) + @ValidateNested({ each: true }) + @Type(() => CreateMarketDto) + markets: CreateMarketDto[]; } diff --git a/backend/src/markets/dto/market-report.dto.ts b/backend/src/markets/dto/market-report.dto.ts index 2b53d1d7..995c6942 100644 --- a/backend/src/markets/dto/market-report.dto.ts +++ b/backend/src/markets/dto/market-report.dto.ts @@ -1,70 +1,70 @@ import { ApiProperty } from '@nestjs/swagger'; export class PredictionOutcomeDto { - @ApiProperty() - outcome: string; + @ApiProperty() + outcome: string; - @ApiProperty() - count: number; + @ApiProperty() + count: number; - @ApiProperty() - percentage: number; + @ApiProperty() + percentage: number; - @ApiProperty() - total_staked_stroops: string; + @ApiProperty() + total_staked_stroops: string; } export class MarketEventDto { - @ApiProperty() - timestamp: Date; + @ApiProperty() + timestamp: Date; - @ApiProperty() - event_type: string; + @ApiProperty() + event_type: string; - @ApiProperty() - description: string; + @ApiProperty() + description: string; } export class MarketReportDto { - @ApiProperty() - market_id: string; + @ApiProperty() + market_id: string; - @ApiProperty() - title: string; + @ApiProperty() + title: string; - @ApiProperty() - description: string; + @ApiProperty() + description: string; - @ApiProperty() - category: string; + @ApiProperty() + category: string; - @ApiProperty() - created_at: Date; + @ApiProperty() + created_at: Date; - @ApiProperty() - end_time: Date; + @ApiProperty() + end_time: Date; - @ApiProperty() - resolution_time: Date; + @ApiProperty() + resolution_time: Date; - @ApiProperty() - is_resolved: boolean; + @ApiProperty() + is_resolved: boolean; - @ApiProperty({ nullable: true }) - resolved_outcome: string | null; + @ApiProperty({ nullable: true }) + resolved_outcome: string | null; - @ApiProperty() - total_participants: number; + @ApiProperty() + total_participants: number; - @ApiProperty() - total_pool_stroops: string; + @ApiProperty() + total_pool_stroops: string; - @ApiProperty({ type: [PredictionOutcomeDto] }) - outcome_distribution: PredictionOutcomeDto[]; + @ApiProperty({ type: [PredictionOutcomeDto] }) + outcome_distribution: PredictionOutcomeDto[]; - @ApiProperty({ type: [MarketEventDto] }) - timeline: MarketEventDto[]; + @ApiProperty({ type: [MarketEventDto] }) + timeline: MarketEventDto[]; - @ApiProperty() - generated_at: Date; + @ApiProperty() + generated_at: Date; } diff --git a/backend/src/markets/markets.controller.ts b/backend/src/markets/markets.controller.ts index 5e5f94b6..8aec3687 100644 --- a/backend/src/markets/markets.controller.ts +++ b/backend/src/markets/markets.controller.ts @@ -39,7 +39,7 @@ import { User } from '../users/entities/user.entity'; @ApiTags('Markets') @Controller('markets') export class MarketsController { - constructor(private readonly marketsService: MarketsService) { } + constructor(private readonly marketsService: MarketsService) {} @Get('templates') @Public() @@ -88,13 +88,18 @@ export class MarketsController { @Throttle({ default: { limit: 5, ttl: 60000 } }) @HttpCode(HttpStatus.CREATED) @ApiBearerAuth() - @ApiOperation({ summary: 'Bulk create prediction markets (max 10 per request)' }) + @ApiOperation({ + summary: 'Bulk create prediction markets (max 10 per request)', + }) @ApiResponse({ status: 201, description: 'Markets created', type: [Market], }) - @ApiResponse({ status: 400, description: 'Validation error or exceeds limit' }) + @ApiResponse({ + status: 400, + description: 'Validation error or exceeds limit', + }) @ApiResponse({ status: 502, description: 'Soroban contract call failed' }) async bulkCreateMarkets( @Body() dto: BulkCreateMarketsDto, @@ -174,7 +179,9 @@ export class MarketsController { @Get(':id/report') @Public() - @ApiOperation({ summary: 'Generate detailed market report with anonymized predictions' }) + @ApiOperation({ + summary: 'Generate detailed market report with anonymized predictions', + }) @ApiResponse({ status: 200, description: 'Market report with outcome distribution and timeline', @@ -183,4 +190,4 @@ export class MarketsController { async getMarketReport(@Param('id') id: string): Promise { return this.marketsService.generateMarketReport(id); } -} \ No newline at end of file +} diff --git a/backend/src/markets/markets.service.bulk.spec.ts b/backend/src/markets/markets.service.bulk.spec.ts index 52bd8481..c9549a1e 100644 --- a/backend/src/markets/markets.service.bulk.spec.ts +++ b/backend/src/markets/markets.service.bulk.spec.ts @@ -12,144 +12,147 @@ import { User } from '../users/entities/user.entity'; import { CreateMarketDto } from './dto/create-market.dto'; describe('MarketsService - Bulk Creation', () => { - let service: MarketsService; - let marketsRepository: jest.Mocked>; - let dataSource: jest.Mocked; - let sorobanService: jest.Mocked; - - const mockUser = { - id: 'user-1', - stellar_address: 'GABC123', - } as User; - - const makeCreateDto = (): CreateMarketDto => ({ - title: 'Test Market', - description: 'Test description for market', - category: 'Crypto' as any, - outcome_options: ['YES', 'NO'], - end_time: new Date(Date.now() + 60_000).toISOString(), - resolution_time: new Date(Date.now() + 120_000).toISOString(), - creator_fee_bps: 100, - min_stake_stroops: '1000', - max_stake_stroops: '1000000', - is_public: true, - }); - - beforeEach(async () => { - marketsRepository = { - create: jest.fn(), - save: jest.fn(), - findOne: jest.fn(), - find: jest.fn(), - } as any; - - const mockQueryRunner = { - connect: jest.fn(), - startTransaction: jest.fn(), - commitTransaction: jest.fn(), - rollbackTransaction: jest.fn(), - release: jest.fn(), - manager: { - create: jest.fn(), - save: jest.fn(), - }, - }; - - dataSource = { - createQueryRunner: jest.fn().mockReturnValue(mockQueryRunner), - } as any; - - sorobanService = { - createMarket: jest.fn().mockResolvedValue({ - market_id: 'market_123', - tx_hash: 'tx_hash_123', - }), - } as any; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - MarketsService, - { - provide: getRepositoryToken(Market), - useValue: marketsRepository, - }, - { - provide: getRepositoryToken(Comment), - useValue: {}, - }, - { - provide: getRepositoryToken(MarketTemplate), - useValue: {}, - }, - { - provide: UsersService, - useValue: {}, - }, - { - provide: SorobanService, - useValue: sorobanService, - }, - { - provide: DataSource, - useValue: dataSource, - }, - ], - }).compile(); - - service = module.get(MarketsService); - }); - - it('should reject bulk creation with more than 10 markets', async () => { - const dtos = Array(11).fill(makeCreateDto()); - - // The validation happens at DTO level via class-validator - // So we just verify the service accepts up to 10 - const result = await service.createBulk(dtos.slice(0, 10), mockUser); - expect(result).toHaveLength(10); - }); - - it('should reject if any market has past end_time', async () => { - const dto = makeCreateDto(); - dto.end_time = new Date(Date.now() - 1000).toISOString(); - - await expect(service.createBulk([dto], mockUser)).rejects.toThrow( - BadRequestException, - ); - }); - - it('should create multiple markets in transaction', async () => { - const dtos = [makeCreateDto(), makeCreateDto()]; - const mockQueryRunner = dataSource.createQueryRunner(); - - mockQueryRunner.manager.create.mockImplementation((entity, data) => ({ - ...data, - id: 'market-' + Math.random(), - })); - - mockQueryRunner.manager.save.mockResolvedValue({ - id: 'market-123', - on_chain_market_id: 'market_123', - }); - - const result = await service.createBulk(dtos, mockUser); - - expect(mockQueryRunner.startTransaction).toHaveBeenCalled(); - expect(mockQueryRunner.commitTransaction).toHaveBeenCalled(); - expect(result).toHaveLength(2); - }); - - it('should rollback transaction on Soroban failure', async () => { - const dtos = [makeCreateDto()]; - const mockQueryRunner = dataSource.createQueryRunner(); - - sorobanService.createMarket.mockRejectedValueOnce( - new Error('Soroban error'), - ); - - await expect(service.createBulk(dtos, mockUser)).rejects.toThrow( - BadGatewayException, - ); - - expect(mockQueryRunner.rollbackTransaction).toHaveBeenCalled(); - }); + let service: MarketsService; + let marketsRepository: jest.Mocked>; + let dataSource: jest.Mocked; + let sorobanService: jest.Mocked; + + const mockUser = { + id: 'user-1', + stellar_address: 'GABC123', + } as User; + + const makeCreateDto = (): CreateMarketDto => ({ + title: 'Test Market', + description: 'Test description for market', + category: 'Crypto' as any, + outcome_options: ['YES', 'NO'], + end_time: new Date(Date.now() + 60_000).toISOString(), + resolution_time: new Date(Date.now() + 120_000).toISOString(), + creator_fee_bps: 100, + min_stake_stroops: '1000', + max_stake_stroops: '1000000', + is_public: true, + }); + + beforeEach(async () => { + marketsRepository = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + } as any; + + const mockQueryRunner = { + connect: jest.fn(), + startTransaction: jest.fn(), + commitTransaction: jest.fn(), + rollbackTransaction: jest.fn(), + release: jest.fn(), + manager: { + create: jest.fn(), + save: jest.fn(), + }, + }; + + dataSource = { + createQueryRunner: jest.fn().mockReturnValue(mockQueryRunner), + } as any; + + sorobanService = { + createMarket: jest.fn().mockResolvedValue({ + market_id: 'market_123', + tx_hash: 'tx_hash_123', + }), + } as any; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MarketsService, + { + provide: getRepositoryToken(Market), + useValue: marketsRepository, + }, + { + provide: getRepositoryToken(Comment), + useValue: {}, + }, + { + provide: getRepositoryToken(MarketTemplate), + useValue: {}, + }, + { + provide: UsersService, + useValue: {}, + }, + { + provide: SorobanService, + useValue: sorobanService, + }, + { + provide: DataSource, + useValue: dataSource, + }, + ], + }).compile(); + + service = module.get(MarketsService); + }); + + it('should reject bulk creation with more than 10 markets', async () => { + const dtos: CreateMarketDto[] = Array(11).fill(makeCreateDto()); + + // The validation happens at DTO level via class-validator + // So we just verify the service accepts up to 10 + const result = await service.createBulk(dtos.slice(0, 10), mockUser); + expect(result).toHaveLength(10); + }); + + it('should reject if any market has past end_time', async () => { + const dto = makeCreateDto(); + dto.end_time = new Date(Date.now() - 1000).toISOString(); + + await expect(service.createBulk([dto], mockUser)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should create multiple markets in transaction', async () => { + const dtos = [makeCreateDto(), makeCreateDto()]; + const mockQueryRunner = dataSource.createQueryRunner(); + + mockQueryRunner.manager.create.mockImplementation( + (entity, data) => + ({ + ...data, + id: 'market-' + Math.random(), + }) as Market, + ); + + mockQueryRunner.manager.save.mockResolvedValue({ + id: 'market-123', + on_chain_market_id: 'market_123', + } as Market); + + const result = await service.createBulk(dtos, mockUser); + + expect(mockQueryRunner.startTransaction).toHaveBeenCalled(); + expect(mockQueryRunner.commitTransaction).toHaveBeenCalled(); + expect(result).toHaveLength(2); + }); + + it('should rollback transaction on Soroban failure', async () => { + const dtos = [makeCreateDto()]; + const mockQueryRunner = dataSource.createQueryRunner(); + + sorobanService.createMarket.mockRejectedValueOnce( + new Error('Soroban error'), + ); + + await expect(service.createBulk(dtos, mockUser)).rejects.toThrow( + BadGatewayException, + ); + + expect(mockQueryRunner.rollbackTransaction).toHaveBeenCalled(); + }); }); diff --git a/backend/src/markets/markets.service.ts b/backend/src/markets/markets.service.ts index 02c154ef..ce6742ef 100644 --- a/backend/src/markets/markets.service.ts +++ b/backend/src/markets/markets.service.ts @@ -37,7 +37,7 @@ export class MarketsService { private readonly usersService: UsersService, private readonly sorobanService: SorobanService, private readonly dataSource: DataSource, - ) { } + ) {} /** * Get prediction statistics for a market - anonymous outcome counts only @@ -489,4 +489,4 @@ export class MarketsService { generated_at: new Date(), }; } -} \ No newline at end of file +} diff --git a/backend/src/users/users.service.spec.ts b/backend/src/users/users.service.spec.ts index 2eccf302..6dfb04df 100644 --- a/backend/src/users/users.service.spec.ts +++ b/backend/src/users/users.service.spec.ts @@ -155,7 +155,11 @@ describe('UsersService', () => { jest .spyOn(participantsRepository, 'createQueryBuilder') - .mockReturnValue(queryBuilder as any); + .mockReturnValue( + queryBuilder as any as ReturnType< + typeof participantsRepository.createQueryBuilder + >, + ); const result = await service.findUserCompetitions( mockUser.stellar_address, @@ -231,7 +235,11 @@ describe('UsersService', () => { jest .spyOn(predictionsRepository, 'createQueryBuilder') - .mockReturnValue(queryBuilder as any); + .mockReturnValue( + queryBuilder as any as ReturnType< + typeof predictionsRepository.createQueryBuilder + >, + ); const result = await service.findPublicPredictionsByAddress( mockUser.stellar_address, @@ -269,7 +277,11 @@ describe('UsersService', () => { queryBuilder.getManyAndCount.mockResolvedValue([[], 0]); jest .spyOn(marketsRepository, 'createQueryBuilder') - .mockReturnValue(queryBuilder as any); + .mockReturnValue( + queryBuilder as any as ReturnType< + typeof marketsRepository.createQueryBuilder + >, + ); const result = await service.findMarketsByAddress( mockUser.stellar_address, @@ -292,7 +304,11 @@ describe('UsersService', () => { queryBuilder.getManyAndCount.mockResolvedValue([[], 0]); jest .spyOn(marketsRepository, 'createQueryBuilder') - .mockReturnValue(queryBuilder as any); + .mockReturnValue( + queryBuilder as any as ReturnType< + typeof marketsRepository.createQueryBuilder + >, + ); await service.findMarketsByAddress(mockUser.stellar_address, { status: UserMarketFilterStatus.Active, @@ -308,7 +324,11 @@ describe('UsersService', () => { queryBuilder.getManyAndCount.mockResolvedValue([[], 0]); jest .spyOn(marketsRepository, 'createQueryBuilder') - .mockReturnValue(queryBuilder as any); + .mockReturnValue( + queryBuilder as any as ReturnType< + typeof marketsRepository.createQueryBuilder + >, + ); await service.findMarketsByAddress(mockUser.stellar_address, { status: UserMarketFilterStatus.Resolved, @@ -324,7 +344,11 @@ describe('UsersService', () => { queryBuilder.getManyAndCount.mockResolvedValue([[], 0]); jest .spyOn(marketsRepository, 'createQueryBuilder') - .mockReturnValue(queryBuilder as any); + .mockReturnValue( + queryBuilder as any as ReturnType< + typeof marketsRepository.createQueryBuilder + >, + ); await service.findMarketsByAddress(mockUser.stellar_address, { status: UserMarketFilterStatus.Cancelled, @@ -340,7 +364,11 @@ describe('UsersService', () => { queryBuilder.getManyAndCount.mockResolvedValue([[], 0]); jest .spyOn(marketsRepository, 'createQueryBuilder') - .mockReturnValue(queryBuilder as any); + .mockReturnValue( + queryBuilder as any as ReturnType< + typeof marketsRepository.createQueryBuilder + >, + ); await service.findMarketsByAddress(mockUser.stellar_address, { sort_by: UserMarketsSortBy.ParticipantCount,