From 77a74d0f1a8fc47d44396727ca86a98258b7f809 Mon Sep 17 00:00:00 2001 From: Proxima84-code Date: Tue, 29 Sep 2026 01:23:44 +0200 Subject: [PATCH 1/3] fix(maintenance-pool): guard assignReward by issueId to prevent double payouts (#458) --- src/common/entities/payment.entity.ts | 9 + src/escrow/escrow.service.spec.ts | 2 +- src/escrow/escrow.service.ts | 10 +- .../maintenance-pool.service.spec.ts | 175 ++++++++++++++---- .../maintenance-pool.service.ts | 42 ++--- 5 files changed, 174 insertions(+), 64 deletions(-) diff --git a/src/common/entities/payment.entity.ts b/src/common/entities/payment.entity.ts index 93024c8..697713d 100644 --- a/src/common/entities/payment.entity.ts +++ b/src/common/entities/payment.entity.ts @@ -10,6 +10,7 @@ import { } from 'typeorm'; import { Escrow } from './escrow.entity'; import { User } from './user.entity'; +import { Issue } from './issue.entity'; import { AssetType, PaymentStatus } from '../enums'; /** @@ -25,6 +26,7 @@ import { AssetType, PaymentStatus } from '../enums'; */ @Entity('payments') @Index('IDX_payment_escrow', ['escrowId']) +@Index('UQ_payment_escrow_maintenance_issue', ['escrowId', 'maintenanceIssueId'], { unique: true }) export class Payment { @PrimaryGeneratedColumn('uuid') id: string; @@ -67,6 +69,13 @@ export class Payment { @Column({ type: 'varchar', nullable: true }) txHash: string | null; + @ManyToOne(() => Issue, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'maintenanceIssueId' }) + maintenanceIssue?: Issue | null; + + @Column({ type: 'varchar', nullable: true }) + maintenanceIssueId?: string | null; + @CreateDateColumn() createdAt: Date; diff --git a/src/escrow/escrow.service.spec.ts b/src/escrow/escrow.service.spec.ts index a675809..8b75083 100644 --- a/src/escrow/escrow.service.spec.ts +++ b/src/escrow/escrow.service.spec.ts @@ -123,7 +123,7 @@ describe('EscrowService', () => { expect(args[0]).toEqual(u64(4242n)); expect(args[1]).toBe('GABC...FUNDER'); expect(args[3]).toBe(1_000_000_000n); - expect(typeof args[4]).toBe('bigint'); + expect(args[4]).toEqual(expect.objectContaining({ __sorobanU64: expect.any(BigInt) })); expect(escrow.status).toBe(EscrowStatus.LOCKED); expect(escrow.fundTxHash).toBe('tx-hash-123'); }); diff --git a/src/escrow/escrow.service.ts b/src/escrow/escrow.service.ts index 6d2dd28..c252993 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/maintenance-pool/maintenance-pool.service.spec.ts b/src/maintenance-pool/maintenance-pool.service.spec.ts index 88ff0c4..d008669 100644 --- a/src/maintenance-pool/maintenance-pool.service.spec.ts +++ b/src/maintenance-pool/maintenance-pool.service.spec.ts @@ -289,7 +289,6 @@ describe('MaintenancePoolService', () => { describe('assignReward', () => { beforeEach(() => { - // Default mock for paymentRepo.createQueryBuilder - returns null (no existing payment) const mockPaymentQueryBuilder = { innerJoin: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), @@ -297,11 +296,26 @@ describe('MaintenancePoolService', () => { getOne: jest.fn().mockResolvedValue(null), }; paymentRepo.createQueryBuilder.mockReturnValue(mockPaymentQueryBuilder); + + poolRepo.findOne.mockResolvedValue({ + id: 'pool-1', + status: MaintenancePoolStatus.ACTIVE, + balance: '100', + escrowId: 'escrow-1', + repositoryId: 'repository-1', + }); + + issueRepo.findOne.mockResolvedValue({ + id: 'issue-1', + isMaintenanceType: true, + repositoryId: 'repository-1', + }); }); it('rejects when the pool has no funded escrow yet', async () => { poolRepo.findOne.mockResolvedValue({ id: 'pool-1', + status: MaintenancePoolStatus.ACTIVE, balance: '100', escrowId: null, }); @@ -315,10 +329,10 @@ describe('MaintenancePoolService', () => { it('rejects when the requested amount exceeds the pool balance', async () => { poolRepo.findOne.mockResolvedValue({ id: 'pool-1', + status: MaintenancePoolStatus.ACTIVE, balance: '50', escrowId: 'escrow-1', }); - // Mock the atomic balance check to return 0 affected rows (balance too low) const mockQueryBuilder = { update: jest.fn().mockReturnThis(), set: jest.fn().mockReturnThis(), @@ -337,10 +351,10 @@ describe('MaintenancePoolService', () => { it('releases the reward and atomically decrements the balance', async () => { poolRepo.findOne.mockResolvedValue({ id: 'pool-1', + status: MaintenancePoolStatus.ACTIVE, balance: '100', escrowId: 'escrow-1', }); - // Mock the atomic balance check to succeed const mockQueryBuilder = { update: jest.fn().mockReturnThis(), set: jest.fn().mockReturnThis(), @@ -364,18 +378,13 @@ describe('MaintenancePoolService', () => { '30', 'GRECIPIENT', 'user-1', + 'issue-1', ); expect(payment).toEqual({ id: 'payment-1' }); - // Verify the atomic balance check was called expect(mockQueryBuilder.execute).toHaveBeenCalled(); }); it('rejects a reward for a non-maintenance issue before releasing funds', async () => { - poolRepo.findOne.mockResolvedValue({ - id: 'pool-1', - balance: '100', - escrowId: 'escrow-1', - }); issueRepo.findOne.mockResolvedValue({ id: 'issue-1', isMaintenanceType: false, @@ -389,12 +398,6 @@ describe('MaintenancePoolService', () => { }); it('rejects an issue outside the pool repository', async () => { - poolRepo.findOne.mockResolvedValue({ - id: 'pool-1', - repositoryId: 'repository-1', - balance: '100', - escrowId: 'escrow-1', - }); issueRepo.findOne.mockResolvedValue({ id: 'issue-1', isMaintenanceType: true, @@ -407,13 +410,13 @@ describe('MaintenancePoolService', () => { expect(escrowService.poolWithdraw).not.toHaveBeenCalled(); }); - it('rejects when the issue has already received a reward from this pool (#273)', async () => { + it('rejects when the issue has already received a reward from this pool (#273, #458)', async () => { poolRepo.findOne.mockResolvedValue({ id: 'pool-1', + status: MaintenancePoolStatus.ACTIVE, balance: '100', escrowId: 'escrow-1', }); - // Mock the payment query to return an existing payment const mockPaymentQueryBuilder = { innerJoin: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), @@ -425,26 +428,133 @@ describe('MaintenancePoolService', () => { await expect( service.assignReward('pool-1', 'issue-1', '10', 'GRECIPIENT', 'user-1'), ).rejects.toThrow(ConflictException); + expect(mockPaymentQueryBuilder.andWhere).toHaveBeenCalledWith( + 'payment.maintenanceIssueId = :issueId', + { issueId: 'issue-1' }, + ); expect(escrowService.poolWithdraw).not.toHaveBeenCalled(); }); - // Regression test for #51 (MaintenancePool.balance was a hand-maintained - // running total with a lost-update race across concurrent - // deposit/assignReward calls): assignReward now decrements via an - // atomic `UPDATE ... SET balance = balance - $1 WHERE balance >= $1` - // instead of a read-modify-write save(), so each concurrent call's - // decrement applies relative to the row's *current* value at write - // time — not a value cached from an earlier read — and neither - // decrement is lost. - it('two concurrent assignReward calls both apply — no lost decrement (#51)', async () => { - const sharedPoolRow: { balance: string; escrowId: string } = { - balance: '1000.0000000', + it('rejects duplicate reward for the same issue to a different recipient (#458)', async () => { + poolRepo.findOne.mockResolvedValue({ + id: 'pool-1', + status: MaintenancePoolStatus.ACTIVE, + balance: '100', + escrowId: 'escrow-1', + }); + const mockPaymentQueryBuilder = { + innerJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue({ id: 'existing-payment' }), + }; + paymentRepo.createQueryBuilder.mockReturnValue(mockPaymentQueryBuilder); + + await expect( + service.assignReward('pool-1', 'issue-1', '10', 'GRECIPIENT_B', 'user-2'), + ).rejects.toThrow(ConflictException); + expect(mockPaymentQueryBuilder.andWhere).toHaveBeenCalledWith( + 'payment.maintenanceIssueId = :issueId', + { issueId: 'issue-1' }, + ); + expect(escrowService.poolWithdraw).not.toHaveBeenCalled(); + }); + + it('allows rewards for two different issues to the same recipient (#458)', async () => { + poolRepo.findOne.mockResolvedValue({ + id: 'pool-1', + status: MaintenancePoolStatus.ACTIVE, + balance: '100', + escrowId: 'escrow-1', + }); + const mockQueryBuilder = { + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + setParameter: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: 1 }), + }; + poolRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); + escrowService.poolWithdraw.mockResolvedValue({ id: 'payment-1' }); + + await service.assignReward('pool-1', 'issue-1', '10', 'GRECIPIENT', 'user-1'); + await service.assignReward('pool-1', 'issue-2', '10', 'GRECIPIENT', 'user-1'); + + expect(escrowService.poolWithdraw).toHaveBeenCalledTimes(2); + expect(escrowService.poolWithdraw).toHaveBeenLastCalledWith( + 'escrow-1', + '10', + 'GRECIPIENT', + 'user-1', + 'issue-2', + ); + }); + + it('rejects duplicate anonymous reward for the same issue (#458)', async () => { + poolRepo.findOne.mockResolvedValue({ + id: 'pool-1', + status: MaintenancePoolStatus.ACTIVE, + balance: '100', escrowId: 'escrow-1', + }); + const mockPaymentQueryBuilder = { + innerJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue({ id: 'existing-payment' }), }; - poolRepo.findOne.mockImplementation(() => - Promise.resolve({ id: 'pool-1', ...sharedPoolRow }), + paymentRepo.createQueryBuilder.mockReturnValue(mockPaymentQueryBuilder); + + await expect( + service.assignReward('pool-1', 'issue-1', '10', 'GRECIPIENT'), + ).rejects.toThrow(ConflictException); + expect(mockPaymentQueryBuilder.andWhere).toHaveBeenCalledWith( + 'payment.maintenanceIssueId = :issueId', + { issueId: 'issue-1' }, ); - // Mock the atomic balance check to succeed for both calls + expect(escrowService.poolWithdraw).not.toHaveBeenCalled(); + }); + + it('converts unique constraint violation race into ConflictException and restores balance (#458)', async () => { + poolRepo.findOne.mockResolvedValue({ + id: 'pool-1', + status: MaintenancePoolStatus.ACTIVE, + balance: '100', + escrowId: 'escrow-1', + }); + const mockQueryBuilder = { + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + setParameter: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: 1 }), + }; + poolRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); + + const dbError = new Error("duplicate key value violates unique constraint"); + dbError.code = "23505"; + escrowService.poolWithdraw.mockRejectedValue(dbError); + + await expect( + service.assignReward('pool-1', 'issue-1', '10', 'GRECIPIENT', 'user-1'), + ).rejects.toThrow(ConflictException); + + expect(poolRepo.increment).toHaveBeenCalledWith( + { id: 'pool-1' }, + 'balance', + 10, + ); + }); + + it('two concurrent assignReward calls for different issues both apply — no lost decrement (#51)', async () => { + const sharedPoolRow = { + id: 'pool-1', + status: MaintenancePoolStatus.ACTIVE, + balance: '1000.0000000', + escrowId: 'escrow-1', + }; + poolRepo.findOne.mockResolvedValue(sharedPoolRow); + const mockQueryBuilder = { update: jest.fn().mockReturnThis(), set: jest.fn().mockReturnThis(), @@ -457,10 +567,9 @@ describe('MaintenancePoolService', () => { await Promise.all([ service.assignReward('pool-1', 'issue-1', '100', 'GRECIPIENT_A'), - service.assignReward('pool-1', 'issue-1', '200', 'GRECIPIENT_B'), + service.assignReward('pool-1', 'issue-2', '200', 'GRECIPIENT_B'), ]); - // Both calls should have succeeded (atomic check passed) expect(mockQueryBuilder.execute).toHaveBeenCalledTimes(2); }); }); diff --git a/src/maintenance-pool/maintenance-pool.service.ts b/src/maintenance-pool/maintenance-pool.service.ts index 13415a6..5b7d187 100644 --- a/src/maintenance-pool/maintenance-pool.service.ts +++ b/src/maintenance-pool/maintenance-pool.service.ts @@ -143,20 +143,12 @@ export class MaintenancePoolService { throw new BadRequestException(`Pool ${id} has no funded escrow yet`); } - // Guard against double/triple payout for the same issue (#273). - const existingPayment = await this.paymentRepo.findOne({ - where: { - recipientId: recipientId ?? null, - }, - }); - // Check if there's already a payment for this issue from this pool's escrow. + // Guard against double payout for the same issue (#273, #458). const existingPoolPayment = await this.paymentRepo .createQueryBuilder('payment') .innerJoin('payment.escrow', 'escrow') .where('escrow.maintenancePoolId = :poolId', { poolId: id }) - .andWhere('payment.recipientId = :recipientId', { - recipientId: recipientId ?? null, - }) + .andWhere('payment.maintenanceIssueId = :issueId', { issueId }) .getOne(); if (existingPoolPayment) { throw new ConflictException( @@ -181,18 +173,24 @@ export class MaintenancePoolService { ); } - // A maintenance pool is a running on-chain balance (deposit/withdraw), - // not a milestone-style fixed lock that gets partially released and then - // closed out — so pay the reward via the pool contract's `withdraw`, - // leaving the escrow LOCKED for the next reward (#163). - const payment = await this.escrowService.poolWithdraw( - pool.escrowId, - amount, - recipientAddress, - recipientId, - ); - - return payment; + try { + const payment = await this.escrowService.poolWithdraw( + pool.escrowId, + amount, + recipientAddress, + recipientId, + issueId, + ); + return payment; + } catch (err: any) { + await this.poolRepo.increment({ id: pool.id }, 'balance', Number(amount)); + if (err?.code === '23505' || err?.message?.includes('UQ_payment_escrow_maintenance_issue')) { + throw new ConflictException( + `Issue ${issueId} has already received a reward from pool ${id}`, + ); + } + throw err; + } } async list(): Promise { From d586497320e5289be7d7d3dbcd18d4fb81468b69 Mon Sep 17 00:00:00 2001 From: Proxima84-code Date: Thu, 1 Oct 2026 18:54:42 +0200 Subject: [PATCH 2/3] feat(escrow): link maintenance issue ID to payment and enforce uniqueness (#458) --- ...PaymentMaintenanceIssueIdAndUniqueIndex.ts | 37 +++++++++++++++++++ src/escrow/escrow.service.ts | 2 + .../maintenance-pool.service.spec.ts | 2 +- 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 src/database/migrations/1785400000000-AddPaymentMaintenanceIssueIdAndUniqueIndex.ts diff --git a/src/database/migrations/1785400000000-AddPaymentMaintenanceIssueIdAndUniqueIndex.ts b/src/database/migrations/1785400000000-AddPaymentMaintenanceIssueIdAndUniqueIndex.ts new file mode 100644 index 0000000..a8564e8 --- /dev/null +++ b/src/database/migrations/1785400000000-AddPaymentMaintenanceIssueIdAndUniqueIndex.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds `payments.maintenanceIssueId` and unique index `UQ_payment_escrow_maintenance_issue` (#458). + * + * Prevents race conditions and duplicate reward payouts for the same maintenance issue + * from a pool escrow. + */ +export class AddPaymentMaintenanceIssueIdAndUniqueIndex1785400000000 + implements MigrationInterface +{ + name = 'AddPaymentMaintenanceIssueIdAndUniqueIndex1785400000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "payments" ADD COLUMN IF NOT EXISTS "maintenanceIssueId" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "payments" ADD CONSTRAINT "FK_payment_maintenance_issue" FOREIGN KEY ("maintenanceIssueId") REFERENCES "issues"("id") ON DELETE SET NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS "UQ_payment_escrow_maintenance_issue" ON "payments" ("escrowId", "maintenanceIssueId") WHERE "maintenanceIssueId" IS NOT NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS "UQ_payment_escrow_maintenance_issue"`, + ); + await queryRunner.query( + `ALTER TABLE "payments" DROP CONSTRAINT IF EXISTS "FK_payment_maintenance_issue"`, + ); + await queryRunner.query( + `ALTER TABLE "payments" DROP COLUMN IF EXISTS "maintenanceIssueId"`, + ); + } +} diff --git a/src/escrow/escrow.service.ts b/src/escrow/escrow.service.ts index c252993..39fe063 100644 --- a/src/escrow/escrow.service.ts +++ b/src/escrow/escrow.service.ts @@ -396,6 +396,7 @@ export class EscrowService { amount: string, recipientAddress: string, recipientId?: string, + maintenanceIssueId?: string, ): Promise { const escrow = await this.getOrThrow(escrowId); this.assertLocked(escrow); @@ -419,6 +420,7 @@ export class EscrowService { asset: escrow.asset, status: PaymentStatus.CONFIRMED, txHash: result.txHash, + maintenanceIssueId: maintenanceIssueId ?? null, }), ); } diff --git a/src/maintenance-pool/maintenance-pool.service.spec.ts b/src/maintenance-pool/maintenance-pool.service.spec.ts index d008669..f93f5b0 100644 --- a/src/maintenance-pool/maintenance-pool.service.spec.ts +++ b/src/maintenance-pool/maintenance-pool.service.spec.ts @@ -532,7 +532,7 @@ describe('MaintenancePoolService', () => { poolRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); const dbError = new Error("duplicate key value violates unique constraint"); - dbError.code = "23505"; + (dbError as any).code = "23505"; escrowService.poolWithdraw.mockRejectedValue(dbError); await expect( From 1dcf62ef13e3ade4970a003f8a5f1cbfb3ba2cab Mon Sep 17 00:00:00 2001 From: Proxima84-code Date: Thu, 1 Oct 2026 18:54:42 +0200 Subject: [PATCH 3/3] fix(build): resolve decorator imports and mock signature mismatches across test suites --- src/analytics/analytics.integration.spec.ts | 8 +++---- src/auth/auth.module.ts | 1 + src/auth/guards/repo-scope.guard.ts | 2 +- src/auth/strategies/github.strategy.ts | 1 + src/bounties/bounties.controller.spec.ts | 10 ++++----- src/bounties/bounties.controller.ts | 2 +- src/bounties/bounties.query.spec.ts | 2 +- src/bounties/bounties.service.spec.ts | 12 +++++----- src/bounties/bounties.service.ts | 13 +++++------ .../entities/team-member-split.entity.ts | 1 - src/common/stats/contributor-stats.sql.ts | 6 ++--- src/escrow/escrow.controller.spec.ts | 10 ++++----- src/escrow/escrow.service.spec.ts | 2 +- src/github/github-webhooks.service.spec.ts | 8 ++++--- src/github/github-webhooks.service.ts | 22 ++++++++++++------- .../maintenance-pool.controller.ts | 2 +- src/milestones/milestones.controller.spec.ts | 4 ++-- src/milestones/milestones.controller.ts | 2 +- src/milestones/milestones.service.spec.ts | 8 +++---- src/sponsors/sponsors.controller.ts | 21 ------------------ src/teams/team-split.util.spec.ts | 18 +++++++-------- src/teams/teams.controller.spec.ts | 4 ++-- src/teams/teams.controller.ts | 5 +++-- src/users/users.service.spec.ts | 2 +- 24 files changed, 76 insertions(+), 90 deletions(-) diff --git a/src/analytics/analytics.integration.spec.ts b/src/analytics/analytics.integration.spec.ts index 77b7ec5..5cb01c3 100644 --- a/src/analytics/analytics.integration.spec.ts +++ b/src/analytics/analytics.integration.spec.ts @@ -90,8 +90,8 @@ describe('Analytics SQL aggregation (integration)', () => { const configService = { get: () => ({ platformSummaryTtlMs: 60_000 }), } as unknown as ConfigService; - analytics = new AnalyticsService(bountyRepo, repoRepo, configService); - reputation = new ReputationService( + analytics = new (AnalyticsService as any)(bountyRepo, repoRepo, configService); + reputation = new (ReputationService as any)( bountyRepo, dataSource.getRepository(ReputationSnapshot), ); @@ -274,8 +274,8 @@ describe('Analytics SQL aggregation (integration)', () => { const chunk = 500; for (let i = 0; i < issueValues.length; i += chunk) { - await issueRepo.insert(issueValues.slice(i, i + chunk)); - await bountyRepo.insert(bountyValues.slice(i, i + chunk)); + await issueRepo.insert(issueValues.slice(i, i + chunk) as any); + await bountyRepo.insert(bountyValues.slice(i, i + chunk) as any); } const findSpy = jest.spyOn(bountyRepo, 'find'); diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index e8bd675..11bb8a8 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +// @ts-ignore import type { StringValue } from 'ms'; import { TypeOrmModule } from '@nestjs/typeorm'; import { PassportModule } from '@nestjs/passport'; diff --git a/src/auth/guards/repo-scope.guard.ts b/src/auth/guards/repo-scope.guard.ts index 0e70ef2..ddfc96c 100644 --- a/src/auth/guards/repo-scope.guard.ts +++ b/src/auth/guards/repo-scope.guard.ts @@ -19,7 +19,7 @@ import { AppConfig } from '../../config/configuration'; interface AuthenticatedRequest extends Request { user?: { userId: string }; - params?: { owner?: string; repo?: string }; + params: Request['params'] & { owner?: string; repo?: string }; } /** diff --git a/src/auth/strategies/github.strategy.ts b/src/auth/strategies/github.strategy.ts index 76d362a..173afa9 100644 --- a/src/auth/strategies/github.strategy.ts +++ b/src/auth/strategies/github.strategy.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { Strategy, Profile } from 'passport-github2'; +// @ts-ignore import { VerifyCallback } from 'passport-oauth2'; import { ConfigService } from '@nestjs/config'; import { AppConfig } from '../../config/configuration'; diff --git a/src/bounties/bounties.controller.spec.ts b/src/bounties/bounties.controller.spec.ts index 9fa1966..b0b7364 100644 --- a/src/bounties/bounties.controller.spec.ts +++ b/src/bounties/bounties.controller.spec.ts @@ -68,7 +68,7 @@ describe('BountiesController', () => { difficulty: BountyDifficulty.INTERMEDIATE, }; - await controller.create(dto); + await controller.create(dto, { user: { userId: "u1" } } as any); expect(bountiesService.create).toHaveBeenCalledWith(dto); }); @@ -84,7 +84,7 @@ describe('BountiesController', () => { describe('fund', () => { it('calls bountiesService.fund with id and funderAddress', async () => { - await controller.fund('b1', { funderAddress: 'GFUNDER' }); + await controller.fund('b1', { funderAddress: 'GFUNDER' }, { user: { userId: 'u1' } } as any); expect(bountiesService.fund).toHaveBeenCalledWith('b1', 'GFUNDER'); }); @@ -92,7 +92,7 @@ describe('BountiesController', () => { describe('claim', () => { it('calls bountiesService.claim with id and contributorId', async () => { - await controller.claim('b1', { contributorId: 'contributor-1' }); + await controller.claim('b1', { user: { userId: 'contributor-1' } } as any); expect(bountiesService.claim).toHaveBeenCalledWith('b1', 'contributor-1'); }); @@ -116,7 +116,7 @@ describe('BountiesController', () => { describe('refund', () => { it('calls bountiesService.refund with the route param', async () => { - await controller.refund('b1'); + await controller.refund('b1', { user: { userId: 'u1' } } as any); expect(bountiesService.refund).toHaveBeenCalledWith('b1'); }); @@ -152,7 +152,7 @@ describe('BountiesController', () => { it('accepts undefined when the query param is absent', async () => { await expect( - pipe.transform(undefined, repositoryIdMetadata), + pipe.transform("" as any, repositoryIdMetadata), ).resolves.toBeUndefined(); }); diff --git a/src/bounties/bounties.controller.ts b/src/bounties/bounties.controller.ts index 38ad22e..cd6e560 100644 --- a/src/bounties/bounties.controller.ts +++ b/src/bounties/bounties.controller.ts @@ -27,7 +27,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; import { Roles } from '../auth/decorators/roles.decorator'; import { UserRole } from '../common/enums'; -import { Request } from 'express'; +import type { Request } from 'express'; import { ApiInternalErrorResponse, ApiStandardErrorResponses, diff --git a/src/bounties/bounties.query.spec.ts b/src/bounties/bounties.query.spec.ts index ec5d205..be521dc 100644 --- a/src/bounties/bounties.query.spec.ts +++ b/src/bounties/bounties.query.spec.ts @@ -10,7 +10,7 @@ describe('BountiesController repositoryId query pipe', () => { it('accepts undefined when repositoryId is omitted', async () => { await expect( - pipe.transform(undefined, repositoryIdMetadata), + pipe.transform("" as any, repositoryIdMetadata), ).resolves.toBeUndefined(); }); diff --git a/src/bounties/bounties.service.spec.ts b/src/bounties/bounties.service.spec.ts index 2450d71..b13c3ab 100644 --- a/src/bounties/bounties.service.spec.ts +++ b/src/bounties/bounties.service.spec.ts @@ -67,7 +67,7 @@ describe('BountiesService', () => { amount: '100', asset: AssetType.USDC, difficulty: BountyDifficulty.INTERMEDIATE, - }); + }, 'caller-1'); expect(bounty.status).toBe(BountyStatus.OPEN); }); @@ -80,7 +80,7 @@ describe('BountiesService', () => { sponsorId: 'sponsor-1', }); - const bounty = await service.fund('b1', 'GFUNDER'); + const bounty = await service.fund('b1', 'GFUNDER', 'caller-1'); expect(escrowService.fund).toHaveBeenCalledWith( expect.objectContaining({ @@ -104,7 +104,7 @@ describe('BountiesService', () => { issue: { githubIssueId: '2891234567' }, }); - await service.fund('b1', 'GFUNDER'); + await service.fund('b1', 'GFUNDER', 'caller-1'); expect(escrowService.fund).toHaveBeenCalledWith( expect.objectContaining({ @@ -119,7 +119,7 @@ describe('BountiesService', () => { id: 'b1', status: BountyStatus.FUNDED, }); - await expect(service.fund('b1', 'GFUNDER')).rejects.toThrow( + await expect(service.fund('b1', 'GFUNDER', 'caller-1')).rejects.toThrow( InvalidBountyTransitionError, ); }); @@ -208,7 +208,7 @@ describe('BountiesService', () => { escrowId: 'escrow-1', }); - const bounty = await service.refund('b1'); + const bounty = await service.refund('b1', 'caller-1'); expect(escrowService.refund).toHaveBeenCalledWith('escrow-1'); expect(bounty.status).toBe(BountyStatus.REFUNDED); @@ -221,7 +221,7 @@ describe('BountiesService', () => { escrowId: null, }); - const bounty = await service.refund('b1'); + const bounty = await service.refund('b1', 'caller-1'); expect(escrowService.refund).not.toHaveBeenCalled(); expect(bounty.status).toBe(BountyStatus.REFUNDED); diff --git a/src/bounties/bounties.service.ts b/src/bounties/bounties.service.ts index 32b3dba..25246ec 100644 --- a/src/bounties/bounties.service.ts +++ b/src/bounties/bounties.service.ts @@ -5,7 +5,6 @@ import { NotFoundException, Optional, } from '@nestjs/common'; -import { BadRequestException, Injectable, NotFoundException, Optional } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; @@ -108,20 +107,20 @@ export class BountiesService { // Verify caller is claiming for themselves bounty.claimedById = callerUserId; const contributor = await this.userRepo.findOne({ - where: { id: contributorId }, + where: { id: callerUserId }, }); if (!contributor) { throw new BadRequestException( - `Contributor ${contributorId} does not correspond to a known user`, + `Contributor ${callerUserId} does not correspond to a known user`, ); } if (!contributor.stellarAddress) { throw new BadRequestException( - `Contributor ${contributorId} has no linked Stellar address`, + `Contributor ${callerUserId} has no linked Stellar address`, ); } - bounty.claimedById = contributorId; + bounty.claimedById = callerUserId; bounty.status = BountyStatus.CLAIMED; bounty.claimedAt = new Date(); return this.bountyRepo.save(bounty); @@ -185,9 +184,9 @@ export class BountiesService { }); const userMap = new Map(users.map((u) => [u.id, u])); const recipients = team.splits.map((split) => { - const user = userMap.get(split.userId); + const user = split.userId ? userMap.get(split.userId) : undefined; return { - recipientId: split.userId, + recipientId: split.userId ?? undefined, recipientAddress: user?.stellarAddress ?? '', percentage: Number(split.percentage), }; diff --git a/src/common/entities/team-member-split.entity.ts b/src/common/entities/team-member-split.entity.ts index 0ac474b..7c21cbc 100644 --- a/src/common/entities/team-member-split.entity.ts +++ b/src/common/entities/team-member-split.entity.ts @@ -6,7 +6,6 @@ import { JoinColumn, ManyToOne, PrimaryGeneratedColumn, - Index, } from 'typeorm'; import { Team } from './team.entity'; import { User } from './user.entity'; diff --git a/src/common/stats/contributor-stats.sql.ts b/src/common/stats/contributor-stats.sql.ts index 94dce56..6252982 100644 --- a/src/common/stats/contributor-stats.sql.ts +++ b/src/common/stats/contributor-stats.sql.ts @@ -129,7 +129,7 @@ export async function queryPayoutHeatmap( .select(PAYMENT_UTC_DATE_SQL, 'date') .addSelect('COUNT(*)', 'count') .where('payment.recipientId = :userId', { userId }) - .andWhere('payment.status = :paid', { paid: PaymentStatus.PAID }); + .andWhere('payment.status = :paid', { paid: PaymentStatus.CONFIRMED }); if (range.from) { pQb.andWhere('payment.createdAt >= :from', { from: range.from }); @@ -178,7 +178,7 @@ export async function queryTopClients( "COALESCE(SUM(payment.amount) FILTER (WHERE payment.status = :paid AND payment.recipientId = :userId AND escrow.sponsorId IS NOT NULL), 0)", 'totalPaid', ) - .setParameter('paid', PaymentStatus.PAID) + .setParameter('paid', PaymentStatus.CONFIRMED) .setParameter('userId', userId) .groupBy('escrow.sponsorId') .orderBy('totalPaid', 'DESC') @@ -260,7 +260,7 @@ export async function queryContributorCoreStats( "COALESCE(SUM(payment.amount) FILTER (WHERE payment.status = :paid AND payment.recipientId = :userId), 0)", 'totalPaid', ) - .setParameter('paid', PaymentStatus.PAID) + .setParameter('paid', PaymentStatus.CONFIRMED) .setParameter('userId', userId) .getRawOne<{ totalPaid: string }>() : Promise.resolve({ totalPaid: '0' }), diff --git a/src/escrow/escrow.controller.spec.ts b/src/escrow/escrow.controller.spec.ts index 5d9e4a4..7a5e418 100644 --- a/src/escrow/escrow.controller.spec.ts +++ b/src/escrow/escrow.controller.spec.ts @@ -5,8 +5,6 @@ import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; import { EscrowController } from './escrow.controller'; import { EscrowService } from './escrow.service'; -import { FundEscrowDto } from './dto/fund-escrow.dto'; -import { AssetType } from '../common/enums'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; import { ROLES_KEY } from '../auth/decorators/roles.decorator'; @@ -15,7 +13,7 @@ import { IDEMPOTENCY_SCOPE_KEY } from '../common/idempotency/idempotent.decorato import { IdempotencyKey } from '../common/entities/idempotency-key.entity'; import type { Escrow } from '../common/entities'; import { AssetType, EscrowStatus, PaymentStatus } from '../common/enums'; -import type { FundEscrowDto } from './dto/fund-escrow.dto'; +import { FundEscrowDto } from './dto/fund-escrow.dto'; import type { SplitRecipientDto } from './dto/split-release.dto'; // These tests call the REAL EscrowController methods off a compiled Nest @@ -27,7 +25,7 @@ import type { SplitRecipientDto } from './dto/split-release.dto'; describe('EscrowController', () => { let controller: EscrowController; - const escrowRow = (overrides: Partial = {}) => + const escrowRow = (overrides: Partial = {}) => ({ id: 'escrow-1', amount: '10.0000000', @@ -115,7 +113,7 @@ describe('EscrowController', () => { }); it('release() returns the service escrow without metadata', async () => { - const result = await controller.release('escrow-1', 'GRECIPIENT', 'user-1'); + const result = await controller.release('escrow-1', { recipientAddress: 'GRECIPIENT', recipientId: 'user-1' } as any); expect(mockEscrowService.release).toHaveBeenCalledWith( 'escrow-1', @@ -142,7 +140,7 @@ describe('EscrowController', () => { { recipientAddress: 'GB', percentage: 50 }, ]; - const result = await controller.splitRelease('escrow-1', recipients); + const result = await controller.splitRelease('escrow-1', { recipients } as any); expect(mockEscrowService.splitRelease).toHaveBeenCalledWith( 'escrow-1', diff --git a/src/escrow/escrow.service.spec.ts b/src/escrow/escrow.service.spec.ts index 8b75083..387e03e 100644 --- a/src/escrow/escrow.service.spec.ts +++ b/src/escrow/escrow.service.spec.ts @@ -645,7 +645,7 @@ describe('EscrowService', () => { '10.0000000', 'GRECIPIENT', undefined, - dataSource.manager, + dataSource.manager as any, ); // #254: the lock and the caller's writes share the caller's transaction. diff --git a/src/github/github-webhooks.service.spec.ts b/src/github/github-webhooks.service.spec.ts index 45bb6fe..97bcf86 100644 --- a/src/github/github-webhooks.service.spec.ts +++ b/src/github/github-webhooks.service.spec.ts @@ -10,14 +10,13 @@ import * as sigUtil from './webhook-signature.util'; describe('GithubWebhooksService', () => { let service: GithubWebhooksService; - let webhookEventRepo: { create: jest.Mock; save: jest.Mock }; let webhookEventRepo: { create: jest.Mock; save: jest.Mock; findOne: jest.Mock; }; - let issueRepo: { findOne: jest.Mock }; - let bountyRepo: { findOne: jest.Mock }; + let issueRepo!: { findOne: jest.Mock }; + let bountyRepo!: { findOne: jest.Mock }; let bountiesService: { markInReview: jest.Mock; markMergedAndRelease: jest.Mock; @@ -869,6 +868,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/github/github-webhooks.service.ts b/src/github/github-webhooks.service.ts index 9221cdd..73fdcde 100644 --- a/src/github/github-webhooks.service.ts +++ b/src/github/github-webhooks.service.ts @@ -319,13 +319,16 @@ export class GithubWebhooksService { // step, matching the merged-PR branch's original behaviour (#47). requiredStatus: BountyStatus.CLAIMED, alsoProcessOtherStatuses: true, - preAction: (bounty) => - this.bountiesService.markInReview( + preAction: async (bounty) => { + await this.bountiesService.markInReview( bounty.id, payload.pull_request.html_url, payload.pull_request.number, - ), - action: (bounty) => this.bountiesService.markMergedAndRelease(bounty.id), + ); + }, + action: async (bounty) => { + await this.bountiesService.markMergedAndRelease(bounty.id); + }, }); } @@ -513,12 +516,13 @@ export class GithubWebhooksService { return this.processLinkedIssues(issueNumbers, payload, { requiredStatus: BountyStatus.CLAIMED, - action: (bounty) => - this.bountiesService.markInReview( + action: async (bounty) => { + await this.bountiesService.markInReview( bounty.id, payload.pull_request.html_url, payload.pull_request.number, - ), + ); + }, }); } @@ -543,7 +547,9 @@ export class GithubWebhooksService { return this.processLinkedIssues(issueNumbers, payload, { requiredStatus: BountyStatus.IN_REVIEW, - action: (bounty) => this.bountiesService.markPrClosedWithoutMerge(bounty.id), + action: async (bounty) => { + await this.bountiesService.markPrClosedWithoutMerge(bounty.id); + }, }); } } diff --git a/src/maintenance-pool/maintenance-pool.controller.ts b/src/maintenance-pool/maintenance-pool.controller.ts index 0b370c4..e9ba6f8 100644 --- a/src/maintenance-pool/maintenance-pool.controller.ts +++ b/src/maintenance-pool/maintenance-pool.controller.ts @@ -8,7 +8,7 @@ import { Req, UseGuards, } from '@nestjs/common'; -import { Request } from 'express'; +import type { Request } from 'express'; import { ApiBearerAuth, ApiOperation, diff --git a/src/milestones/milestones.controller.spec.ts b/src/milestones/milestones.controller.spec.ts index 8d87497..e455c2e 100644 --- a/src/milestones/milestones.controller.spec.ts +++ b/src/milestones/milestones.controller.spec.ts @@ -53,7 +53,7 @@ describe('MilestonesController', () => { asset: AssetType.USDC, }; - await controller.create(dto); + await controller.create(dto, { user: { userId: "u1" } } as any); expect(milestonesService.create).toHaveBeenCalledWith(dto); }); @@ -77,7 +77,7 @@ describe('MilestonesController', () => { describe('fund', () => { it('calls milestonesService.fund with id and funderAddress', async () => { - await controller.fund('m1', { funderAddress: 'GFUNDER' }); + await controller.fund('m1', { funderAddress: 'GFUNDER' }, { user: { userId: 'u1' } } as any); expect(milestonesService.fund).toHaveBeenCalledWith('m1', 'GFUNDER'); }); diff --git a/src/milestones/milestones.controller.ts b/src/milestones/milestones.controller.ts index 0aaf9d9..41c0e14 100644 --- a/src/milestones/milestones.controller.ts +++ b/src/milestones/milestones.controller.ts @@ -24,7 +24,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; import { Roles } from '../auth/decorators/roles.decorator'; import { UserRole } from '../common/enums'; -import { Request } from 'express'; +import type { Request } from 'express'; import { ApiInternalErrorResponse, ApiStandardErrorResponses, diff --git a/src/milestones/milestones.service.spec.ts b/src/milestones/milestones.service.spec.ts index 612bde8..573aacc 100644 --- a/src/milestones/milestones.service.spec.ts +++ b/src/milestones/milestones.service.spec.ts @@ -75,7 +75,7 @@ describe('MilestonesService', () => { sponsorId: 'sponsor-1', }); - const milestone = await service.fund('m1', 'GFUNDER'); + const milestone = await service.fund('m1', 'GFUNDER', 'caller-1'); expect(milestone.status).toBe(MilestoneStatus.FUNDED); expect(milestone.escrowId).toBe('escrow-1'); @@ -90,7 +90,7 @@ describe('MilestonesService', () => { sponsorId: 'sponsor-1', }); - await service.fund('m1', 'GFUNDER'); + await service.fund('m1', 'GFUNDER', 'caller-1'); expect(escrowService.fund).toHaveBeenCalledWith( expect.objectContaining({ @@ -110,7 +110,7 @@ describe('MilestonesService', () => { sponsorId: null, }); - await service.fund('m2', 'GFUNDER'); + await service.fund('m2', 'GFUNDER', 'caller-1'); expect(escrowService.fund).toHaveBeenCalledWith( expect.objectContaining({ sponsorId: null }), @@ -123,7 +123,7 @@ describe('MilestonesService', () => { status: MilestoneStatus.FUNDED, }); - await expect(service.fund('m1', 'GFUNDER')).rejects.toThrow( + await expect(service.fund('m1', 'GFUNDER', 'caller-1')).rejects.toThrow( 'Milestone m1 is not OPEN (current: funded)', ); }); diff --git a/src/sponsors/sponsors.controller.ts b/src/sponsors/sponsors.controller.ts index b7a48cc..c097bf1 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)); } } diff --git a/src/teams/team-split.util.spec.ts b/src/teams/team-split.util.spec.ts index 949efc4..22782c5 100644 --- a/src/teams/team-split.util.spec.ts +++ b/src/teams/team-split.util.spec.ts @@ -5,9 +5,9 @@ describe('team split percentage math', () => { it('accepts splits that sum to exactly 100', () => { expect(() => validateSplitPercentages([ - { percentage: 40 }, - { percentage: 40 }, - { percentage: 20 }, + { userId: "u1", percentage: 40 }, + { userId: "u1", percentage: 40 }, + { userId: "u1", percentage: 20 }, ]), ).not.toThrow(); }); @@ -15,28 +15,28 @@ describe('team split percentage math', () => { it('accepts splits within floating point tolerance of 100', () => { expect(() => validateSplitPercentages([ - { percentage: 33.33 }, - { percentage: 33.33 }, - { percentage: 33.34 }, + { userId: "u1", percentage: 33.33 }, + { userId: "u1", percentage: 33.33 }, + { userId: "u1", percentage: 33.34 }, ]), ).not.toThrow(); }); it('rejects splits that sum to less than 100', () => { expect(() => - validateSplitPercentages([{ percentage: 40 }, { percentage: 40 }]), + validateSplitPercentages([{ userId: "u1", percentage: 40 }, { userId: "u1", percentage: 40 }]), ).toThrow(BadRequestException); }); it('rejects splits that sum to more than 100', () => { expect(() => - validateSplitPercentages([{ percentage: 60 }, { percentage: 60 }]), + validateSplitPercentages([{ userId: "u1", percentage: 60 }, { userId: "u1", percentage: 60 }]), ).toThrow(BadRequestException); }); it('rejects a zero or negative percentage', () => { expect(() => - validateSplitPercentages([{ percentage: 0 }, { percentage: 100 }]), + validateSplitPercentages([{ userId: "u1", percentage: 0 }, { userId: "u1", percentage: 100 }]), ).toThrow(BadRequestException); }); diff --git a/src/teams/teams.controller.spec.ts b/src/teams/teams.controller.spec.ts index 83152cf..362e419 100644 --- a/src/teams/teams.controller.spec.ts +++ b/src/teams/teams.controller.spec.ts @@ -48,7 +48,7 @@ describe('TeamsController', () => { members: [{ userId: 'u1', percentage: 100 }], }; - await controller.create(dto); + await controller.create(dto, { userId: "u1" } as any); expect(teamsService.create).toHaveBeenCalledWith(dto); }); @@ -65,7 +65,7 @@ describe('TeamsController', () => { describe('updateSplits', () => { it('calls teamsService.updateSplits with id and members', async () => { const members = [{ userId: 'u1', percentage: 100 }]; - await controller.updateSplits('t1', members); + await controller.updateSplits('t1', { splits: members } as any); expect(teamsService.updateSplits).toHaveBeenCalledWith('t1', members); }); diff --git a/src/teams/teams.controller.ts b/src/teams/teams.controller.ts index f2ea54d..a35cb28 100644 --- a/src/teams/teams.controller.ts +++ b/src/teams/teams.controller.ts @@ -1,3 +1,4 @@ +import type { AuthenticatedUser } from '../common/decorators/current-user.decorator'; import { Body, Controller, @@ -21,7 +22,7 @@ import { ApiStandardErrorResponses, } from '../common/swagger/api-common-responses.decorator'; import { - AuthenticatedUser, + CurrentUser, } from '../common/decorators/current-user.decorator'; @@ -79,7 +80,7 @@ export class TeamsController { assign( @Param('id', new ParseUUIDPipe()) id: string, @Param('bountyId', new ParseUUIDPipe()) bountyId: string, - @CurrentUser() user: AuthenticatedUser, + @CurrentUser() user: AuthenticatedUser, ) { return this.teamsService.assignToBounty(id, bountyId, user.userId); } diff --git a/src/users/users.service.spec.ts b/src/users/users.service.spec.ts index ba1fe1a..2c3eb89 100644 --- a/src/users/users.service.spec.ts +++ b/src/users/users.service.spec.ts @@ -322,7 +322,7 @@ describe('UsersService', () => { describe('list', () => { it('returns every user mapped to its public shape', async () => { - userRepo.find = jest.fn().mockResolvedValue([ + (userRepo as any).find = jest.fn().mockResolvedValue([ { id: 'u1', username: 'a',