Skip to content
Merged
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
195 changes: 195 additions & 0 deletions backend/src/common/interceptors/response.interceptor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ExecutionContext, StreamableFile } from '@nestjs/common';
import { ResponseInterceptor, ApiSuccessEnvelope } from './response.interceptor';
import { CallHandler } from '@nestjs/common';
import { of } from 'rxjs';

describe('ResponseInterceptor', () => {
let interceptor: ResponseInterceptor;
let mockExecutionContext: jest.Mocked<ExecutionContext>;
let mockCallHandler: jest.Mocked<CallHandler>;
let mockResponse: any;

beforeEach(async () => {
mockResponse = {
statusCode: 200,
};

mockExecutionContext = {
switchToHttp: jest.fn().mockReturnValue({
getResponse: jest.fn().mockReturnValue(mockResponse),
}),
} as unknown as jest.Mocked<ExecutionContext>;

mockCallHandler = {
handle: jest.fn(),
} as unknown as jest.Mocked<CallHandler>;

interceptor = new ResponseInterceptor();
});

describe('normal response wrapping', () => {
it('wraps normal response in success envelope', (done) => {
const responseData = { id: 1, name: 'test' };
mockCallHandler.handle.mockReturnValue(of(responseData));

interceptor
.intercept(mockExecutionContext, mockCallHandler)
.subscribe((result) => {
expect(result).toEqual(
expect.objectContaining({
success: true,
data: responseData,
timestamp: expect.any(String),
}),
);
expect(result.timestamp).toMatch(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/,
);
done();
});
});

it('wraps null data in success envelope', (done) => {
mockCallHandler.handle.mockReturnValue(of(null));

interceptor
.intercept(mockExecutionContext, mockCallHandler)
.subscribe((result) => {
expect(result).toEqual(
expect.objectContaining({
success: true,
data: null,
timestamp: expect.any(String),
}),
);
done();
});
});

it('wraps array response in success envelope', (done) => {
const responseData = [{ id: 1 }, { id: 2 }];
mockCallHandler.handle.mockReturnValue(of(responseData));

interceptor
.intercept(mockExecutionContext, mockCallHandler)
.subscribe((result) => {
expect(result).toEqual(
expect.objectContaining({
success: true,
data: responseData,
timestamp: expect.any(String),
}),
);
done();
});
});

it('wraps empty object in success envelope', (done) => {
const responseData = {};
mockCallHandler.handle.mockReturnValue(of(responseData));

interceptor
.intercept(mockExecutionContext, mockCallHandler)
.subscribe((result) => {
expect(result).toEqual(
expect.objectContaining({
success: true,
data: responseData,
timestamp: expect.any(String),
}),
);
done();
});
});
});

describe('StreamableFile passthrough', () => {
it('does not wrap StreamableFile instance', (done) => {
const streamableFile = new StreamableFile(Buffer.from('test content'));
mockCallHandler.handle.mockReturnValue(of(streamableFile));

interceptor
.intercept(mockExecutionContext, mockCallHandler)
.subscribe((result) => {
expect(result).toBe(streamableFile);
expect(result).not.toHaveProperty('success');
expect(result).not.toHaveProperty('data');
expect(result).not.toHaveProperty('timestamp');
done();
});
});
});

describe('204 No Content status passthrough', () => {
it('does not wrap response when status code is 204', (done) => {
mockResponse.statusCode = 204;
const responseData = undefined;
mockCallHandler.handle.mockReturnValue(of(responseData));

interceptor
.intercept(mockExecutionContext, mockCallHandler)
.subscribe((result) => {
expect(result).toBeUndefined();
done();
});
});

it('does not wrap response when status code is 304 Not Modified', (done) => {
mockResponse.statusCode = 304;
const responseData = undefined;
mockCallHandler.handle.mockReturnValue(of(responseData));

interceptor
.intercept(mockExecutionContext, mockCallHandler)
.subscribe((result) => {
expect(result).toBeUndefined();
done();
});
});
});

describe('status code boundary cases', () => {
it('wraps response when status code is 200', (done) => {
mockResponse.statusCode = 200;
const responseData = { message: 'ok' };
mockCallHandler.handle.mockReturnValue(of(responseData));

interceptor
.intercept(mockExecutionContext, mockCallHandler)
.subscribe((result) => {
expect(result).toHaveProperty('success', true);
expect(result).toHaveProperty('data', responseData);
done();
});
});

it('wraps response when status code is 201 Created', (done) => {
mockResponse.statusCode = 201;
const responseData = { id: 'new-id' };
mockCallHandler.handle.mockReturnValue(of(responseData));

interceptor
.intercept(mockExecutionContext, mockCallHandler)
.subscribe((result) => {
expect(result).toHaveProperty('success', true);
expect(result).toHaveProperty('data', responseData);
done();
});
});

it('wraps response when status code is 400 Bad Request', (done) => {
mockResponse.statusCode = 400;
const responseData = { error: 'invalid input' };
mockCallHandler.handle.mockReturnValue(of(responseData));

interceptor
.intercept(mockExecutionContext, mockCallHandler)
.subscribe((result) => {
expect(result).toHaveProperty('success', true);
expect(result).toHaveProperty('data', responseData);
done();
});
});
});
});
90 changes: 90 additions & 0 deletions backend/src/seasons/seasons.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,96 @@ describe('SeasonsService', () => {
}),
);
});

it('throws NotFoundException when no season at all exists in database', async () => {
seasonsRepository.createQueryBuilder.mockReturnValue({
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue(null),
} as never);

await expect(service.findActive()).rejects.toThrow(NotFoundException);
await expect(service.findActive()).rejects.toThrow(
'No active season exists',
);
});

