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
21 changes: 21 additions & 0 deletions backend/src/auth/auth.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { Keypair } from '@stellar/stellar-sdk';
import request, { SuperTest, Test as SuperTestRequest } from 'supertest';
import { User } from '../users/entities/user.entity';
import { UserPreferences } from '../users/entities/user-preferences.entity';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
Expand Down Expand Up @@ -43,13 +44,25 @@ describe('Auth E2E — challenge → verify flow', () => {
save: jest.Mock;
};

let mockUserPreferencesRepository: {
findOneBy: jest.Mock;
create: jest.Mock;
save: jest.Mock;
};

beforeAll(async () => {
mockUsersRepository = {
findOneBy: jest.fn(),
create: jest.fn(),
save: jest.fn(),
};

mockUserPreferencesRepository = {
findOneBy: jest.fn(),
create: jest.fn(),
save: jest.fn(),
};

mockJwtService.signAsync.mockResolvedValue('mock-jwt-token');

const module: TestingModule = await Test.createTestingModule({
Expand Down Expand Up @@ -87,6 +100,10 @@ describe('Auth E2E — challenge → verify flow', () => {
provide: getRepositoryToken(User),
useValue: mockUsersRepository,
},
{
provide: getRepositoryToken(UserPreferences),
useValue: mockUserPreferencesRepository,
},
],
})
.overrideGuard(JwtAuthGuard)
Expand Down Expand Up @@ -126,6 +143,10 @@ describe('Auth E2E — challenge → verify flow', () => {
Promise.resolve({ ...user, id: user.id || 'e2e-uuid' }),
);

mockUserPreferencesRepository.findOneBy.mockResolvedValue(null);
mockUserPreferencesRepository.create.mockImplementation((dto: any) => dto);
mockUserPreferencesRepository.save.mockImplementation((dto: any) => Promise.resolve(dto));

mockJwtService.signAsync.mockResolvedValue('mock-jwt-token');
});

Expand Down
3 changes: 2 additions & 1 deletion backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '../users/entities/user.entity';
import { UserPreferences } from '../users/entities/user-preferences.entity';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { RateLimitService } from './rate-limit.service';
Expand All @@ -16,7 +17,7 @@ import { ApiKeyController } from './api-key.controller';
imports: [
PassportModule,
ConfigModule,
TypeOrmModule.forFeature([User, ApiKey]),
TypeOrmModule.forFeature([User, ApiKey, UserPreferences]),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
Expand Down
38 changes: 37 additions & 1 deletion backend/src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,22 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { Keypair } from '@stellar/stellar-sdk';
import { Repository } from 'typeorm';
import { User } from '../users/entities/user.entity';
import { UserPreferences } from '../users/entities/user-preferences.entity';
import { AuthService } from './auth.service';

type UsersRepoMock = jest.Mocked<
Pick<Repository<User>, 'findOneBy' | 'create' | 'save'>
>;

type PreferencesRepoMock = jest.Mocked<
Pick<Repository<UserPreferences>, 'findOneBy' | 'create' | 'save'>
>;

describe('AuthService', () => {
let service: AuthService;
let jwtService: jest.Mocked<JwtService>;
let usersRepository: UsersRepoMock;
let preferencesRepository: PreferencesRepoMock;

const address = 'GABC1234567890';

Expand All @@ -36,12 +42,21 @@ describe('AuthService', () => {
save: jest.fn(),
},
},
{
provide: getRepositoryToken(UserPreferences),
useValue: {
findOneBy: jest.fn(),
create: jest.fn(),
save: jest.fn(),
},
},
],
}).compile();

service = module.get<AuthService>(AuthService);
jwtService = module.get(JwtService);
usersRepository = module.get(getRepositoryToken(User));
preferencesRepository = module.get(getRepositoryToken(UserPreferences));
});

