From 552667997e51561a275042a1532516033c220830 Mon Sep 17 00:00:00 2001 From: Yggdrasil Date: Wed, 14 Oct 2026 00:46:45 -0400 Subject: [PATCH 1/3] docs(api): mark the stub allocate/approve/reject routes deprecated in Swagger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three stub routes (the #253 regression) presented as working operations in /api/docs — API consumers had no hint nothing happens. Marked deprecated with 'Not implemented'. Resolves #613 --- src/bounties/bounties.controller.ts | 16 ++++++++++++++-- src/milestones/milestones.controller.ts | 8 +++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/bounties/bounties.controller.ts b/src/bounties/bounties.controller.ts index 38ad22e1..37025469 100644 --- a/src/bounties/bounties.controller.ts +++ b/src/bounties/bounties.controller.ts @@ -132,7 +132,13 @@ export class BountiesController { return this.bountiesService.claim(id, userId); } - @ApiOperation({ summary: 'Approve a claimed bounty' }) + // Stub — not implemented (#253 regression). Marked deprecated so /api/docs + // doesn't present it as a working operation (#613). + @ApiOperation({ + summary: 'Approve a claimed bounty', + deprecated: true, + description: 'Not implemented — stub route, no approval happens.', + }) @ApiBearerAuth() @ApiStandardErrorResponses() @Idempotent('bounty.approve') @@ -143,7 +149,13 @@ export class BountiesController { return this.bountiesService.approve(id); } - @ApiOperation({ summary: 'Reject a claimed bounty' }) + // Stub — not implemented (#253 regression). Marked deprecated so /api/docs + // doesn't present it as a working operation (#613). + @ApiOperation({ + summary: 'Reject a claimed bounty', + deprecated: true, + description: 'Not implemented — stub route, no rejection happens.', + }) @ApiBearerAuth() @ApiStandardErrorResponses() @Idempotent('bounty.reject') diff --git a/src/milestones/milestones.controller.ts b/src/milestones/milestones.controller.ts index 0aaf9d99..eade29f2 100644 --- a/src/milestones/milestones.controller.ts +++ b/src/milestones/milestones.controller.ts @@ -133,7 +133,13 @@ export class MilestonesController { ); } - @ApiOperation({ summary: 'Allocate the milestone budget' }) + // Stub — not implemented (#253 regression). Marked deprecated so /api/docs + // doesn't present it as a working operation (#613). + @ApiOperation({ + summary: 'Allocate the milestone budget', + deprecated: true, + description: 'Not implemented — stub route, no allocation happens.', + }) @ApiBearerAuth() @ApiStandardErrorResponses() @Idempotent('milestone.allocateBudget') From e8ab47ee5fa00a95542c24cff9428a1c3b1250e6 Mon Sep 17 00:00:00 2001 From: yggdrasilworld Date: Sun, 18 Oct 2026 21:50:02 -0400 Subject: [PATCH 2/3] fix: repair merge glue that left the branch uncompilable The shared base carried half-written merge artifacts: an orphaned invokeOnLockedEscrow block in escrow.service.ts, duplicated returns plus a dead parsePagination in sponsors.controller.ts, and missing describe/it closers in github-webhooks.service.spec.ts. Aligned each with the intended form so the branch typechecks and its specs load. --- src/escrow/escrow.service.ts | 10 ++-------- src/github/github-webhooks.service.spec.ts | 3 +++ src/sponsors/sponsors.controller.ts | 21 --------------------- 3 files changed, 5 insertions(+), 29 deletions(-) diff --git a/src/escrow/escrow.service.ts b/src/escrow/escrow.service.ts index 6d2dd289..c252993f 100644 --- a/src/escrow/escrow.service.ts +++ b/src/escrow/escrow.service.ts @@ -584,20 +584,14 @@ export class EscrowService { recipients: Array<[string, number]>, manager?: EntityManager, ): Promise { - return this.invokeOnLockedEscrow(escrow, operation, () => - this.soroban.invoke( - 'release', - // `release(issue_id: u64, recipients)` — u64-typed on-chain (#301). - [u64(this.onChainKeyFor(escrow)), recipients], - this.contractOpts(escrow), - ), return this.invokeOnLockedEscrow( escrow, operation, () => this.soroban.invoke( 'release', - [this.onChainKeyFor(escrow), recipients], + // `release(issue_id: u64, recipients)` — u64-typed on-chain (#301). + [u64(this.onChainKeyFor(escrow)), recipients], this.contractOpts(escrow), ), manager, diff --git a/src/github/github-webhooks.service.spec.ts b/src/github/github-webhooks.service.spec.ts index 45bb6fe8..a35ac3be 100644 --- a/src/github/github-webhooks.service.spec.ts +++ b/src/github/github-webhooks.service.spec.ts @@ -869,6 +869,9 @@ describe('GithubWebhooksService', () => { expect(bountiesService.markInReview).not.toHaveBeenCalled(); expect(bountiesService.markMergedAndRelease).not.toHaveBeenCalled(); + }); + }); + // #308: a redelivered X-GitHub-Delivery used to hit the unique constraint // on webhook_events.deliveryId and escape handleEvent as a 500, so every // redelivery of that event failed forever. diff --git a/src/sponsors/sponsors.controller.ts b/src/sponsors/sponsors.controller.ts index b7a48cca..c097bf1e 100644 --- a/src/sponsors/sponsors.controller.ts +++ b/src/sponsors/sponsors.controller.ts @@ -26,25 +26,6 @@ function validatePage(limit?: number, offset?: number) { throw new BadRequestException('offset must be >= 0'); } return { limit, offset }; -/** - * Parse optional limit/offset query params, rejecting NaN, negatives, - * non-integers, and limits above MAX_PAGE_LIMIT with a 400 (#280). - */ -function parsePagination(limit?: string, offset?: string) { - const parse = (name: string, raw: string | undefined, min: number, max: number) => { - if (raw === undefined || raw === '') return undefined; - const n = Number(raw); - if (!Number.isInteger(n) || n < min || n > max) { - throw new BadRequestException( - `${name} must be an integer between ${min} and ${max}`, - ); - } - return n; - }; - return { - limit: parse('limit', limit, 1, MAX_PAGE_LIMIT), - offset: parse('offset', offset, 0, Number.MAX_SAFE_INTEGER), - }; } @ApiTags('sponsors') @@ -73,7 +54,6 @@ export class SponsorsController { ) { this.assertOwnsSponsor(user, id); return this.sponsorsService.dashboard(id, validatePage(limit, offset)); - return this.sponsorsService.dashboard(id, parsePagination(limit, offset)); } @ApiBearerAuth() @@ -91,6 +71,5 @@ export class SponsorsController { id, validatePage(limit, offset), ); - return this.sponsorsService.milestoneProgress(id, parsePagination(limit, offset)); } } From 70037066520aa645f84aab09de9cb9a5808f5998 Mon Sep 17 00:00:00 2001 From: Devin Date: Sun, 18 Oct 2026 22:09:42 -0400 Subject: [PATCH 3/3] repair stale specs to match shipped signatures/fixtures --- src/bounties/bounties.controller.spec.ts | 44 +++- src/milestones/bounties.controller.spec.ts | 231 +++++++++++++++++++ src/milestones/milestones.controller.spec.ts | 32 ++- 3 files changed, 291 insertions(+), 16 deletions(-) create mode 100644 src/milestones/bounties.controller.spec.ts diff --git a/src/bounties/bounties.controller.spec.ts b/src/bounties/bounties.controller.spec.ts index 9fa19668..d42a9290 100644 --- a/src/bounties/bounties.controller.spec.ts +++ b/src/bounties/bounties.controller.spec.ts @@ -1,5 +1,7 @@ +import type { Request } from 'express'; import { ArgumentMetadata, ParseUUIDPipe } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; import { BountiesController } from './bounties.controller'; @@ -9,6 +11,7 @@ import { ClaimBountyDto } from './dto/claim-bounty.dto'; import { AssetType, BountyDifficulty, BountyStatus } from '../common/enums'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; +import { IdempotencyKey } from '../common/entities/idempotency-key.entity'; describe('BountiesController', () => { let controller: BountiesController; @@ -47,7 +50,13 @@ describe('BountiesController', () => { const module: TestingModule = await Test.createTestingModule({ controllers: [BountiesController], - providers: [{ provide: BountiesService, useValue: bountiesService }], + providers: [ + { provide: BountiesService, useValue: bountiesService }, + { + provide: getRepositoryToken(IdempotencyKey), + useValue: { findOne: jest.fn(), save: jest.fn(), create: jest.fn() }, + }, + ], }) .overrideGuard(JwtAuthGuard) .useValue({ canActivate: () => true }) @@ -68,9 +77,11 @@ describe('BountiesController', () => { difficulty: BountyDifficulty.INTERMEDIATE, }; - await controller.create(dto); + await controller.create(dto, { + user: { userId: 'sponsor-1' }, + } as unknown as Request); - expect(bountiesService.create).toHaveBeenCalledWith(dto); + expect(bountiesService.create).toHaveBeenCalledWith(dto, 'sponsor-1'); }); }); @@ -84,15 +95,23 @@ describe('BountiesController', () => { describe('fund', () => { it('calls bountiesService.fund with id and funderAddress', async () => { - await controller.fund('b1', { funderAddress: 'GFUNDER' }); - - expect(bountiesService.fund).toHaveBeenCalledWith('b1', 'GFUNDER'); + await controller.fund('b1', { funderAddress: 'GFUNDER' }, { + user: { userId: 'sponsor-1' }, + } as unknown as Request); + + expect(bountiesService.fund).toHaveBeenCalledWith( + 'b1', + 'GFUNDER', + 'sponsor-1', + ); }); }); describe('claim', () => { - it('calls bountiesService.claim with id and contributorId', async () => { - await controller.claim('b1', { contributorId: 'contributor-1' }); + it('calls bountiesService.claim with id and the authenticated user id', async () => { + await controller.claim('b1', { + user: { userId: 'contributor-1' }, + } as unknown as Request); expect(bountiesService.claim).toHaveBeenCalledWith('b1', 'contributor-1'); }); @@ -116,9 +135,14 @@ describe('BountiesController', () => { describe('refund', () => { it('calls bountiesService.refund with the route param', async () => { - await controller.refund('b1'); + await controller.refund('b1', { + user: { userId: 'sponsor-1' }, + } as unknown as Request); - expect(bountiesService.refund).toHaveBeenCalledWith('b1'); + expect(bountiesService.refund).toHaveBeenCalledWith( + 'b1', + 'sponsor-1', + ); }); }); diff --git a/src/milestones/bounties.controller.spec.ts b/src/milestones/bounties.controller.spec.ts new file mode 100644 index 00000000..d42a9290 --- /dev/null +++ b/src/milestones/bounties.controller.spec.ts @@ -0,0 +1,231 @@ +import type { Request } from 'express'; +import { ArgumentMetadata, ParseUUIDPipe } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { BountiesController } from './bounties.controller'; +import { BountiesService } from './bounties.service'; +import { CreateBountyDto } from './dto/create-bounty.dto'; +import { ClaimBountyDto } from './dto/claim-bounty.dto'; +import { AssetType, BountyDifficulty, BountyStatus } from '../common/enums'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { IdempotencyKey } from '../common/entities/idempotency-key.entity'; + +describe('BountiesController', () => { + let controller: BountiesController; + let bountiesService: { + create: jest.Mock; + findOne: jest.Mock; + fund: jest.Mock; + claim: jest.Mock; + approve: jest.Mock; + reject: jest.Mock; + refund: jest.Mock; + list: jest.Mock; + }; + + beforeEach(async () => { + bountiesService = { + create: jest + .fn() + .mockResolvedValue({ id: 'b1', status: BountyStatus.OPEN }), + findOne: jest + .fn() + .mockResolvedValue({ id: 'b1', status: BountyStatus.OPEN }), + fund: jest + .fn() + .mockResolvedValue({ id: 'b1', status: BountyStatus.FUNDED }), + claim: jest + .fn() + .mockResolvedValue({ id: 'b1', status: BountyStatus.CLAIMED }), + approve: jest.fn().mockResolvedValue({ id: 'b1', status: 'approved' }), + reject: jest.fn().mockResolvedValue({ id: 'b1', status: 'rejected' }), + refund: jest + .fn() + .mockResolvedValue({ id: 'b1', status: BountyStatus.REFUNDED }), + list: jest.fn().mockResolvedValue([]), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [BountiesController], + providers: [ + { provide: BountiesService, useValue: bountiesService }, + { + provide: getRepositoryToken(IdempotencyKey), + useValue: { findOne: jest.fn(), save: jest.fn(), create: jest.fn() }, + }, + ], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(BountiesController); + }); + + describe('create', () => { + it('calls bountiesService.create with the provided DTO', async () => { + const dto = { + issueId: '00000000-0000-0000-0000-000000000001', + sponsorId: '00000000-0000-0000-0000-000000000002', + amount: '100', + asset: AssetType.USDC, + difficulty: BountyDifficulty.INTERMEDIATE, + }; + + await controller.create(dto, { + user: { userId: 'sponsor-1' }, + } as unknown as Request); + + expect(bountiesService.create).toHaveBeenCalledWith(dto, 'sponsor-1'); + }); + }); + + describe('findOne', () => { + it('calls bountiesService.findOne with the route param', async () => { + await controller.findOne('b1'); + + expect(bountiesService.findOne).toHaveBeenCalledWith('b1'); + }); + }); + + describe('fund', () => { + it('calls bountiesService.fund with id and funderAddress', async () => { + await controller.fund('b1', { funderAddress: 'GFUNDER' }, { + user: { userId: 'sponsor-1' }, + } as unknown as Request); + + expect(bountiesService.fund).toHaveBeenCalledWith( + 'b1', + 'GFUNDER', + 'sponsor-1', + ); + }); + }); + + describe('claim', () => { + it('calls bountiesService.claim with id and the authenticated user id', async () => { + await controller.claim('b1', { + user: { userId: 'contributor-1' }, + } as unknown as Request); + + expect(bountiesService.claim).toHaveBeenCalledWith('b1', 'contributor-1'); + }); + }); + + describe('approve', () => { + it('calls bountiesService.approve with the route param', async () => { + await controller.approve('b1'); + + expect(bountiesService.approve).toHaveBeenCalledWith('b1'); + }); + }); + + describe('reject', () => { + it('calls bountiesService.reject with the route param', async () => { + await controller.reject('b1'); + + expect(bountiesService.reject).toHaveBeenCalledWith('b1'); + }); + }); + + describe('refund', () => { + it('calls bountiesService.refund with the route param', async () => { + await controller.refund('b1', { + user: { userId: 'sponsor-1' }, + } as unknown as Request); + + expect(bountiesService.refund).toHaveBeenCalledWith( + 'b1', + 'sponsor-1', + ); + }); + }); + + describe('list', () => { + it('calls bountiesService.list with query params', async () => { + await controller.list( + BountyStatus.OPEN, + BountyDifficulty.BEGINNER, + AssetType.USDC, + 'repo-1', + 'TypeScript', + ); + + expect(bountiesService.list).toHaveBeenCalledWith({ + status: BountyStatus.OPEN, + difficulty: BountyDifficulty.BEGINNER, + asset: AssetType.USDC, + repositoryId: 'repo-1', + primaryLanguage: 'TypeScript', + }); + }); + }); + + describe('repositoryId query pipe behavior', () => { + const repositoryIdMetadata: ArgumentMetadata = { + type: 'query', + metatype: String, + data: 'repositoryId', + }; + const pipe = new ParseUUIDPipe({ version: '4', optional: true }); + + it('accepts undefined when the query param is absent', async () => { + await expect( + pipe.transform(undefined, repositoryIdMetadata), + ).resolves.toBeUndefined(); + }); + + it('accepts a valid v4 UUID', async () => { + const validUuid = 'c73bcdcc-2669-4bf6-81d3-e4ae73fb11fd'; + await expect( + pipe.transform(validUuid, repositoryIdMetadata), + ).resolves.toBe(validUuid); + }); + + it('rejects a non-UUID string with BadRequestException', async () => { + await expect( + pipe.transform('not-a-uuid', repositoryIdMetadata), + ).rejects.toThrow(); + }); + }); + + describe('CreateBountyDto validation', () => { + it('rejects a body with no issueId', async () => { + const dto = plainToInstance(CreateBountyDto, { + sponsorId: '00000000-0000-0000-0000-000000000001', + amount: '100', + asset: AssetType.USDC, + difficulty: BountyDifficulty.INTERMEDIATE, + }); + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + }); + + it('rejects a body with no sponsorId', async () => { + const dto = plainToInstance(CreateBountyDto, { + issueId: '00000000-0000-0000-0000-000000000001', + amount: '100', + asset: AssetType.USDC, + difficulty: BountyDifficulty.INTERMEDIATE, + }); + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + }); + + it('rejects a body with no amount', async () => { + const dto = plainToInstance(CreateBountyDto, { + issueId: '00000000-0000-0000-0000-000000000001', + sponsorId: '00000000-0000-0000-0000-000000000002', + asset: AssetType.USDC, + difficulty: BountyDifficulty.INTERMEDIATE, + }); + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/milestones/milestones.controller.spec.ts b/src/milestones/milestones.controller.spec.ts index 8d87497d..06a90603 100644 --- a/src/milestones/milestones.controller.spec.ts +++ b/src/milestones/milestones.controller.spec.ts @@ -1,3 +1,4 @@ +import type { Request } from 'express'; import { Test, TestingModule } from '@nestjs/testing'; import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; @@ -5,8 +6,10 @@ import { MilestonesController } from './milestones.controller'; import { MilestonesService } from './milestones.service'; import { CreateMilestoneDto } from './dto/create-milestone.dto'; import { AssetType, MilestoneStatus } from '../common/enums'; +import { getRepositoryToken } from '@nestjs/typeorm'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; +import { IdempotencyKey } from '../common/entities/idempotency-key.entity'; describe('MilestonesController', () => { let controller: MilestonesController; @@ -33,7 +36,13 @@ describe('MilestonesController', () => { const module: TestingModule = await Test.createTestingModule({ controllers: [MilestonesController], - providers: [{ provide: MilestonesService, useValue: milestonesService }], + providers: [ + { provide: MilestonesService, useValue: milestonesService }, + { + provide: getRepositoryToken(IdempotencyKey), + useValue: { findOne: jest.fn(), save: jest.fn(), create: jest.fn() }, + }, + ], }) .overrideGuard(JwtAuthGuard) .useValue({ canActivate: () => true }) @@ -53,9 +62,14 @@ describe('MilestonesController', () => { asset: AssetType.USDC, }; - await controller.create(dto); + await controller.create(dto, { + user: { userId: 'sponsor-1' }, + } as unknown as Request); - expect(milestonesService.create).toHaveBeenCalledWith(dto); + expect(milestonesService.create).toHaveBeenCalledWith( + dto, + 'sponsor-1', + ); }); }); @@ -77,9 +91,15 @@ describe('MilestonesController', () => { describe('fund', () => { it('calls milestonesService.fund with id and funderAddress', async () => { - await controller.fund('m1', { funderAddress: 'GFUNDER' }); - - expect(milestonesService.fund).toHaveBeenCalledWith('m1', 'GFUNDER'); + await controller.fund('m1', { funderAddress: 'GFUNDER' }, { + user: { userId: 'sponsor-1' }, + } as unknown as Request); + + expect(milestonesService.fund).toHaveBeenCalledWith( + 'm1', + 'GFUNDER', + 'sponsor-1', + ); }); });