diff --git a/apps/swap-service/src/affiliate/__tests__/affiliate.service.test.ts b/apps/swap-service/src/affiliate/__tests__/affiliate.service.test.ts new file mode 100644 index 0000000..c2fee7d --- /dev/null +++ b/apps/swap-service/src/affiliate/__tests__/affiliate.service.test.ts @@ -0,0 +1,89 @@ +import type { Affiliate } from '@prisma/client' + +import type { PrismaService } from '../../prisma/prisma.service' +import { AffiliateService } from '../affiliate.service' + +type MockAffiliateDelegate = { + findUnique: jest.Mock + create: jest.Mock +} + +const makePrismaMock = (overrides?: Partial): PrismaService => { + const affiliate: MockAffiliateDelegate = { + findUnique: jest.fn().mockResolvedValue(null), + create: jest.fn(), + ...overrides, + } + return { affiliate } as unknown as PrismaService +} + +const baseRow = (overrides?: Partial): Affiliate => ({ + id: 'a1', + walletAddress: '0x1111111111111111111111111111111111111111', + receiveAddress: null, + partnerCode: 'goodcode', + bps: 60, + isActive: true, + createdAt: new Date('2026-05-28T00:00:00.000Z'), + updatedAt: new Date('2026-05-28T00:00:00.000Z'), + ...overrides, +}) + +describe('AffiliateService.createAffiliate', () => { + const wallet = '0x1111111111111111111111111111111111111111' + + it('creates a row when wallet and code are unused', async () => { + const create = jest.fn().mockResolvedValue(baseRow()) + const prisma = makePrismaMock({ create }) + const service = new AffiliateService(prisma) + + const result = await service.createAffiliate({ + walletAddress: wallet, + partnerCode: 'goodcode', + bps: 75, + }) + + expect(create).toHaveBeenCalledWith({ + data: { + walletAddress: wallet, + receiveAddress: undefined, + partnerCode: 'goodcode', + bps: 75, + }, + }) + expect(result.walletAddress).toBe(wallet) + }) + + it('rejects when wallet is already registered', async () => { + const prisma = makePrismaMock({ + findUnique: jest.fn().mockResolvedValueOnce(baseRow()), + }) + const service = new AffiliateService(prisma) + + await expect(service.createAffiliate({ walletAddress: wallet, partnerCode: 'newcode', bps: 60 })).rejects.toThrow( + /already/, + ) + }) + + it('rejects reserved partner codes (case-insensitive)', async () => { + const prisma = makePrismaMock() + const service = new AffiliateService(prisma) + + await expect(service.createAffiliate({ walletAddress: wallet, partnerCode: 'ADMIN', bps: 60 })).rejects.toThrow( + /reserved/, + ) + }) + + it('rejects when partner code is already taken', async () => { + const findUnique = jest + .fn() + .mockResolvedValueOnce(null) // wallet lookup + .mockResolvedValueOnce(baseRow({ walletAddress: '0xother', partnerCode: 'goodcode' })) + const prisma = makePrismaMock({ findUnique }) + const service = new AffiliateService(prisma) + + await expect(service.createAffiliate({ walletAddress: wallet, partnerCode: 'goodcode', bps: 60 })).rejects.toThrow( + /taken/, + ) + }) +}) diff --git a/apps/swap-service/src/affiliate/affiliate.controller.ts b/apps/swap-service/src/affiliate/affiliate.controller.ts index 1513458..764a007 100644 --- a/apps/swap-service/src/affiliate/affiliate.controller.ts +++ b/apps/swap-service/src/affiliate/affiliate.controller.ts @@ -18,13 +18,7 @@ import { SHAPESHIFT_BPS } from '../swaps/constants' import { AffiliateService } from './affiliate.service' import { SiweAuthGuard, SiweRequest } from './siwe-auth.guard' -import { - AffiliateStatsQueryDto, - AffiliateSwapsQueryDto, - ClaimPartnerCodeDto, - CreateAffiliateDto, - UpdateAffiliateDto, -} from './types' +import { AffiliateStatsQueryDto, AffiliateSwapsQueryDto, CreateAffiliateDto, UpdateAffiliateDto } from './types' import { assertSiweMatches } from './utils' const toAffiliateResponse = (affiliate: Affiliate) => { @@ -62,7 +56,10 @@ export class AffiliateController { return toAffiliateResponse(await this.affiliateService.createAffiliate(data)) } catch (error) { if (error instanceof Error) { - if (error.message.includes('already')) throw new ConflictException(error.message) + if (error.message.includes('reserved')) throw new BadRequestException(error.message) + if (error.message.includes('already') || error.message.includes('taken')) { + throw new ConflictException(error.message) + } } throw error } @@ -75,26 +72,6 @@ export class AffiliateController { return toAffiliateResponse(await this.affiliateService.updateAffiliate(address, data)) } - - @UseGuards(SiweAuthGuard) - @Post('claim-code') - async claimPartnerCode(@Req() req: SiweRequest, @Body() data: ClaimPartnerCodeDto) { - assertSiweMatches(req, data.walletAddress, 'Authenticated address does not match walletAddress') - - try { - return toAffiliateResponse(await this.affiliateService.claimPartnerCode(data.walletAddress, data.partnerCode)) - } catch (error) { - if (error instanceof Error) { - if (error.message.includes('taken') || error.message.includes('reserved')) { - throw new ConflictException(error.message) - } - if (error.message.includes('must be')) { - throw new BadRequestException(error.message) - } - } - throw error - } - } } @Controller('v1/partner') diff --git a/apps/swap-service/src/affiliate/affiliate.service.ts b/apps/swap-service/src/affiliate/affiliate.service.ts index 4307187..177347b 100644 --- a/apps/swap-service/src/affiliate/affiliate.service.ts +++ b/apps/swap-service/src/affiliate/affiliate.service.ts @@ -14,7 +14,7 @@ import type { CreateAffiliateDto, UpdateAffiliateDto, } from './types' -import { RESERVED_PARTNER_CODES } from './utils' +import { isReservedPartnerCode } from './utils' @Injectable() export class AffiliateService { @@ -38,12 +38,14 @@ export class AffiliateService { const existing = await this.prisma.affiliate.findUnique({ where: { walletAddress } }) if (existing) throw new Error('Affiliate already registered') - if (data.partnerCode) { - const existingCode = await this.prisma.affiliate.findUnique({ where: { partnerCode } }) - if (existingCode) throw new Error('Partner code already taken') - } + if (isReservedPartnerCode(partnerCode)) throw new Error('This partner code is reserved') + + const existingCode = await this.prisma.affiliate.findUnique({ where: { partnerCode } }) + if (existingCode) throw new Error('Partner code already taken') - return this.prisma.affiliate.create({ data: { walletAddress, receiveAddress, partnerCode, bps: bps ?? 60 } }) + return this.prisma.affiliate.create({ + data: { walletAddress, receiveAddress, partnerCode, bps }, + }) } async updateAffiliate(walletAddress: string, data: UpdateAffiliateDto): Promise { @@ -62,21 +64,6 @@ export class AffiliateService { return this.prisma.affiliate.update({ where: { walletAddress }, data: updateData }) } - async claimPartnerCode(walletAddress: string, partnerCode: string): Promise { - if (RESERVED_PARTNER_CODES.includes(partnerCode.toLowerCase())) throw new Error('This partner code is reserved') - - const existingCode = await this.prisma.affiliate.findUnique({ where: { partnerCode } }) - if (existingCode && existingCode.walletAddress.toLowerCase() !== walletAddress.toLowerCase()) { - throw new Error('Partner code already taken') - } - - return this.prisma.affiliate.upsert({ - where: { walletAddress }, - update: { partnerCode }, - create: { walletAddress, partnerCode, bps: 60 }, - }) - } - async getAffiliateStats(address: string, options: AffiliateStatsQueryDto): Promise { const { startDate, endDate } = options diff --git a/apps/swap-service/src/affiliate/types.ts b/apps/swap-service/src/affiliate/types.ts index 7e5f706..d492982 100644 --- a/apps/swap-service/src/affiliate/types.ts +++ b/apps/swap-service/src/affiliate/types.ts @@ -5,7 +5,7 @@ import { PaginationQueryDto } from '../swaps/types' import { PARTNER_CODE_REGEX } from './utils' -const PARTNER_CODE_MESSAGE = 'Partner code must be 3-32 alphanumeric characters or hyphens' +const PARTNER_CODE_MESSAGE = 'Partner code must be 3-32 lowercase letters or numbers (e.g. mypartnercode)' export interface AffiliateStatsResult { totalSwaps: number @@ -53,15 +53,13 @@ export class CreateAffiliateDto { @IsEthereumAddress() receiveAddress?: string - @IsOptional() @Matches(PARTNER_CODE_REGEX, { message: PARTNER_CODE_MESSAGE }) - partnerCode?: string + partnerCode: string - @IsOptional() @IsInt() @Min(0) @Max(1000) - bps?: number + bps: number } export class UpdateAffiliateDto { @@ -79,11 +77,3 @@ export class UpdateAffiliateDto { @IsBoolean() isActive?: boolean } - -export class ClaimPartnerCodeDto { - @IsEthereumAddress() - walletAddress: string - - @Matches(PARTNER_CODE_REGEX, { message: PARTNER_CODE_MESSAGE }) - partnerCode: string -} diff --git a/apps/swap-service/src/affiliate/utils.ts b/apps/swap-service/src/affiliate/utils.ts index ea60664..de9e6df 100644 --- a/apps/swap-service/src/affiliate/utils.ts +++ b/apps/swap-service/src/affiliate/utils.ts @@ -2,8 +2,30 @@ import { ForbiddenException } from '@nestjs/common' import type { SiweRequest } from './siwe-auth.guard' -export const PARTNER_CODE_REGEX = /^[a-zA-Z0-9-]{3,32}$/ -export const RESERVED_PARTNER_CODES = ['ss', 'admin', 'api', 'test', 'demo'] +export const PARTNER_CODE_REGEX = /^[a-z0-9]{3,32}$/ + +const RESERVED_PARTNER_CODES = [ + 'admin', + 'api', + 'auth', + 'demo', + 'dev', + 'develop', + 'prod', + 'production', + 'release', + 'ss', + 'system', + 'test', +] + +const RESERVED_PARTNER_CODE_SUBSTRINGS = ['shapeshift'] + +export function isReservedPartnerCode(partnerCode: string): boolean { + const code = partnerCode.toLowerCase() + if (RESERVED_PARTNER_CODES.includes(code)) return true + return RESERVED_PARTNER_CODE_SUBSTRINGS.some((substring) => code.includes(substring)) +} export function assertSiweMatches(req: SiweRequest, target: string, message: string): void { if (req.siweAddress !== target.toLowerCase()) throw new ForbiddenException(message)