Skip to content

Commit 1e6608b

Browse files
Merge pull request #89 from 7udah/fix/issue-31-unit-tests-gists-soroban-ipfs
test: add unit tests for GistsService, SorobanService, IpfsService
2 parents 55ca605 + aa8d9f4 commit 1e6608b

3 files changed

Lines changed: 499 additions & 0 deletions

File tree

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { GistsService } from './gists.service';
3+
import { GistRepository } from './gist.repository';
4+
import { GeoService } from '../geo/geo.service';
5+
import { IpfsService } from '../ipfs/ipfs.service';
6+
import { SorobanService } from '../soroban/soroban.service';
7+
import { CacheService } from '../cache/cache.service';
8+
import { Gist } from './entities/gist.entity';
9+
import { CreateGistDto } from './dto/create-gist.dto';
10+
import { QueryGistsDto } from './dto/query-gists.dto';
11+
12+
jest.mock('../common/utils/sanitize', () => ({
13+
stripHtml: jest.fn((text: string) => text),
14+
}));
15+
16+
const mockGist = (): Gist => ({
17+
id: 'uuid-1',
18+
content: 'Test gist',
19+
location_cell: 's1t7d8c',
20+
content_hash: 'mock_Qmabc123',
21+
stellar_gist_id: '1000',
22+
tx_hash: 'mock_tx_abc',
23+
location: null,
24+
created_at: new Date('2026-01-01T00:00:00.000Z'),
25+
});
26+
27+
describe('GistsService', () => {
28+
let service: GistsService;
29+
let gistRepository: jest.Mocked<GistRepository>;
30+
let geoService: jest.Mocked<GeoService>;
31+
let ipfsService: jest.Mocked<IpfsService>;
32+
let sorobanService: jest.Mocked<SorobanService>;
33+
let cacheService: jest.Mocked<CacheService>;
34+
35+
beforeEach(async () => {
36+
const module: TestingModule = await Test.createTestingModule({
37+
providers: [
38+
GistsService,
39+
{
40+
provide: GistRepository,
41+
useValue: {
42+
create: jest.fn(),
43+
findNearby: jest.fn(),
44+
findByGistId: jest.fn(),
45+
},
46+
},
47+
{
48+
provide: GeoService,
49+
useValue: { encode: jest.fn() },
50+
},
51+
{
52+
provide: IpfsService,
53+
useValue: { pinJson: jest.fn() },
54+
},
55+
{
56+
provide: SorobanService,
57+
useValue: { postGist: jest.fn() },
58+
},
59+
{
60+
provide: CacheService,
61+
useValue: {
62+
get: jest.fn(),
63+
set: jest.fn(),
64+
delPattern: jest.fn(),
65+
},
66+
},
67+
],
68+
}).compile();
69+
70+
service = module.get<GistsService>(GistsService);
71+
gistRepository = module.get(GistRepository);
72+
geoService = module.get(GeoService);
73+
ipfsService = module.get(IpfsService);
74+
sorobanService = module.get(SorobanService);
75+
cacheService = module.get(CacheService);
76+
});
77+
78+
describe('create()', () => {
79+
it('calls GeoService.encode with lat/lon', async () => {
80+
const dto: CreateGistDto = { content: 'Test', lat: 9.0579, lon: 7.4951 };
81+
geoService.encode.mockReturnValue('s1t7d8c');
82+
ipfsService.pinJson.mockResolvedValue({ cid: 'mock_Qmabc', mock: true });
83+
sorobanService.postGist.mockResolvedValue({ gistId: '1', txHash: 'tx1', mock: true });
84+
gistRepository.create.mockResolvedValue(mockGist());
85+
cacheService.delPattern.mockResolvedValue();
86+
87+
await service.create(dto);
88+
89+
expect(geoService.encode).toHaveBeenCalledWith(9.0579, 7.4951);
90+
});
91+
92+
it('calls IpfsService.pinJson with content and location metadata', async () => {
93+
const dto: CreateGistDto = { content: 'Test', lat: 9.0579, lon: 7.4951 };
94+
geoService.encode.mockReturnValue('s1t7d8c');
95+
ipfsService.pinJson.mockResolvedValue({ cid: 'mock_Qmabc', mock: true });
96+
sorobanService.postGist.mockResolvedValue({ gistId: '1', txHash: 'tx1', mock: true });
97+
gistRepository.create.mockResolvedValue(mockGist());
98+
cacheService.delPattern.mockResolvedValue();
99+
100+
await service.create(dto);
101+
102+
expect(ipfsService.pinJson).toHaveBeenCalledWith(
103+
expect.objectContaining({
104+
content: 'Test',
105+
lat: 9.0579,
106+
lon: 7.4951,
107+
location_cell: 's1t7d8c',
108+
}),
109+
);
110+
});
111+
112+
it('calls SorobanService.postGist with locationCell, cid, and author', async () => {
113+
const dto: CreateGistDto = { content: 'Test', lat: 9.0579, lon: 7.4951, author: 'GABC' };
114+
geoService.encode.mockReturnValue('s1t7d8c');
115+
ipfsService.pinJson.mockResolvedValue({ cid: 'mock_Qmabc', mock: true });
116+
sorobanService.postGist.mockResolvedValue({ gistId: '1', txHash: 'tx1', mock: true });
117+
gistRepository.create.mockResolvedValue(mockGist());
118+
cacheService.delPattern.mockResolvedValue();
119+
120+
await service.create(dto);
121+
122+
expect(sorobanService.postGist).toHaveBeenCalledWith('s1t7d8c', 'mock_Qmabc', 'GABC');
123+
});
124+
125+
it('calls GistRepository.create with all required fields', async () => {
126+
const dto: CreateGistDto = { content: 'Test', lat: 9.0579, lon: 7.4951 };
127+
geoService.encode.mockReturnValue('s1t7d8c');
128+
ipfsService.pinJson.mockResolvedValue({ cid: 'mock_Qmabc', mock: true });
129+
sorobanService.postGist.mockResolvedValue({ gistId: '42', txHash: 'tx42', mock: true });
130+
gistRepository.create.mockResolvedValue(mockGist());
131+
cacheService.delPattern.mockResolvedValue();
132+
133+
await service.create(dto);
134+
135+
expect(gistRepository.create).toHaveBeenCalledWith({
136+
content: 'Test',
137+
lat: 9.0579,
138+
lon: 7.4951,
139+
location_cell: 's1t7d8c',
140+
content_hash: 'mock_Qmabc',
141+
stellar_gist_id: '42',
142+
tx_hash: 'tx42',
143+
});
144+
});
145+
146+
it('returns the gist created by the repository', async () => {
147+
const dto: CreateGistDto = { content: 'Test', lat: 9.0579, lon: 7.4951 };
148+
const gist = mockGist();
149+
geoService.encode.mockReturnValue('s1t7d8c');
150+
ipfsService.pinJson.mockResolvedValue({ cid: 'cid1', mock: true });
151+
sorobanService.postGist.mockResolvedValue({ gistId: '1', txHash: 'tx', mock: true });
152+
gistRepository.create.mockResolvedValue(gist);
153+
cacheService.delPattern.mockResolvedValue();
154+
155+
const result = await service.create(dto);
156+
157+
expect(result).toBe(gist);
158+
});
159+
});
160+
161+
describe('findNearby()', () => {
162+
const query: QueryGistsDto = { lat: 9.0579, lon: 7.4951, radius: 500, limit: 20 };
163+
const paginatedResult = {
164+
data: [mockGist()],
165+
pagination: { count: 1, cursor: null, hasMore: false },
166+
};
167+
168+
it('returns cached result when cache hit occurs', async () => {
169+
cacheService.get.mockResolvedValue(paginatedResult);
170+
171+
const result = await service.findNearby(query);
172+
173+
expect(result).toBe(paginatedResult);
174+
expect(gistRepository.findNearby).not.toHaveBeenCalled();
175+
});
176+
177+
it('calls GistRepository.findNearby on cache miss', async () => {
178+
cacheService.get.mockResolvedValue(null);
179+
cacheService.set.mockResolvedValue();
180+
gistRepository.findNearby.mockResolvedValue(paginatedResult);
181+
182+
await service.findNearby(query);
183+
184+
expect(gistRepository.findNearby).toHaveBeenCalledWith({
185+
lat: 9.0579,
186+
lon: 7.4951,
187+
radiusMeters: 500,
188+
limit: 20,
189+
cursor: undefined,
190+
});
191+
});
192+
193+
it('skips cache and calls repository directly when cursor is present', async () => {
194+
const queryWithCursor = { ...query, cursor: '2026-01-01T00:00:00.000Z' };
195+
gistRepository.findNearby.mockResolvedValue(paginatedResult);
196+
197+
await service.findNearby(queryWithCursor);
198+
199+
expect(cacheService.get).not.toHaveBeenCalled();
200+
expect(gistRepository.findNearby).toHaveBeenCalledWith(
201+
expect.objectContaining({ cursor: '2026-01-01T00:00:00.000Z' }),
202+
);
203+
});
204+
});
205+
206+
describe('findOne()', () => {
207+
it('returns cached gist on cache hit', async () => {
208+
const gist = mockGist();
209+
cacheService.get.mockResolvedValue(gist);
210+
211+
const result = await service.findOne('uuid-1');
212+
213+
expect(result).toBe(gist);
214+
expect(gistRepository.findByGistId).not.toHaveBeenCalled();
215+
});
216+
217+
it('calls GistRepository.findByGistId on cache miss', async () => {
218+
const gist = mockGist();
219+
cacheService.get.mockResolvedValue(null);
220+
cacheService.set.mockResolvedValue();
221+
gistRepository.findByGistId.mockResolvedValue(gist);
222+
223+
const result = await service.findOne('uuid-1');
224+
225+
expect(gistRepository.findByGistId).toHaveBeenCalledWith('uuid-1');
226+
expect(result).toBe(gist);
227+
});
228+
229+
it('returns null when gist not found', async () => {
230+
cacheService.get.mockResolvedValue(null);
231+
cacheService.set.mockResolvedValue();
232+
gistRepository.findByGistId.mockResolvedValue(null);
233+
234+
const result = await service.findOne('nonexistent');
235+
236+
expect(result).toBeNull();
237+
});
238+
});
239+
});
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { ConfigService } from '@nestjs/config';
3+
import { IpfsService } from './ipfs.service';
4+
5+
describe('IpfsService', () => {
6+
let service: IpfsService;
7+
8+
const buildService = async (apiKey?: string, secretKey?: string, retries = 3): Promise<IpfsService> => {
9+
const module: TestingModule = await Test.createTestingModule({
10+
providers: [
11+
IpfsService,
12+
{
13+
provide: ConfigService,
14+
useValue: {
15+
get: jest.fn().mockImplementation((key: string, def?: unknown) => {
16+
if (key === 'PINATA_API_KEY') return apiKey;
17+
if (key === 'PINATA_SECRET_KEY') return secretKey;
18+
if (key === 'IPFS_RETRY_ATTEMPTS') return retries;
19+
return def;
20+
}),
21+
},
22+
},
23+
],
24+
}).compile();
25+
26+
return module.get<IpfsService>(IpfsService);
27+
};
28+
29+
describe('dev mode (no Pinata credentials)', () => {
30+
beforeEach(async () => {
31+
service = await buildService(undefined, undefined);
32+
});
33+
34+
describe('pinJson()', () => {
35+
it('returns a mock CID in dev mode', async () => {
36+
const result = await service.pinJson({ content: 'hello', lat: 9, lon: 7 });
37+
expect(result.mock).toBe(true);
38+
expect(result.cid).toMatch(/^mock_Qm/);
39+
});
40+
41+
it('generates different CIDs for different content', async () => {
42+
const r1 = await service.pinJson({ content: 'A' });
43+
const r2 = await service.pinJson({ content: 'B' });
44+
expect(r1.cid).not.toBe(r2.cid);
45+
});
46+
47+
it('returns synchronously (no network call)', async () => {
48+
const start = Date.now();
49+
await service.pinJson({ content: 'test' });
50+
// dev mock must complete well under 100 ms (no retry delays)
51+
expect(Date.now() - start).toBeLessThan(100);
52+
});
53+
});
54+
55+
describe('getJson()', () => {
56+
it('returns a mock response for mock CIDs', async () => {
57+
const result = await service.getJson('mock_Qmabc123');
58+
expect(result).toMatchObject({ mock: true });
59+
});
60+
61+
it('returns a mock response in dev mode for any CID', async () => {
62+
const result = await service.getJson('QmRealLookingCid');
63+
expect(result).toMatchObject({ mock: true });
64+
});
65+
});
66+
});
67+
68+
describe('real mode (Pinata credentials provided)', () => {
69+
let fetchSpy: jest.SpyInstance;
70+
71+
beforeEach(async () => {
72+
// Prevent the real require('@pinata/sdk') from failing in test env
73+
jest.mock('@pinata/sdk', () => {
74+
return jest.fn().mockImplementation(() => ({
75+
pinJSONToIPFS: jest.fn().mockRejectedValue(new Error('Pinata network error')),
76+
}));
77+
}, { virtual: true });
78+
79+
// We won't call pinJson in real mode for getJson tests — just test getJson path
80+
// Service built with credentials but getJson for mock_* CIDs still returns mock
81+
service = await buildService('test-api-key', 'test-secret-key');
82+
});
83+
84+
afterEach(() => {
85+
if (fetchSpy) fetchSpy.mockRestore();
86+
jest.resetModules();
87+
});
88+
89+
describe('getJson()', () => {
90+
it('returns mock response for mock_ prefixed CIDs even in real mode', async () => {
91+
const result = await service.getJson('mock_Qmabcdef');
92+
expect(result).toMatchObject({ mock: true });
93+
});
94+
95+
it('retries on fetch failure and throws after exhausting retries', async () => {
96+
fetchSpy = jest.spyOn(global, 'fetch').mockRejectedValue(new Error('Network failure'));
97+
98+
await expect(service.getJson('QmRealCid123')).rejects.toThrow('Network failure');
99+
100+
// Should have been called 3 times (maxRetries=3)
101+
expect(fetchSpy).toHaveBeenCalledTimes(3);
102+
});
103+
104+
it('throws on non-OK HTTP response after retries', async () => {
105+
fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
106+
ok: false,
107+
status: 404,
108+
json: jest.fn(),
109+
} as unknown as Response);
110+
111+
await expect(service.getJson('QmNotFound')).rejects.toThrow('IPFS fetch failed: 404');
112+
113+
expect(fetchSpy).toHaveBeenCalledTimes(3);
114+
});
115+
116+
it('returns parsed JSON on successful fetch', async () => {
117+
const mockData = { content: 'test content', lat: 9.0579, lon: 7.4951 };
118+
fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
119+
ok: true,
120+
status: 200,
121+
json: jest.fn().mockResolvedValue(mockData),
122+
} as unknown as Response);
123+
124+
const result = await service.getJson('QmSuccess123');
125+
expect(result).toEqual(mockData);
126+
});
127+
});
128+
});
129+
});

0 commit comments

Comments
 (0)