afterEach(() => {
Expand All @@ -58,7 +73,7 @@ describe('AuthService', () => {
expect(two).toContain(address);
});

it('verifySignature() returns user on valid sig', async () => {
it('verifySignature() returns user on valid sig and creates preferences if new user', async () => {
service.generateChallenge(address);
jest.spyOn(service, 'verifyStellarSignature').mockReturnValue(true);

Expand All @@ -67,10 +82,31 @@ describe('AuthService', () => {
usersRepository.create.mockReturnValue(savedUser);
usersRepository.save.mockResolvedValue(savedUser);

preferencesRepository.findOneBy.mockResolvedValue(null);
preferencesRepository.create.mockReturnValue({ id: 'p-1', userId: 'u-1' } as UserPreferences);
preferencesRepository.save.mockResolvedValue({ id: 'p-1', userId: 'u-1' } as UserPreferences);

const user = await service.verifySignature(address, 'signed-hex');

expect(user).toEqual(savedUser);
expect(usersRepository.save).toHaveBeenCalledWith(savedUser);
expect(preferencesRepository.findOneBy).toHaveBeenCalledWith({ userId: 'u-1' });
expect(preferencesRepository.create).toHaveBeenCalledWith({ userId: 'u-1' });
expect(preferencesRepository.save).toHaveBeenCalled();
});

it('verifySignature() does not create duplicate preferences for existing users', async () => {
service.generateChallenge(address);
jest.spyOn(service, 'verifyStellarSignature').mockReturnValue(true);

const existingUser = { id: 'u-1', stellar_address: address } as User;
usersRepository.findOneBy.mockResolvedValue(existingUser);
usersRepository.save.mockResolvedValue(existingUser);

const user = await service.verifySignature(address, 'signed-hex');

expect(user).toEqual(existingUser);
expect(preferencesRepository.save).not.toHaveBeenCalled();
});

it('verifySignature() throws UnauthorizedException on invalid sig', async () => {
Expand Down
13 changes: 13 additions & 0 deletions backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { randomBytes } from 'crypto';
import { Keypair } from '@stellar/stellar-sdk';
import { Repository } from 'typeorm';
import { User } from '../users/entities/user.entity';
import { UserPreferences } from '../users/entities/user-preferences.entity';

@Injectable()
export class AuthService {
Expand All @@ -19,6 +20,8 @@ export class AuthService {
private readonly jwtService: JwtService,
@InjectRepository(User)
private readonly usersRepository: Repository<User>,
@InjectRepository(UserPreferences)
private readonly preferencesRepository: Repository<UserPreferences>,
) {}

generateChallenge(stellar_address: string): string {
Expand Down Expand Up @@ -112,11 +115,21 @@ export class AuthService {

// Upsert the user record
let user = await this.usersRepository.findOneBy({ stellar_address });
const isNewUser = !user;
if (!user) {
this.logger.debug(`Creating new user for ${stellar_address}`);
user = this.usersRepository.create({ stellar_address });
}
user = await this.usersRepository.save(user);

if (isNewUser) {
const existingPrefs = await this.preferencesRepository.findOneBy({ userId: user.id });
if (!existingPrefs) {
const prefs = this.preferencesRepository.create({ userId: user.id });
await this.preferencesRepository.save(prefs);
}
}

return user;
}

Expand Down
25 changes: 25 additions & 0 deletions backend/src/predictions/dto/list-market-predictions.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { IsOptional, IsInt, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';

export class ListMarketPredictionsDto {
@ApiPropertyOptional({ description: 'Page number', minimum: 1, default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;

@ApiPropertyOptional({
description: 'Items per page',
minimum: 1,
maximum: 50,
default: 20,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(50)
limit?: number = 20;
}
17 changes: 17 additions & 0 deletions backend/src/predictions/predictions.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ import {
PaginatedMyPredictionsResponse,
PredictionWithStatus,
} from './dto/list-my-predictions.dto';
import { ListMarketPredictionsDto } from './dto/list-market-predictions.dto';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { Public } from '../common/decorators/public.decorator';
import { User } from '../users/entities/user.entity';
import { Prediction } from './entities/prediction.entity';

Expand Down Expand Up @@ -133,4 +135,19 @@ export class PredictionsController {
): Promise<Prediction> {
return this.predictionsService.claim(id, user);
}

@Public()
@Get('market/:marketId')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Get paginated, anonymized predictions for a market (public)' })
@ApiResponse({
status: 200,
description: 'Paginated, anonymized predictions list',
})
async getMarketPredictions(
@Param('marketId', ParseUUIDPipe) marketId: string,
@Query() query: ListMarketPredictionsDto,
): Promise<{ data: any[]; total: number; page: number; limit: number }> {
return this.predictionsService.findByMarket(marketId, query);
}
}
50 changes: 50 additions & 0 deletions backend/src/predictions/predictions.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ describe('PredictionsService', () => {
makeUser(),
),
).rejects.toThrow(NotFoundException);
expect(mockSoroban.submitPrediction).not.toHaveBeenCalled();
});

it('throws BadRequestException when market is resolved', async () => {
Expand All @@ -172,6 +173,7 @@ describe('PredictionsService', () => {
makeUser(),
),
).rejects.toThrow(BadRequestException);
expect(mockSoroban.submitPrediction).not.toHaveBeenCalled();
});

it('throws BadRequestException when market is cancelled', async () => {
Expand All @@ -189,6 +191,7 @@ describe('PredictionsService', () => {
makeUser(),
),
).rejects.toThrow(BadRequestException);
expect(mockSoroban.submitPrediction).not.toHaveBeenCalled();
});

it('throws BadRequestException when end_time has passed', async () => {
Expand All @@ -206,6 +209,7 @@ describe('PredictionsService', () => {
makeUser(),
),
).rejects.toThrow(BadRequestException);
expect(mockSoroban.submitPrediction).not.toHaveBeenCalled();
});

it('throws BadRequestException for invalid outcome', async () => {
Expand All @@ -221,6 +225,7 @@ describe('PredictionsService', () => {
makeUser(),
),
).rejects.toThrow(BadRequestException);
expect(mockSoroban.submitPrediction).not.toHaveBeenCalled();
});

it('throws ConflictException for duplicate prediction', async () => {
Expand All @@ -239,6 +244,7 @@ describe('PredictionsService', () => {
makeUser(),
),
).rejects.toThrow(ConflictException);
expect(mockSoroban.submitPrediction).not.toHaveBeenCalled();
});
});

Expand Down Expand Up @@ -488,4 +494,48 @@ describe('PredictionsService', () => {
expect(result.status).toBe('lost');
});
});

describe('findByMarket', () => {
it('returns anonymized and paginated predictions for an existing market', async () => {
const market = makeMarket();
mockMarketsRepo.findOne.mockResolvedValue(market);

const mockPredictions = [
{
id: 'pred-1',
chosen_outcome: 'Yes',
stake_amount_stroops: '1000',
payout_claimed: false,
payout_amount_stroops: '0',
tx_hash: 'tx-1',
submitted_at: new Date(),
},
];
mockPredictionsRepo.findAndCount = jest.fn().mockResolvedValue([mockPredictions, 1]);

const result = await service.findByMarket(market.id, { page: 1, limit: 10 });

expect(result.total).toBe(1);
expect(result.data[0]).toEqual({
id: 'pred-1',
chosen_outcome: 'Yes',
stake_amount_stroops: '1000',
payout_claimed: false,
payout_amount_stroops: '0',
tx_hash: 'tx-1',
submitted_at: mockPredictions[0].submitted_at,
});
expect(result.data[0]).not.toHaveProperty('user');
expect(result.data[0]).not.toHaveProperty('userId');
});

it('returns empty list for non-existent market', async () => {
mockMarketsRepo.findOne.mockResolvedValue(null);

const result = await service.findByMarket('non-existent', { page: 1, limit: 10 });

expect(result.total).toBe(0);
expect(result.data).toEqual([]);
});
});
});
Loading
Loading