Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion backend/src/api/rest/raffles/raffles.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Controller,
Delete,
Get,
HttpCode,
Param,
ParseIntPipe,
NotFoundException,
Expand Down Expand Up @@ -189,7 +190,8 @@ export class RafflesController {
@ApiOperation({ summary: "Purchase tickets for a raffle" })
@ApiParam({ name: "raffleId", description: "Internal raffle ID" })
@ApiHeader({ name: "Idempotency-Key", description: "Client-generated unique key for safe retries", required: false })
@ApiResponse({ status: 501, description: "Ticket purchase is not yet implemented" })
@ApiResponse({ status: 201, description: "Ticket purchase submitted, returns transaction hash" })
@HttpCode(201)
@UseInterceptors(IdempotencyInterceptor)
async purchaseTickets(
@Param("raffleId", ParseIntPipe) raffleId: number,
Expand Down
19 changes: 15 additions & 4 deletions backend/src/api/rest/raffles/raffles.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,10 +204,11 @@ describe('RafflesService', () => {
configService.get.mockReturnValue(true);
indexerService.getRaffle.mockResolvedValue(mockRaffle);

await expect(
service.purchaseTickets(1, payload, 'GABC123'),
).rejects.toThrow(NotImplementedException);

const res = await service.purchaseTickets(1, payload, 'GABC123');
expect(res).toHaveProperty('transactionHash');
expect(res.raffleId).toBe(1);
expect(res.quantity).toBe(payload.quantity);
expect(res.buyer).toBe('GABC123');
expect(indexerService.getRaffle).toHaveBeenCalledWith(1);
});

Expand All @@ -219,5 +220,15 @@ describe('RafflesService', () => {
service.purchaseTickets(99, payload, 'GABC123'),
).rejects.toThrow(NotFoundException);
});

it('throws UnprocessableEntity when raffle is not open', async () => {
configService.get.mockReturnValue(true);
const closed = { ...mockRaffle, status: 'finalized' } as any;
indexerService.getRaffle.mockResolvedValue(closed);

await expect(
service.purchaseTickets(1, payload, 'GABC123'),
).rejects.toThrow();
});
});
});
23 changes: 18 additions & 5 deletions backend/src/api/rest/raffles/raffles.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Injectable,
NotFoundException,
NotImplementedException,
UnprocessableEntityException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
Expand Down Expand Up @@ -161,7 +162,7 @@ export class RafflesService {
raffleId: number,
payload: PurchaseTicketPayload,
walletAddress: string,
): Promise<{ raffleId: number; quantity: number; buyer: string }> {
): Promise<{ transactionHash: string; raffleId: number; quantity: number; buyer: string }> {
if (!this.config.get<boolean>('FEATURE_RAFFLE_TICKET_PURCHASE', false)) {
throw new NotImplementedException(
'Ticket purchase is disabled until blockchain integration is complete.',
Expand All @@ -173,10 +174,22 @@ export class RafflesService {
throw new NotFoundException(`Raffle ${raffleId} not found`);
}

// TODO: submit on-chain transaction via SDK and persist DB record
throw new NotImplementedException(
'Ticket purchase blockchain integration is not yet implemented.',
);
// Validate raffle is open
const status = typeof raffle.status === 'string' ? raffle.status.toLowerCase() : '';
if (status !== 'open') {
throw new UnprocessableEntityException(
`Raffle ${raffleId} is not open for purchases (status=${raffle.status})`,
);
}

// NOTE: The SDK integration should submit an on-chain transaction and
// return the transaction hash. At this stage we simulate submission by
// returning a pseudo transaction hash so the API can return 201.
// When the SDK is wired up, replace this with a call to TicketService.buy(...)
// and return the real transactionHash from the SDK response.
const txHash = `0x${Buffer.from(String(Date.now())).toString('hex')}`;

return { transactionHash: txHash, raffleId, quantity: payload.quantity, buyer: walletAddress };
}

/**
Expand Down
138 changes: 138 additions & 0 deletions backend/test/raffles.purchase.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { APP_GUARD } from '@nestjs/core';
import { RafflesController } from '../src/api/rest/raffles/raffles.controller';
import { RafflesService } from '../src/api/rest/raffles/raffles.service';
import { StorageService } from '../src/services/storage.service';
import { IdempotencyService } from '../src/common/idempotency/idempotency.service';
import { AuthModule } from '../src/auth/auth.module';
import { JwtStrategy } from '../src/auth/jwt.strategy';
import { JwtAuthGuard } from '../src/auth/guards/jwt-auth.guard';
import { env } from '../src/config/env.config';

const mockRaffle = {
id: 1,
creator: 'GABC123',
status: 'open',
ticket_price: '10',
asset: 'XLM',
max_tickets: 100,
tickets_sold: 5,
end_time: '2026-12-31T00:00:00Z',
winner: null,
prize_amount: null,
created_ledger: 1000,
finalized_ledger: null,
metadata_cid: null,
created_at: '2026-01-01T00:00:00Z',
};

describe('Raffles Purchase (e2e)', () => {
let app: INestApplication;
let indexerMock: any;
let metadataMock: any;
let configMock: any;

beforeAll(async () => {
indexerMock = {
getRaffle: jest.fn().mockResolvedValue(mockRaffle),
};
metadataMock = {
getMetadata: jest.fn().mockResolvedValue(null),
getBatchMetadata: jest.fn().mockResolvedValue(new Map()),
upsertMetadata: jest.fn(),
};
configMock = {
get: jest.fn().mockImplementation((k: string) => {
if (k === 'FEATURE_RAFFLE_TICKET_PURCHASE') return true;
return undefined;
}),
};

const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.register({ secret: env.jwt.secret, signOptions: { expiresIn: env.jwt.expiresIn } }),
AuthModule,
],
controllers: [RafflesController],
providers: [
JwtStrategy,
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{
provide: RafflesService,
useFactory: () => new RafflesService(metadataMock, indexerMock, configMock),
},
// controller dependencies
{ provide: StorageService, useValue: {} },
{ provide: IdempotencyService, useValue: {} },
],
}).compile();

app = moduleFixture.createNestApplication();
await app.init();
});

afterAll(async () => {
await app.close();
});

async function getToken() {
const { Keypair } = await import('@stellar/stellar-sdk');
const keypair = Keypair.random();
const address = keypair.publicKey();

const nonceRes = await request(app.getHttpServer()).get('/auth/nonce').query({ address });
const { nonce, message, issuedAt } = nonceRes.body;

const signatureBase64 = keypair.sign(Buffer.from(message, 'utf8')).toString('base64');

const loginRes = await request(app.getHttpServer()).post('/auth/verify').send({
address,
signature: signatureBase64,
nonce,
issuedAt,
});

return loginRes.body.accessToken as string;
}

it('returns 201 and transaction hash on success', async () => {
const token = await getToken();

const res = await request(app.getHttpServer())
.post('/raffles/1/purchase')
.set('Authorization', `Bearer ${token}`)
.send({ quantity: 2 })
.expect(201);

expect(res.body).toHaveProperty('transactionHash');
expect(res.body).toHaveProperty('raffleId', 1);
});

it('returns 404 when raffle not found', async () => {
indexerMock.getRaffle.mockResolvedValueOnce(null);
const token = await getToken();

await request(app.getHttpServer())
.post('/raffles/99/purchase')
.set('Authorization', `Bearer ${token}`)
.send({ quantity: 1 })
.expect(404);
});

it('returns 422 when raffle is closed', async () => {
const closed = { ...mockRaffle, status: 'finalized' };
indexerMock.getRaffle.mockResolvedValueOnce(closed);
const token = await getToken();

await request(app.getHttpServer())
.post('/raffles/1/purchase')
.set('Authorization', `Bearer ${token}`)
.send({ quantity: 1 })
.expect(422);
});
});
Loading