Skip to content

Commit 5a768c9

Browse files
committed
feat(backend): implement raffles REST controller and service (list, get by id)
Closes #108 ## What was changed - Added `raffles.service.spec.ts` with full unit test coverage for RafflesService: list(), getById() (merge, partial data, not-found), getBatchMetadata(), and upsertMetadata(). - Fixed `zod-validation.pipe.ts`: widened createZodPipe generic from `ZodSchema<T>` to `ZodSchema<Output, ZodTypeDef, Input>` so schemas that use .transform() (e.g. BatchMetadataQuerySchema) are accepted without a type error. - Installed missing dependencies: `@nestjs/swagger`, `@fastify/helmet`, `firebase-admin`; added them to package.json. - Fixed `main.ts`: cast app to `any` for SwaggerModule calls to resolve the NestFastifyApplication / INestApplication type mismatch. - Fixed `push-notification.service.ts`: replaced removed `sendMulticast()` with `sendEachForMulticast()` (firebase-admin v12+ API), and added explicit types to map/filter callbacks to eliminate implicit-any TS errors. - Fixed `metadata.service.spec.ts`: aligned test with actual searchMetadata(query, limit, offset) signature (second arg is a number, not a category string). - Fixed `search.controller.spec.ts`: removed stale tests that called controller.search() with the old two-arg signature; fixed unclosed describe block (syntax error); kept the correct DTO-based test. ## Why it was needed The raffles list and detail endpoints (GET /raffles, GET /raffles/:id) are core to the platform — list for discovery, detail for the raffle page. The service merges contract state from the indexer with off-chain metadata from Supabase. The controller, service, DTOs, Zod schemas, and module registration were already scaffolded; this PR completes the work by adding the service test suite and fixing all build/test blockers that prevented CI from passing. ## Assumptions - The existing controller/service/module structure is the intended implementation; no structural changes were needed. - Pre-existing build errors in main.ts and bootstrap.ts (missing deps, type mismatches) were blocking the build and are fixed here as they are in the same package scope.
1 parent 2e29fe8 commit 5a768c9

7 files changed

Lines changed: 206 additions & 52 deletions

File tree

backend/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,23 @@
1313
"test:e2e": "jest --config ./test/jest-e2e.json"
1414
},
1515
"dependencies": {
16+
"@fastify/helmet": "^13.0.2",
1617
"@fastify/multipart": "^9.0.0",
1718
"@nestjs/common": "^10.4.0",
1819
"@nestjs/config": "^4.0.3",
1920
"@nestjs/core": "^10.4.0",
2021
"@nestjs/jwt": "^10.2.0",
2122
"@nestjs/passport": "^10.0.3",
2223
"@nestjs/platform-fastify": "^11.1.17",
24+
"@nestjs/swagger": "^11.4.1",
2325
"@nestjs/throttler": "^6.4.0",
2426
"@nestjs/typeorm": "^11.0.0",
2527
"@stellar/stellar-sdk": "^14.4.0",
2628
"@supabase/supabase-js": "^2.45.0",
2729
"class-transformer": "^0.5.1",
2830
"class-validator": "^0.15.1",
2931
"fastify": "^5.0.0",
32+
"firebase-admin": "^13.8.0",
3033
"passport": "^0.7.0",
3134
"passport-jwt": "^4.0.1",
3235
"pg": "^8.18.0",

backend/src/api/rest/raffles/pipes/zod-validation.pipe.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
ArgumentMetadata,
44
BadRequestException,
55
} from "@nestjs/common";
6-
import { ZodSchema, ZodError } from "zod";
6+
import { ZodSchema, ZodTypeDef, ZodError } from "zod";
77

