Skip to content

Commit 58c4df4

Browse files
authored
Merge pull request #1323 from martinzhames/feat/subscriptions-tests-and-pagination
Feat/subscriptions tests and pagination
2 parents ce06ac6 + ef00c0d commit 58c4df4

6 files changed

Lines changed: 753 additions & 1 deletion

File tree

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
import { validate } from 'class-validator';
2+
import { plainToInstance } from 'class-transformer';
3+
import { ListSubscriptionsQueryDto } from './list-subscriptions-query.dto';
4+
import { ListCreatorSubscribersQueryDto } from './list-creator-subscribers-query.dto';
5+
import { SubscriptionStateQueryDto } from './subscription-state-query.dto';
6+
import { SubscriptionIndexerEventDto } from './subscription-indexer-event.dto';
7+
import { SetSpendingCapDto } from './spending-cap.dto';
8+
import { FanDashboardQueryDto } from './fan-dashboard-query.dto';
9+
10+
describe('ListSubscriptionsQueryDto – invalid input', () => {
11+
async function validateDto(plain: object) {
12+
const dto = plainToInstance(ListSubscriptionsQueryDto, plain);
13+
return validate(dto);
14+
}
15+
16+
it('fails when fan is empty string', async () => {
17+
const errors = await validateDto({ fan: '' });
18+
expect(errors.some((e) => e.property === 'fan')).toBe(true);
19+
});
20+
21+
it('fails when fan is missing', async () => {
22+
const errors = await validateDto({});
23+
expect(errors.some((e) => e.property === 'fan')).toBe(true);
24+
});
25+
26+
it('fails when status is an invalid string', async () => {
27+
const errors = await validateDto({ fan: 'GFAN', status: 'bogus' });
28+
const statusErrors = errors.filter((e) => e.property === 'status');
29+
expect(statusErrors).toHaveLength(1);
30+
});
31+
32+
it('fails when sort is an invalid string', async () => {
33+
const errors = await validateDto({ fan: 'GFAN', sort: 'alphabetical' });
34+
const sortErrors = errors.filter((e) => e.property === 'sort');
35+
expect(sortErrors).toHaveLength(1);
36+
});
37+
38+
it('fails when limit is zero', async () => {
39+
const errors = await validateDto({ fan: 'GFAN', limit: 0 });
40+
expect(errors.some((e) => e.property === 'limit')).toBe(true);
41+
});
42+
43+
it('fails when limit is negative', async () => {
44+
const errors = await validateDto({ fan: 'GFAN', limit: -5 });
45+
expect(errors.some((e) => e.property === 'limit')).toBe(true);
46+
});
47+
48+
it('fails when limit exceeds maximum', async () => {
49+
const errors = await validateDto({ fan: 'GFAN', limit: 101 });
50+
expect(errors.some((e) => e.property === 'limit')).toBe(true);
51+
});
52+
53+
it('fails when page is zero', async () => {
54+
const errors = await validateDto({ fan: 'GFAN', page: 0 });
55+
expect(errors.some((e) => e.property === 'page')).toBe(true);
56+
});
57+
58+
it('fails when page is negative', async () => {
59+
const errors = await validateDto({ fan: 'GFAN', page: -1 });
60+
expect(errors.some((e) => e.property === 'page')).toBe(true);
61+
});
62+
});
63+
64+
describe('ListCreatorSubscribersQueryDto – invalid input', () => {
65+
async function validateDto(plain: object) {
66+
const dto = plainToInstance(ListCreatorSubscribersQueryDto, plain);
67+
return validate(dto);
68+
}
69+
70+
it('fails when creator is missing', async () => {
71+
const errors = await validateDto({});
72+
expect(errors.some((e) => e.property === 'creator')).toBe(true);
73+
});
74+
75+
it('fails when creator is empty string', async () => {
76+
const errors = await validateDto({ creator: '' });
77+
expect(errors.some((e) => e.property === 'creator')).toBe(true);
78+
});
79+
80+
it('fails when status is not active or expired', async () => {
81+
const errors = await validateDto({ creator: 'GCREATOR', status: 'pending' });
82+
expect(errors.some((e) => e.property === 'status')).toBe(true);
83+
});
84+
85+
it('fails when sort is invalid', async () => {
86+
const errors = await validateDto({ creator: 'GCREATOR', sort: 'price' });
87+
expect(errors.some((e) => e.property === 'sort')).toBe(true);
88+
});
89+
90+
it('fails when limit exceeds maximum', async () => {
91+
const errors = await validateDto({ creator: 'GCREATOR', limit: 200 });
92+
expect(errors.some((e) => e.property === 'limit')).toBe(true);
93+
});
94+
95+
it('fails when limit is zero', async () => {
96+
const errors = await validateDto({ creator: 'GCREATOR', limit: 0 });
97+
expect(errors.some((e) => e.property === 'limit')).toBe(true);
98+
});
99+
});
100+
101+
describe('SubscriptionStateQueryDto – invalid input', () => {
102+
async function validateDto(plain: object) {
103+
const dto = plainToInstance(SubscriptionStateQueryDto, plain);
104+
return validate(dto);
105+
}
106+
107+
it('fails when creator is missing', async () => {
108+
const errors = await validateDto({});
109+
expect(errors.some((e) => e.property === 'creator')).toBe(true);
110+
});
111+
112+
it('fails when creator is empty string', async () => {
113+
const errors = await validateDto({ creator: '' });
114+
expect(errors.some((e) => e.property === 'creator')).toBe(true);
115+
});
116+
117+
it('fails when creator does not match G-address format', async () => {
118+
const errors = await validateDto({ creator: 'not-a-stellar-address' });
119+
const creatorErrors = errors.filter((e) => e.property === 'creator');
120+
expect(creatorErrors.length).toBeGreaterThan(0);
121+
});
122+
123+
it('fails when creator is lowercase g-address', async () => {
124+
const errors = await validateDto({ creator: 'g' + 'A'.repeat(55) });
125+
const creatorErrors = errors.filter((e) => e.property === 'creator');
126+
expect(creatorErrors.length).toBeGreaterThan(0);
127+
});
128+
129+
it('fails when creator is too short', async () => {
130+
const errors = await validateDto({ creator: 'GABCDEF' });
131+
const creatorErrors = errors.filter((e) => e.property === 'creator');
132+
expect(creatorErrors.length).toBeGreaterThan(0);
133+
});
134+
});
135+
136+
describe('SubscriptionIndexerEventDto – invalid input', () => {
137+
async function validateDto(plain: object) {
138+
const dto = plainToInstance(SubscriptionIndexerEventDto, plain);
139+
return validate(dto);
140+
}
141+
142+
it('fails when event is missing', async () => {
143+
const errors = await validateDto({ userId: 'GFAN', creatorId: 'GCREATOR', planId: 1 });
144+
expect(errors.some((e) => e.property === 'event')).toBe(true);
145+
});
146+
147+
it('fails when event is invalid value', async () => {
148+
const errors = await validateDto({ event: 'created', userId: 'GFAN', creatorId: 'GCREATOR', planId: 1 });
149+
expect(errors.some((e) => e.property === 'event')).toBe(true);
150+
});
151+
152+
it('fails when userId is missing', async () => {
153+
const errors = await validateDto({ event: 'renewed', creatorId: 'GCREATOR', planId: 1 });
154+
expect(errors.some((e) => e.property === 'userId')).toBe(true);
155+
});
156+
157+
it('fails when creatorId is missing', async () => {
158+
const errors = await validateDto({ event: 'renewed', userId: 'GFAN', planId: 1 });
159+
expect(errors.some((e) => e.property === 'creatorId')).toBe(true);
160+
});
161+
162+
it('fails when planId is missing', async () => {
163+
const errors = await validateDto({ event: 'renewed', userId: 'GFAN', creatorId: 'GCREATOR' });
164+
expect(errors.some((e) => e.property === 'planId')).toBe(true);
165+
});
166+
167+
it('fails when planId is zero', async () => {
168+
const errors = await validateDto({ event: 'renewed', userId: 'GFAN', creatorId: 'GCREATOR', planId: 0 });
169+
expect(errors.some((e) => e.property === 'planId')).toBe(true);
170+
});
171+
172+
it('fails when planId is negative', async () => {
173+
const errors = await validateDto({ event: 'renewed', userId: 'GFAN', creatorId: 'GCREATOR', planId: -1 });
174+
expect(errors.some((e) => e.property === 'planId')).toBe(true);
175+
});
176+
177+
it('fails when expiry is negative', async () => {
178+
const errors = await validateDto({ event: 'renewed', userId: 'GFAN', creatorId: 'GCREATOR', planId: 1, expiry: -100 });
179+
expect(errors.some((e) => e.property === 'expiry')).toBe(true);
180+
});
181+
182+
it('fails when cancelledAt is negative', async () => {
183+
const errors = await validateDto({ event: 'cancelled', userId: 'GFAN', creatorId: 'GCREATOR', planId: 1, cancelledAt: -1 });
184+
expect(errors.some((e) => e.property === 'cancelledAt')).toBe(true);
185+
});
186+
187+
it('passes for valid renewed event', async () => {
188+
const errors = await validateDto({ event: 'renewed', userId: 'GFAN', creatorId: 'GCREATOR', planId: 1, expiry: 1700000000 });
189+
expect(errors).toHaveLength(0);
190+
});
191+
192+
it('passes for valid cancelled event', async () => {
193+
const errors = await validateDto({ event: 'cancelled', userId: 'GFAN', creatorId: 'GCREATOR', planId: 1, cancelledAt: 1700000000 });
194+
expect(errors).toHaveLength(0);
195+
});
196+
});
197+
198+
describe('SetSpendingCapDto – invalid input', () => {
199+
async function validateDto(plain: object) {
200+
const dto = plainToInstance(SetSpendingCapDto, plain);
201+
return validate(dto);
202+
}
203+
204+
it('fails when period is invalid', async () => {
205+
const errors = await validateDto({ period: 'weekly' });
206+
expect(errors.some((e) => e.property === 'period')).toBe(true);
207+
});
208+
209+
it('fails when capAmount is negative', async () => {
210+
const errors = await validateDto({ capAmount: -100, period: 'monthly' });
211+
expect(errors.some((e) => e.property === 'capAmount')).toBe(true);
212+
});
213+
214+
it('fails when capAmount is a float', async () => {
215+
const errors = await validateDto({ capAmount: 10.5, period: 'monthly' });
216+
expect(errors.some((e) => e.property === 'capAmount')).toBe(true);
217+
});
218+
219+
it('passes when capAmount is zero', async () => {
220+
const errors = await validateDto({ capAmount: 0, period: 'monthly' });
221+
expect(errors.filter((e) => e.property === 'capAmount')).toHaveLength(0);
222+
});
223+
224+
it('passes when capAmount is omitted', async () => {
225+
const errors = await validateDto({ period: 'monthly' });
226+
expect(errors.filter((e) => e.property === 'capAmount')).toHaveLength(0);
227+
});
228+
});
229+
230+
describe('FanDashboardQueryDto – invalid input', () => {
231+
async function validateDto(plain: object) {
232+
const dto = plainToInstance(FanDashboardQueryDto, plain);
233+
return validate(dto);
234+
}
235+
236+
it('fails when page is zero', async () => {
237+
const errors = await validateDto({ page: 0 });
238+
expect(errors.some((e) => e.property === 'page')).toBe(true);
239+
});
240+
241+
it('fails when page is negative', async () => {
242+
const errors = await validateDto({ page: -1 });
243+
expect(errors.some((e) => e.property === 'page')).toBe(true);
244+
});
245+
246+
it('fails when limit is zero', async () => {
247+
const errors = await validateDto({ limit: 0 });
248+
expect(errors.some((e) => e.property === 'limit')).toBe(true);
249+
});
250+
251+
it('fails when limit exceeds maximum', async () => {
252+
const errors = await validateDto({ limit: 101 });
253+
expect(errors.some((e) => e.property === 'limit')).toBe(true);
254+
});
255+
256+
it('passes with valid page and limit', async () => {
257+
const errors = await validateDto({ page: 1, limit: 50 });
258+
expect(errors).toHaveLength(0);
259+
});
260+
261+
it('passes when both are omitted', async () => {
262+
const errors = await validateDto({});
263+
expect(errors).toHaveLength(0);
264+
});
265+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { ApiPropertyOptional } from '@nestjs/swagger';
2+
import { IsOptional, IsInt, Min, Max } from 'class-validator';
3+
import { Type } from 'class-transformer';
4+
5+
export class FanDashboardQueryDto {
6+
@ApiPropertyOptional({ description: 'Page number (1-based)', default: 1, minimum: 1 })
7+
@IsOptional()
8+
@IsInt()
9+
@Min(1)
10+
@Type(() => Number)
11+
page?: number = 1;
12+
13+
@ApiPropertyOptional({ description: 'Items per page', default: 20, minimum: 1, maximum: 100 })
14+
@IsOptional()
15+
@IsInt()
16+
@Min(1)
17+
@Max(100)
18+
@Type(() => Number)
19+
limit?: number = 20;
20+
}

backend/src/subscriptions/subscriptions.controller.spec.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ describe('SubscriptionsController', () => {
1919
let service: jest.Mocked<
2020
Pick<
2121
SubscriptionsService,
22-
'getFanCreatorSubscriptionState' | 'listCreatorSubscribers' | 'listSubscriptions'
22+
'getFanCreatorSubscriptionState' | 'listCreatorSubscribers' | 'listSubscriptions' | 'getFanDashboardSummary'
2323
>
2424
>;
2525

@@ -63,6 +63,14 @@ describe('SubscriptionsController', () => {
6363
nextCursor: null,
6464
cursor: null,
6565
}),
66+
getFanDashboardSummary: jest.fn().mockResolvedValue({
67+
fan,
68+
totalActive: 0,
69+
subscriptions: [],
70+
page: 1,
71+
limit: 20,
72+
totalPages: 0,
73+
}),
6674
};
6775

6876
const module: TestingModule = await Test.createTestingModule({
@@ -156,6 +164,20 @@ describe('SubscriptionsController', () => {
156164
20,
157165
);
158166
});
167+
168+
it('getFanDashboard delegates to getFanDashboardSummary with fan from request', async () => {
169+
const req = { fanAddress: fan } as RequestWithFan;
170+
await controller.getFanDashboard(req, { page: 2, limit: 10 });
171+
172+
expect(service.getFanDashboardSummary).toHaveBeenCalledWith(fan, 2, 10);
173+
});
174+
175+
it('getFanDashboard uses default page and limit', async () => {
176+
const req = { fanAddress: fan } as RequestWithFan;
177+
await controller.getFanDashboard(req, {});
178+
179+
expect(service.getFanDashboardSummary).toHaveBeenCalledWith(fan, undefined, undefined);
180+
});
159181
});
160182

161183
describe('ListSubscriptionsQueryDto – status validation', () => {

backend/src/subscriptions/subscriptions.controller.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,29 @@ export class SubscriptionsController {
163163
);
164164
}
165165

166+
@Get('me/dashboard')
167+
@UseGuards(FanBearerGuard)
168+
@ApiBearerAuth()
169+
@ApiOperation({
170+
summary: 'Get fan dashboard summary with active subscriptions',
171+
description:
172+
'Offset-paginated dashboard. Pass `page` and `limit` to control pagination.',
173+
})
174+
@ApiQuery({ name: 'page', required: false, description: 'Page number (1-based, default 1)' })
175+
@ApiQuery({ name: 'limit', required: false, description: 'Items per page (default 20, max 100)' })
176+
@ApiResponse({ status: 200, description: 'Fan dashboard summary with pagination' })
177+
@ApiResponse({ status: 401, description: 'Unauthorized' })
178+
getFanDashboard(
179+
@Req() req: RequestWithFan,
180+
@Query() query: FanDashboardQueryDto,
181+
) {
182+
return this.subscriptionsService.getFanDashboardSummary(
183+
req.fanAddress,
184+
query.page,
185+
query.limit,
186+
);
187+
}
188+
166189
@Post('checkout')
167190
@Throttle({ short: { limit: 10, ttl: 60000 } })
168191
@UseGuards(FeatureFlagGuard)

0 commit comments

Comments
 (0)