it('throws NotFoundException when season exists but window has not started yet', async () => {
const futureStart = new Date();
futureStart.setFullYear(futureStart.getFullYear() + 1);
const futureEnd = new Date(futureStart);
futureEnd.setMonth(futureEnd.getMonth() + 6);

seasonsRepository.createQueryBuilder.mockReturnValue({
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue(null),
} as never);

await expect(service.findActive()).rejects.toThrow(NotFoundException);
});

it('throws NotFoundException when season exists but has already ended', async () => {
const pastStart = new Date();
pastStart.setFullYear(pastStart.getFullYear() - 2);
const pastEnd = new Date();
pastEnd.setFullYear(pastEnd.getFullYear() - 1);

seasonsRepository.createQueryBuilder.mockReturnValue({
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue(null),
} as never);

await expect(service.findActive()).rejects.toThrow(NotFoundException);
});

it('returns the season with latest starts_at when multiple active seasons overlap', async () => {
const olderSeason: Season = {
id: 's1',
season_number: 1,
name: 'Season 1',
starts_at: new Date('2020-01-01'),
ends_at: new Date('2099-01-01'),
reward_pool_stroops: '1',
is_active: true,
is_finalized: false,
top_winner: null,
on_chain_season_id: null,
soroban_tx_hash: null,
created_at: new Date(),
updated_at: new Date(),
};
const newerSeason: Season = {
id: 's2',
season_number: 2,
name: 'Season 2',
starts_at: new Date('2022-01-01'),
ends_at: new Date('2099-01-01'),
reward_pool_stroops: '1',
is_active: true,
is_finalized: false,
top_winner: null,
on_chain_season_id: null,
soroban_tx_hash: null,
created_at: new Date(),
updated_at: new Date(),
};

seasonsRepository.createQueryBuilder.mockReturnValue({
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue(newerSeason),
} as never);

const result = await service.findActive();

expect(result).toEqual(newerSeason);
});
});

describe('create', () => {
Expand Down
70 changes: 70 additions & 0 deletions backend/src/websocket/events.gateway.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const makeSocket = (overrides: Record<string, unknown> = {}) =>
leave: jest.fn(),
emit: jest.fn(),
on: jest.fn(),
disconnect: jest.fn(),
...overrides,
}) as any;

Expand Down Expand Up @@ -137,6 +138,75 @@ describe('EventsGateway', () => {
});
});

describe('rate limiting', () => {
it('allows up to 60 messages without disconnect', async () => {
const client = makeSocket();
const rateLimits = (gateway as any).rateLimits;

for (let i = 0; i < 60; i++) {
await gateway.handleJoin(client, 'event:1');
}

expect(client.disconnect).not.toHaveBeenCalled();
expect(client.emit).toHaveBeenCalledWith('joined', { room: 'event:1' });
});

it('disconnects socket on 61st message when rate limit exceeded', async () => {
const client = makeSocket();

for (let i = 0; i < 60; i++) {
await gateway.handleJoin(client, 'event:1');
}

expect(client.disconnect).not.toHaveBeenCalled();

await gateway.handleJoin(client, 'event:2');

expect(client.disconnect).toHaveBeenCalledTimes(1);
expect(client.emit).toHaveBeenCalledWith('error', {
message: 'Rate limit exceeded',
});
});

it('enforces per-socket rate limiting', async () => {
const client1 = makeSocket({ id: 'socket-1' });
const client2 = makeSocket({ id: 'socket-2' });

(gateway as any).rateLimits.set('socket-1', 60);

await gateway.handleJoin(client1, 'event:1');

expect(client1.disconnect).toHaveBeenCalled();
expect(client1.emit).toHaveBeenCalledWith('error', {
message: 'Rate limit exceeded',
});

await gateway.handleJoin(client2, 'event:1');

expect(client2.disconnect).not.toHaveBeenCalled();
expect(client2.emit).toHaveBeenCalledWith('joined', { room: 'event:1' });
});

it('resets rate limit counter after window expires', async () => {
jest.useFakeTimers();
const client = makeSocket();
const rateLimitWindow = 60_000;

await gateway.handleJoin(client, 'event:1');

expect(client.disconnect).not.toHaveBeenCalled();

const rateLimits = (gateway as any).rateLimits;
expect(rateLimits.has('socket-1')).toBe(true);

jest.advanceTimersByTime(rateLimitWindow + 100);

expect(rateLimits.has('socket-1')).toBe(false);

jest.useRealTimers();
});
});

describe('handleLeave', () => {
it('leaves room and emits left event', async () => {
const client = makeSocket();
Expand Down
1 change: 1 addition & 0 deletions backend/src/websocket/events.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export class EventsGateway
): Promise<void> {
if (!this.checkRateLimit(client.id)) {
client.emit('error', { message: 'Rate limit exceeded' });
client.disconnect();
return;
}

Expand Down
3 changes: 3 additions & 0 deletions contracts/open-market/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ pub enum InsightArenaError {
/// The user has already successfully claimed their payout for this market.
/// Raised to prevent double-claiming after `payout_claimed` is set to true.
PayoutAlreadyClaimed = 24,
/// The market has already been closed and is no longer accepting changes.
/// Raised when a mutation (fee update, end_time extension) is attempted after close.
MarketAlreadyClosed = 25,

// ── Escrow ────────────────────────────────────────────────────────────────
/// The contract's escrow balance is insufficient to complete the transfer.
Expand Down
Loading
Loading