Skip to content

Commit 4eec950

Browse files
authored
Merge pull request #449 from Tukura11/staging
docs: add OpenAPI examples to balance indexer endpoints (#383)
2 parents 3f3b0fc + ebe9ee1 commit 4eec950

10 files changed

Lines changed: 1280 additions & 41 deletions

src/balance-indexer/balance-indexer.controller.spec.ts

Lines changed: 412 additions & 0 deletions
Large diffs are not rendered by default.

src/balance-indexer/balance-indexer.controller.ts

Lines changed: 313 additions & 41 deletions
Large diffs are not rendered by default.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { IsEnum, IsOptional, IsString } from 'class-validator';
2+
import { ApiProperty } from '@nestjs/swagger';
3+
import { AssetType } from '../domain/balance.model';
4+
5+
export class BalanceFilterDto {
6+
@ApiProperty({
7+
example: 'NATIVE',
8+
enum: AssetType,
9+
description: 'Filter by asset type',
10+
required: false,
11+
})
12+
@IsEnum(AssetType, { message: 'assetType must be one of: NATIVE, CREDIT_ALPHANUM4, CREDIT_ALPHANUM12, LIQUIDITY_POOL_SHARES' })
13+
@IsOptional()
14+
assetType?: AssetType;
15+
16+
@ApiProperty({
17+
example: 'USD',
18+
description: 'Filter by asset code',
19+
required: false,
20+
})
21+
@IsString({ message: 'assetCode must be a string' })
22+
@IsOptional()
23+
assetCode?: string;
24+
}
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import { plainToInstance } from 'class-transformer';
2+
import { validate } from 'class-validator';
3+
import { PaginationDto } from '../../common/dto/pagination.dto';
4+
import { BalanceFilterDto } from './balance-filter.dto';
5+
import { GetBalanceQueryDto } from './get-balance.query';
6+
import { SyncBalancesDto } from './sync-balances.dto';
7+
import { ReconcileBalanceDto } from './reconcile-balance.dto';
8+
import { AssetType } from '../domain/balance.model';
9+
10+
describe('Balance Indexer DTOs - Validation', () => {
11+
// ─── PaginationDto ────────────────────────────────────────────────────────
12+
13+
describe('PaginationDto', () => {
14+
it('accepts valid page and limit', async () => {
15+
const dto = plainToInstance(PaginationDto, { page: 1, limit: 20 });
16+
const errors = await validate(dto);
17+
expect(errors).toHaveLength(0);
18+
});
19+
20+
it('defaults to page=1 and limit=20', async () => {
21+
const dto = plainToInstance(PaginationDto, {});
22+
expect(dto.page).toBe(1);
23+
expect(dto.limit).toBe(20);
24+
});
25+
26+
it('rejects page < 1', async () => {
27+
const dto = plainToInstance(PaginationDto, { page: 0, limit: 20 });
28+
const errors = await validate(dto);
29+
expect(errors.length).toBeGreaterThan(0);
30+
expect(errors[0].constraints).toHaveProperty('min');
31+
});
32+
33+
it('rejects non-integer page', async () => {
34+
const dto = plainToInstance(PaginationDto, { page: '1.5', limit: 20 });
35+
const errors = await validate(dto);
36+
expect(errors.length).toBeGreaterThan(0);
37+
});
38+
39+
it('rejects limit < 1', async () => {
40+
const dto = plainToInstance(PaginationDto, { page: 1, limit: 0 });
41+
const errors = await validate(dto);
42+
expect(errors.length).toBeGreaterThan(0);
43+
expect(errors[0].constraints).toHaveProperty('min');
44+
});
45+
46+
it('rejects limit > 100', async () => {
47+
const dto = plainToInstance(PaginationDto, { page: 1, limit: 101 });
48+
const errors = await validate(dto);
49+
expect(errors.length).toBeGreaterThan(0);
50+
expect(errors[0].constraints).toHaveProperty('max');
51+
});
52+
53+
it('coerces string numbers to integers', async () => {
54+
const dto = plainToInstance(PaginationDto, { page: '2', limit: '50' });
55+
expect(dto.page).toBe(2);
56+
expect(dto.limit).toBe(50);
57+
});
58+
});
59+
60+
// ─── BalanceFilterDto ─────────────────────────────────────────────────────
61+
62+
describe('BalanceFilterDto', () => {
63+
it('accepts valid assetType', async () => {
64+
const dto = plainToInstance(BalanceFilterDto, {
65+
assetType: AssetType.NATIVE,
66+
});
67+
const errors = await validate(dto);
68+
expect(errors).toHaveLength(0);
69+
});
70+
71+
it('accepts assetCode', async () => {
72+
const dto = plainToInstance(BalanceFilterDto, { assetCode: 'USD' });
73+
const errors = await validate(dto);
74+
expect(errors).toHaveLength(0);
75+
});
76+
77+
it('accepts both assetType and assetCode', async () => {
78+
const dto = plainToInstance(BalanceFilterDto, {
79+
assetType: AssetType.CREDIT_ALPHANUM4,
80+
assetCode: 'EUR',
81+
});
82+
const errors = await validate(dto);
83+
expect(errors).toHaveLength(0);
84+
});
85+
86+
it('rejects invalid assetType', async () => {
87+
const dto = plainToInstance(BalanceFilterDto, { assetType: 'INVALID' });
88+
const errors = await validate(dto);
89+
expect(errors.length).toBeGreaterThan(0);
90+
expect(errors[0].constraints).toHaveProperty('isEnum');
91+
});
92+
93+
it('rejects non-string assetCode', async () => {
94+
const dto = plainToInstance(BalanceFilterDto, { assetCode: 123 });
95+
const errors = await validate(dto);
96+
expect(errors.length).toBeGreaterThan(0);
97+
expect(errors[0].constraints).toHaveProperty('isString');
98+
});
99+
100+
it('allows empty object (all optional)', async () => {
101+
const dto = plainToInstance(BalanceFilterDto, {});
102+
const errors = await validate(dto);
103+
expect(errors).toHaveLength(0);
104+
});
105+
});
106+
107+
// ─── GetBalanceQueryDto ────────────────────────────────────────────────────
108+
109+
describe('GetBalanceQueryDto', () => {
110+
it('accepts assetType only', async () => {
111+
const dto = plainToInstance(GetBalanceQueryDto, {
112+
assetType: AssetType.NATIVE,
113+
});
114+
const errors = await validate(dto);
115+
expect(errors).toHaveLength(0);
116+
});
117+
118+
it('accepts assetType with code and issuer', async () => {
119+
const dto = plainToInstance(GetBalanceQueryDto, {
120+
assetType: AssetType.CREDIT_ALPHANUM4,
121+
assetCode: 'USD',
122+
assetIssuer: 'GBUQWP3BOUZX34ZONKXRBTLNNDOWR5HLCVPL2B4XNCLJTLMUMLTSOGBM',
123+
});
124+
const errors = await validate(dto);
125+
expect(errors).toHaveLength(0);
126+
});
127+
128+
it('rejects invalid assetType', async () => {
129+
const dto = plainToInstance(GetBalanceQueryDto, {
130+
assetType: 'BADTYPE',
131+
});
132+
const errors = await validate(dto);
133+
expect(errors.length).toBeGreaterThan(0);
134+
});
135+
136+
it('allows empty object', async () => {
137+
const dto = plainToInstance(GetBalanceQueryDto, {});
138+
const errors = await validate(dto);
139+
expect(errors).toHaveLength(0);
140+
});
141+
142+
it('rejects non-string assetCode', async () => {
143+
const dto = plainToInstance(GetBalanceQueryDto, { assetCode: 100 });
144+
const errors = await validate(dto);
145+
expect(errors.length).toBeGreaterThan(0);
146+
});
147+
148+
it('rejects non-string assetIssuer', async () => {
149+
const dto = plainToInstance(GetBalanceQueryDto, {
150+
assetType: AssetType.CREDIT_ALPHANUM4,
151+
assetCode: 'USD',
152+
assetIssuer: 123,
153+
});
154+
const errors = await validate(dto);
155+
expect(errors.length).toBeGreaterThan(0);
156+
});
157+
});
158+
159+
// ─── SyncBalancesDto ───────────────────────────────────────────────────────
160+
161+
describe('SyncBalancesDto', () => {
162+
it('accepts forceRefresh=true', async () => {
163+
const dto = plainToInstance(SyncBalancesDto, { forceRefresh: true });
164+
const errors = await validate(dto);
165+
expect(errors).toHaveLength(0);
166+
});
167+
168+
it('accepts forceRefresh=false', async () => {
169+
const dto = plainToInstance(SyncBalancesDto, { forceRefresh: false });
170+
const errors = await validate(dto);
171+
expect(errors).toHaveLength(0);
172+
});
173+
174+
it('defaults to empty object', async () => {
175+
const dto = plainToInstance(SyncBalancesDto, {});
176+
const errors = await validate(dto);
177+
expect(errors).toHaveLength(0);
178+
});
179+
180+
it('rejects non-boolean forceRefresh', async () => {
181+
const dto = plainToInstance(SyncBalancesDto, { forceRefresh: 'yes' });
182+
const errors = await validate(dto);
183+
expect(errors.length).toBeGreaterThan(0);
184+
expect(errors[0].constraints).toHaveProperty('isBoolean');
185+
});
186+
187+
it('coerces boolean strings (type coercion)', async () => {
188+
const dto = plainToInstance(SyncBalancesDto, { forceRefresh: 'true' });
189+
// Note: class-validator does NOT coerce strings to booleans without explicit transform
190+
// This tests that validation fails as expected
191+
const errors = await validate(dto);
192+
expect(errors.length).toBeGreaterThan(0);
193+
});
194+
});
195+
196+
// ─── ReconcileBalanceDto ──────────────────────────────────────────────────
197+
198+
describe('ReconcileBalanceDto', () => {
199+
it('requires assetType', async () => {
200+
const dto = plainToInstance(ReconcileBalanceDto, {});
201+
const errors = await validate(dto);
202+
expect(errors.length).toBeGreaterThan(0);
203+
expect(errors[0].constraints).toHaveProperty('isNotEmpty');
204+
});
205+
206+
it('accepts NATIVE asset', async () => {
207+
const dto = plainToInstance(ReconcileBalanceDto, {
208+
assetType: AssetType.NATIVE,
209+
});
210+
const errors = await validate(dto);
211+
expect(errors).toHaveLength(0);
212+
});
213+
214+
it('accepts CREDIT_ALPHANUM4 with code and issuer', async () => {
215+
const dto = plainToInstance(ReconcileBalanceDto, {
216+
assetType: AssetType.CREDIT_ALPHANUM4,
217+
assetCode: 'USD',
218+
assetIssuer: 'GBUQWP3BOUZX34ZONKXRBTLNNDOWR5HLCVPL2B4XNCLJTLMUMLTSOGBM',
219+
});
220+
const errors = await validate(dto);
221+
expect(errors).toHaveLength(0);
222+
});
223+
224+
it('accepts CREDIT_ALPHANUM12', async () => {
225+
const dto = plainToInstance(ReconcileBalanceDto, {
226+
assetType: AssetType.CREDIT_ALPHANUM12,
227+
assetCode: 'LONGCURRENCYNAME',
228+
assetIssuer: 'GBUQWP3BOUZX34ZONKXRBTLNNDOWR5HLCVPL2B4XNCLJTLMUMLTSOGBM',
229+
});
230+
const errors = await validate(dto);
231+
expect(errors).toHaveLength(0);
232+
});
233+
234+
it('accepts LIQUIDITY_POOL_SHARES', async () => {
235+
const dto = plainToInstance(ReconcileBalanceDto, {
236+
assetType: AssetType.LIQUIDITY_POOL_SHARES,
237+
});
238+
const errors = await validate(dto);
239+
expect(errors).toHaveLength(0);
240+
});
241+
242+
it('rejects invalid assetType', async () => {
243+
const dto = plainToInstance(ReconcileBalanceDto, { assetType: 'INVALID' });
244+
const errors = await validate(dto);
245+
expect(errors.length).toBeGreaterThan(0);
246+
expect(errors[0].constraints).toHaveProperty('isEnum');
247+
});
248+
249+
it('allows optional assetCode and assetIssuer', async () => {
250+
const dto = plainToInstance(ReconcileBalanceDto, {
251+
assetType: AssetType.NATIVE,
252+
assetCode: undefined,
253+
assetIssuer: undefined,
254+
});
255+
const errors = await validate(dto);
256+
expect(errors).toHaveLength(0);
257+
});
258+
});
259+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { IsEnum, IsOptional, IsString } from 'class-validator';
2+
import { ApiProperty } from '@nestjs/swagger';
3+
import { AssetType } from '../domain/balance.model';
4+
5+
export class GetBalanceQueryDto {
6+
@ApiProperty({
7+
example: 'NATIVE',
8+
enum: AssetType,
9+
description: 'Asset type (NATIVE, CREDIT_ALPHANUM4, CREDIT_ALPHANUM12, LIQUIDITY_POOL_SHARES)',
10+
required: false,
11+
})
12+
@IsEnum(AssetType, { message: 'assetType must be one of: NATIVE, CREDIT_ALPHANUM4, CREDIT_ALPHANUM12, LIQUIDITY_POOL_SHARES' })
13+
@IsOptional()
14+
assetType?: AssetType;
15+
16+
@ApiProperty({
17+
example: 'USD',
18+
description: 'Asset code (required if assetType is CREDIT_ALPHANUM4 or CREDIT_ALPHANUM12)',
19+
required: false,
20+
})
21+
@IsString({ message: 'assetCode must be a string' })
22+
@IsOptional()
23+
assetCode?: string;
24+
25+
@ApiProperty({
26+
example: 'GBUQWP3BOUZX34ZONKXRBTLNNDOWR5HLCVPL2B4XNCLJTLMUMLTSOGBM',
27+
description: 'Asset issuer account ID (required if assetType is CREDIT_ALPHANUM4 or CREDIT_ALPHANUM12)',
28+
required: false,
29+
})
30+
@IsString({ message: 'assetIssuer must be a string' })
31+
@IsOptional()
32+
assetIssuer?: string;
33+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { IsEnum, IsOptional, IsString, IsNotEmpty } from 'class-validator';
2+
import { ApiProperty } from '@nestjs/swagger';
3+
import { AssetType } from '../domain/balance.model';
4+
5+
export class ReconcileBalanceDto {
6+
@ApiProperty({
7+
example: 'NATIVE',
8+
enum: AssetType,
9+
description: 'Asset type (NATIVE, CREDIT_ALPHANUM4, CREDIT_ALPHANUM12, LIQUIDITY_POOL_SHARES)',
10+
})
11+
@IsEnum(AssetType, { message: 'assetType must be one of: NATIVE, CREDIT_ALPHANUM4, CREDIT_ALPHANUM12, LIQUIDITY_POOL_SHARES' })
12+
@IsNotEmpty({ message: 'assetType is required' })
13+
assetType: AssetType;
14+
15+
@ApiProperty({
16+
example: 'USD',
17+
description: 'Asset code (required if assetType is CREDIT_ALPHANUM4 or CREDIT_ALPHANUM12)',
18+
required: false,
19+
})
20+
@IsString({ message: 'assetCode must be a string' })
21+
@IsOptional()
22+
assetCode?: string;
23+
24+
@ApiProperty({
25+
example: 'GBUQWP3BOUZX34ZONKXRBTLNNDOWR5HLCVPL2B4XNCLJTLMUMLTSOGBM',
26+
description: 'Asset issuer account ID (required if assetType is CREDIT_ALPHANUM4 or CREDIT_ALPHANUM12)',
27+
required: false,
28+
})
29+
@IsString({ message: 'assetIssuer must be a string' })
30+
@IsOptional()
31+
assetIssuer?: string;
32+
}

0 commit comments

Comments
 (0)