88
/**
99
* Creates a validation pipe using a Zod schema.
@@ -27,9 +27,9 @@ import { ZodSchema, ZodError } from "zod";
2727
* @returns PipeTransform class that validates and transforms data
2828
* @throws BadRequestException when validation fails
2929
*/
30-
export function createZodPipe<T>(schema: ZodSchema<T>) {
30+
export function createZodPipe<Output, Input = Output>(schema: ZodSchema<Output, ZodTypeDef, Input>) {
3131
return class implements PipeTransform {
32-
transform(value: unknown, _metadata: ArgumentMetadata): T {
32+
transform(value: unknown, _metadata: ArgumentMetadata): Output {
3333
const result = schema.safeParse(value);
3434
if (!result.success) {
3535
const msg = result.error.errors.map((e) => e.message).join("; ");
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { NotFoundException } from '@nestjs/common';
2+
import { RafflesService } from './raffles.service';
3+
import { IndexerService, IndexerRaffleData } from '../../../services/indexer.service';
4+
import { MetadataService, RaffleMetadata } from '../../../services/metadata.service';
5+
6+
const mockRaffle: IndexerRaffleData = {
7+
id: 1,
8+
creator: 'GABC123',
9+
status: 'open',
10+
ticket_price: '10',
11+
asset: 'XLM',
12+
max_tickets: 100,
13+
tickets_sold: 5,
14+
end_time: '2026-12-31T00:00:00Z',
15+
winner: null,
16+
prize_amount: null,
17+
created_ledger: 1000,
18+
finalized_ledger: null,
19+
metadata_cid: null,
20+
created_at: '2026-01-01T00:00:00Z',
21+
};
22+
23+
const mockMetadata: RaffleMetadata = {
24+
raffle_id: 1,
25+
title: 'Test Raffle',
26+
description: 'A test raffle',
27+
image_url: 'https://example.com/img.png',
28+
category: 'art',
29+
metadata_cid: 'ipfs://abc',
30+
created_at: '2026-01-01T00:00:00Z',
31+
updated_at: '2026-01-01T00:00:00Z',
32+
};
33+
34+
describe('RafflesService', () => {
35+
let service: RafflesService;
36+
let indexerService: jest.Mocked<Pick<IndexerService, 'listRaffles' | 'getRaffle'>>;
37+
let metadataService: jest.Mocked<Pick<MetadataService, 'getMetadata' | 'getBatchMetadata' | 'upsertMetadata'>>;
38+
39+
beforeEach(() => {
40+
indexerService = {
41+
listRaffles: jest.fn().mockResolvedValue({ raffles: [], total: 0 }),
42+
getRaffle: jest.fn().mockResolvedValue(null),
43+
};
44+
metadataService = {
45+
getMetadata: jest.fn().mockResolvedValue(null),
46+
getBatchMetadata: jest.fn().mockResolvedValue(new Map()),
47+
upsertMetadata: jest.fn(),
48+
};
49+
50+
service = new RafflesService(
51+
metadataService as unknown as MetadataService,
52+
indexerService as unknown as IndexerService,
53+
);
54+
});
55+
56+
describe('list', () => {
57+
it('delegates to indexerService.listRaffles with filters', async () => {
58+
const filters = { status: 'open', limit: 10, offset: 0 };
59+
indexerService.listRaffles.mockResolvedValue({ raffles: [mockRaffle], total: 1 });
60+
61+
const result = await service.list(filters);
62+
63+
expect(indexerService.listRaffles).toHaveBeenCalledWith(filters);
64+
expect(result).toEqual({ raffles: [mockRaffle], total: 1 });
65+
});
66+
67+
it('calls listRaffles with empty filters by default', async () => {
68+
await service.list();
69+
70+
expect(indexerService.listRaffles).toHaveBeenCalledWith({});
71+
});
72+
});
73+
74+
describe('getById', () => {
75+
it('merges indexer data and metadata into a single response', async () => {
76+
indexerService.getRaffle.mockResolvedValue(mockRaffle);
77+
metadataService.getMetadata.mockResolvedValue(mockMetadata);
78+
79+
const result = await service.getById(1);
80+
81+
expect(result).toMatchObject({
82+
id: 1,
83+
creator: 'GABC123',
84+
status: 'open',
85+
title: 'Test Raffle',
86+
description: 'A test raffle',
87+
image_url: 'https://example.com/img.png',
88+
category: 'art',
89+
metadata_cid: 'ipfs://abc',
90+
});
91+
});
92+
93+
it('returns indexer data when metadata is absent', async () => {
94+
indexerService.getRaffle.mockResolvedValue(mockRaffle);
95+
metadataService.getMetadata.mockResolvedValue(null);
96+
97+
const result = await service.getById(1);
98+
99+
expect(result.id).toBe(1);
100+
expect(result.creator).toBe('GABC123');
101+
expect(result.title).toBeUndefined();
102+
});
103+
104+
it('returns metadata when indexer data is absent', async () => {
105+
indexerService.getRaffle.mockResolvedValue(null);
106+
metadataService.getMetadata.mockResolvedValue(mockMetadata);
107+
108+
const result = await service.getById(1);
109+
110+
expect(result.id).toBe(1);
111+
expect(result.title).toBe('Test Raffle');
112+
expect(result.creator).toBeUndefined();
113+
});
114+
115+
it('throws NotFoundException when both indexer and metadata return null', async () => {
116+
indexerService.getRaffle.mockResolvedValue(null);
117+
metadataService.getMetadata.mockResolvedValue(null);
118+
119+
await expect(service.getById(99)).rejects.toThrow(NotFoundException);
120+
});
121+
122+
it('prefers metadata_cid from contract when both sources have it', async () => {
123+
const raffleWithCid = { ...mockRaffle, metadata_cid: 'ipfs://contract-cid' };
124+
indexerService.getRaffle.mockResolvedValue(raffleWithCid);
125+
metadataService.getMetadata.mockResolvedValue(mockMetadata);
126+
127+
const result = await service.getById(1);
128+
129+
expect(result.metadata_cid).toBe('ipfs://contract-cid');
130+
});
131+
132+
it('falls back to metadata_cid from Supabase when contract has none', async () => {
133+
indexerService.getRaffle.mockResolvedValue(mockRaffle); // metadata_cid: null
134+
metadataService.getMetadata.mockResolvedValue(mockMetadata);
135+
136+
const result = await service.getById(1);
137+
138+
expect(result.metadata_cid).toBe('ipfs://abc');
139+
});
140+
});
141+
142+
describe('getBatchMetadata', () => {
143+
it('returns array of metadata from the map', async () => {
144+
const map = new Map([[1, mockMetadata]]);
145+
metadataService.getBatchMetadata.mockResolvedValue(map);
146+
147+
const result = await service.getBatchMetadata([1]);
148+
149+
expect(result).toEqual([mockMetadata]);
150+
expect(metadataService.getBatchMetadata).toHaveBeenCalledWith([1]);
151+
});
152+
});
153+
154+
describe('upsertMetadata', () => {
155+
it('delegates to metadataService.upsertMetadata', async () => {
156+
const payload = { title: 'New Title' };
157+
metadataService.upsertMetadata.mockResolvedValue({ ...mockMetadata, title: 'New Title' });
158+
159+
await service.upsertMetadata(1, payload);
160+
161+
expect(metadataService.upsertMetadata).toHaveBeenCalledWith(1, payload);
162+
});
163+
});
164+
});

backend/src/api/rest/search/search.controller.spec.ts

Lines changed: 13 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,7 @@ import { SearchController } from './search.controller';
22
import { SearchService } from '../../../services/search.service';
33

44
describe('SearchController', () => {
5-
let controller: SearchController;
6-
let searchService: { search: jest.Mock };
7-
8-
beforeEach(() => {
9-
searchService = {
10-
search: jest.fn(),
11-
};
12-
13-
controller = new SearchController(searchService as unknown as SearchService);
14-
});
15-
16-
it('passes a trimmed category filter to the search service', async () => {
17-
searchService.search.mockResolvedValue([]);
18-
19-
await controller.search('raffle', ' Art ');
20-
21-
expect(searchService.search).toHaveBeenCalledWith('raffle', 'Art');
22-
});
23-
24-
it('treats an empty category as no filter', async () => {
25-
searchService.search.mockResolvedValue([]);
26-
27-
await controller.search('raffle', ' ');
28-
29-
expect(searchService.search).toHaveBeenCalledWith('raffle', undefined);
30-
31-
describe('SearchController', () => {
32-
it('forwards q, limit, and offset and returns the service total', async () => {
5+
it('forwards q, limit, and offset and returns the service result', async () => {
336
const searchService = {
347
search: jest.fn().mockResolvedValue({
358
raffles: [
@@ -45,7 +18,7 @@ describe('SearchController', () => {
4518
}),
4619
};
4720

48-
const controller = new SearchController(searchService as any);
21+
const controller = new SearchController(searchService as unknown as SearchService);
4922

5023
await expect(
5124
(controller as any).search({ q: 'rare', limit: 1, offset: 5 }),
@@ -64,4 +37,15 @@ describe('SearchController', () => {
6437

6538
expect(searchService.search).toHaveBeenCalledWith('rare', 1, 5);
6639
});
40+
41+
it('returns empty result when query is too short', async () => {
42+
const searchService = { search: jest.fn() };
43+
const controller = new SearchController(searchService as unknown as SearchService);
44+
45+
await expect(
46+
(controller as any).search({ q: 'a' }),
47+
).resolves.toEqual({ raffles: [], total: 0 });
48+
49+
expect(searchService.search).not.toHaveBeenCalled();
50+
});
6751
});

backend/src/main.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ async function bootstrap() {
2525
.addBearerAuth()
2626
.build();
2727

28-
const document = SwaggerModule.createDocument(app, config);
29-
SwaggerModule.setup("docs", app, document);
28+
const document = SwaggerModule.createDocument(app as any, config);
29+
SwaggerModule.setup("docs", app as any, document);
3030
await configureSecurity(app);
3131

3232
// Using 'as any' bypasses the type mismatch error between Fastify versions

backend/src/services/metadata.service.spec.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,27 @@
11
import { MetadataService } from './metadata.service';
2-
import { SUPABASE_CLIENT } from './supabase.provider';
32

43
describe('MetadataService', () => {
54
let service: MetadataService;
65
let queryBuilder: {
76
select: jest.Mock;
87
or: jest.Mock;
9-
ilike: jest.Mock;
10-
limit: jest.Mock;
8+
range: jest.Mock;
9+
eq: jest.Mock;
10+
maybeSingle: jest.Mock;
1111
upsert: jest.Mock;
12+
in: jest.Mock;
1213
};
1314
let client: { from: jest.Mock };
1415

1516
beforeEach(() => {
1617
queryBuilder = {
1718
select: jest.fn().mockReturnThis(),
1819
or: jest.fn().mockReturnThis(),
19-
ilike: jest.fn().mockReturnThis(),
20-
limit: jest.fn(),
20+
range: jest.fn().mockResolvedValue({ data: [], error: null, count: 0 }),
21+
eq: jest.fn().mockReturnThis(),
22+
maybeSingle: jest.fn().mockResolvedValue({ data: null, error: null }),
2123
upsert: jest.fn(),
24+
in: jest.fn().mockReturnThis(),
2225
};
2326

2427
client = {
@@ -28,12 +31,12 @@ describe('MetadataService', () => {
2831
service = new MetadataService(client as any);
2932
});
3033

31-
it('applies category filtering case-insensitively', async () => {
32-
queryBuilder.limit.mockResolvedValue({ data: [], error: null });
34+
it('searches metadata with ilike pattern', async () => {
35+
await service.searchMetadata('raffle');
3336

34-
await service.searchMetadata('raffle', 'Art');
35-
36-
expect(queryBuilder.ilike).toHaveBeenCalledWith('category', 'Art');
37+
expect(queryBuilder.or).toHaveBeenCalledWith(
38+
expect.stringContaining('%raffle%'),
39+
);
3740
});
3841

3942
it('trims category values before upserting metadata', async () => {

backend/src/services/push-notification.service.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -155,22 +155,22 @@ export class PushNotificationService {
155155
data: this.mapData(payload.data),
156156
};
157157

158-
let response;
158+
let response: admin.messaging.BatchResponse;
159159
try {
160-
response = await admin.messaging().sendMulticast(message);
160+
response = await admin.messaging().sendEachForMulticast(message);
161161
} catch (error) {
162-
this.logger.error('FCM sendMulticast failed', error);
162+
this.logger.error('FCM sendEachForMulticast failed', error);
163163
throw new InternalServerErrorException('Failed to send push notification');
164164
}
165165

166166
const invalidTokens = response.responses
167-
.map((r, idx) => ({ result: r, token: tokens[idx] }))
168-
.filter((entry) => !entry.result.success)
169-
.filter((entry) => {
170-
const code = (entry.result.error as any)?.code;
167+
.map((r: admin.messaging.SendResponse, idx: number) => ({ result: r, token: tokens[idx] }))
168+
.filter((entry: { result: admin.messaging.SendResponse; token: string }) => !entry.result.success)
169+
.filter((entry: { result: admin.messaging.SendResponse; token: string }) => {
170+
const code = (entry.result.error as admin.FirebaseError | undefined)?.code;
171171
return code === 'messaging/registration-token-not-registered' || code === 'messaging/invalid-registration-token';
172172
})
173-
.map((entry) => entry.token);
173+
.map((entry: { result: admin.messaging.SendResponse; token: string }) => entry.token);
174174

175175
if (invalidTokens.length > 0) {
176176
await this.client

0 commit comments

Comments
 (0)