Skip to content
Open
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
239 changes: 239 additions & 0 deletions Backend/src/gists/gists.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import { Test, TestingModule } from '@nestjs/testing';
import { GistsService } from './gists.service';
import { GistRepository } from './gist.repository';
import { GeoService } from '../geo/geo.service';
import { IpfsService } from '../ipfs/ipfs.service';
import { SorobanService } from '../soroban/soroban.service';
import { CacheService } from '../cache/cache.service';
import { Gist } from './entities/gist.entity';
import { CreateGistDto } from './dto/create-gist.dto';
import { QueryGistsDto } from './dto/query-gists.dto';

jest.mock('../common/utils/sanitize', () => ({
stripHtml: jest.fn((text: string) => text),
}));

const mockGist = (): Gist => ({
id: 'uuid-1',
content: 'Test gist',
location_cell: 's1t7d8c',
content_hash: 'mock_Qmabc123',
stellar_gist_id: '1000',
tx_hash: 'mock_tx_abc',
location: null,
created_at: new Date('2026-01-01T00:00:00.000Z'),
});

describe('GistsService', () => {
let service: GistsService;
let gistRepository: jest.Mocked<GistRepository>;
let geoService: jest.Mocked<GeoService>;
let ipfsService: jest.Mocked<IpfsService>;
let sorobanService: jest.Mocked<SorobanService>;
let cacheService: jest.Mocked<CacheService>;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
GistsService,
{
provide: GistRepository,
useValue: {
create: jest.fn(),
findNearby: jest.fn(),
findByGistId: jest.fn(),
},
},
{
provide: GeoService,
useValue: { encode: jest.fn() },
},
{
provide: IpfsService,
useValue: { pinJson: jest.fn() },
},
{
provide: SorobanService,
useValue: { postGist: jest.fn() },
},
{
provide: CacheService,
useValue: {
get: jest.fn(),
set: jest.fn(),
delPattern: jest.fn(),
},
},
],
}).compile();

service = module.get<GistsService>(GistsService);
gistRepository = module.get(GistRepository);
geoService = module.get(GeoService);
ipfsService = module.get(IpfsService);
sorobanService = module.get(SorobanService);
cacheService = module.get(CacheService);
});

describe('create()', () => {
it('calls GeoService.encode with lat/lon', async () => {
const dto: CreateGistDto = { content: 'Test', lat: 9.0579, lon: 7.4951 };
geoService.encode.mockReturnValue('s1t7d8c');
ipfsService.pinJson.mockResolvedValue({ cid: 'mock_Qmabc', mock: true });
sorobanService.postGist.mockResolvedValue({ gistId: '1', txHash: 'tx1', mock: true });
gistRepository.create.mockResolvedValue(mockGist());
cacheService.delPattern.mockResolvedValue();

await service.create(dto);

expect(geoService.encode).toHaveBeenCalledWith(9.0579, 7.4951);
});

it('calls IpfsService.pinJson with content and location metadata', async () => {
const dto: CreateGistDto = { content: 'Test', lat: 9.0579, lon: 7.4951 };
geoService.encode.mockReturnValue('s1t7d8c');
ipfsService.pinJson.mockResolvedValue({ cid: 'mock_Qmabc', mock: true });
sorobanService.postGist.mockResolvedValue({ gistId: '1', txHash: 'tx1', mock: true });
gistRepository.create.mockResolvedValue(mockGist());
cacheService.delPattern.mockResolvedValue();

await service.create(dto);

expect(ipfsService.pinJson).toHaveBeenCalledWith(
expect.objectContaining({
content: 'Test',
lat: 9.0579,
lon: 7.4951,
location_cell: 's1t7d8c',
}),
);
});

it('calls SorobanService.postGist with locationCell, cid, and author', async () => {
const dto: CreateGistDto = { content: 'Test', lat: 9.0579, lon: 7.4951, author: 'GABC' };
geoService.encode.mockReturnValue('s1t7d8c');
ipfsService.pinJson.mockResolvedValue({ cid: 'mock_Qmabc', mock: true });
sorobanService.postGist.mockResolvedValue({ gistId: '1', txHash: 'tx1', mock: true });
gistRepository.create.mockResolvedValue(mockGist());
cacheService.delPattern.mockResolvedValue();

await service.create(dto);

expect(sorobanService.postGist).toHaveBeenCalledWith('s1t7d8c', 'mock_Qmabc', 'GABC');
});

it('calls GistRepository.create with all required fields', async () => {
const dto: CreateGistDto = { content: 'Test', lat: 9.0579, lon: 7.4951 };
geoService.encode.mockReturnValue('s1t7d8c');
ipfsService.pinJson.mockResolvedValue({ cid: 'mock_Qmabc', mock: true });
sorobanService.postGist.mockResolvedValue({ gistId: '42', txHash: 'tx42', mock: true });
gistRepository.create.mockResolvedValue(mockGist());
cacheService.delPattern.mockResolvedValue();

await service.create(dto);

expect(gistRepository.create).toHaveBeenCalledWith({
content: 'Test',
lat: 9.0579,
lon: 7.4951,
location_cell: 's1t7d8c',
content_hash: 'mock_Qmabc',
stellar_gist_id: '42',
tx_hash: 'tx42',
});
});

it('returns the gist created by the repository', async () => {
const dto: CreateGistDto = { content: 'Test', lat: 9.0579, lon: 7.4951 };
const gist = mockGist();
geoService.encode.mockReturnValue('s1t7d8c');
ipfsService.pinJson.mockResolvedValue({ cid: 'cid1', mock: true });
sorobanService.postGist.mockResolvedValue({ gistId: '1', txHash: 'tx', mock: true });
gistRepository.create.mockResolvedValue(gist);
cacheService.delPattern.mockResolvedValue();

const result = await service.create(dto);

expect(result).toBe(gist);
});
});

describe('findNearby()', () => {
const query: QueryGistsDto = { lat: 9.0579, lon: 7.4951, radius: 500, limit: 20 };
const paginatedResult = {
data: [mockGist()],
pagination: { count: 1, cursor: null, hasMore: false },
};

it('returns cached result when cache hit occurs', async () => {
cacheService.get.mockResolvedValue(paginatedResult);

const result = await service.findNearby(query);

expect(result).toBe(paginatedResult);
expect(gistRepository.findNearby).not.toHaveBeenCalled();
});

it('calls GistRepository.findNearby on cache miss', async () => {
cacheService.get.mockResolvedValue(null);
cacheService.set.mockResolvedValue();
gistRepository.findNearby.mockResolvedValue(paginatedResult);

await service.findNearby(query);

expect(gistRepository.findNearby).toHaveBeenCalledWith({
lat: 9.0579,
lon: 7.4951,
radiusMeters: 500,
limit: 20,
cursor: undefined,
});
});

it('skips cache and calls repository directly when cursor is present', async () => {
const queryWithCursor = { ...query, cursor: '2026-01-01T00:00:00.000Z' };
gistRepository.findNearby.mockResolvedValue(paginatedResult);

await service.findNearby(queryWithCursor);

expect(cacheService.get).not.toHaveBeenCalled();
expect(gistRepository.findNearby).toHaveBeenCalledWith(
expect.objectContaining({ cursor: '2026-01-01T00:00:00.000Z' }),
);
});
});

describe('findOne()', () => {
it('returns cached gist on cache hit', async () => {
const gist = mockGist();
cacheService.get.mockResolvedValue(gist);

const result = await service.findOne('uuid-1');

expect(result).toBe(gist);
expect(gistRepository.findByGistId).not.toHaveBeenCalled();
});

it('calls GistRepository.findByGistId on cache miss', async () => {
const gist = mockGist();
cacheService.get.mockResolvedValue(null);
cacheService.set.mockResolvedValue();
gistRepository.findByGistId.mockResolvedValue(gist);

const result = await service.findOne('uuid-1');

expect(gistRepository.findByGistId).toHaveBeenCalledWith('uuid-1');
expect(result).toBe(gist);
});

it('returns null when gist not found', async () => {
cacheService.get.mockResolvedValue(null);
cacheService.set.mockResolvedValue();
gistRepository.findByGistId.mockResolvedValue(null);

const result = await service.findOne('nonexistent');

expect(result).toBeNull();
});
});
});
129 changes: 129 additions & 0 deletions Backend/src/ipfs/ipfs.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { IpfsService } from './ipfs.service';

describe('IpfsService', () => {
let service: IpfsService;

const buildService = async (apiKey?: string, secretKey?: string, retries = 3): Promise<IpfsService> => {
const module: TestingModule = await Test.createTestingModule({
providers: [
IpfsService,
{
provide: ConfigService,
useValue: {
get: jest.fn().mockImplementation((key: string, def?: unknown) => {
if (key === 'PINATA_API_KEY') return apiKey;
if (key === 'PINATA_SECRET_KEY') return secretKey;
if (key === 'IPFS_RETRY_ATTEMPTS') return retries;
return def;
}),
},
},
],
}).compile();

return module.get<IpfsService>(IpfsService);
};

describe('dev mode (no Pinata credentials)', () => {
beforeEach(async () => {
service = await buildService(undefined, undefined);
});

describe('pinJson()', () => {
it('returns a mock CID in dev mode', async () => {
const result = await service.pinJson({ content: 'hello', lat: 9, lon: 7 });
expect(result.mock).toBe(true);
expect(result.cid).toMatch(/^mock_Qm/);
});

it('generates different CIDs for different content', async () => {
const r1 = await service.pinJson({ content: 'A' });
const r2 = await service.pinJson({ content: 'B' });
expect(r1.cid).not.toBe(r2.cid);
});

it('returns synchronously (no network call)', async () => {
const start = Date.now();
await service.pinJson({ content: 'test' });
// dev mock must complete well under 100 ms (no retry delays)
expect(Date.now() - start).toBeLessThan(100);
});
});

describe('getJson()', () => {
it('returns a mock response for mock CIDs', async () => {
const result = await service.getJson('mock_Qmabc123');
expect(result).toMatchObject({ mock: true });
});

it('returns a mock response in dev mode for any CID', async () => {
const result = await service.getJson('QmRealLookingCid');
expect(result).toMatchObject({ mock: true });
});
});
});

describe('real mode (Pinata credentials provided)', () => {
let fetchSpy: jest.SpyInstance;

beforeEach(async () => {
// Prevent the real require('@pinata/sdk') from failing in test env
jest.mock('@pinata/sdk', () => {
return jest.fn().mockImplementation(() => ({
pinJSONToIPFS: jest.fn().mockRejectedValue(new Error('Pinata network error')),
}));
}, { virtual: true });

// We won't call pinJson in real mode for getJson tests — just test getJson path
// Service built with credentials but getJson for mock_* CIDs still returns mock
service = await buildService('test-api-key', 'test-secret-key');
});

afterEach(() => {
if (fetchSpy) fetchSpy.mockRestore();
jest.resetModules();
});

describe('getJson()', () => {
it('returns mock response for mock_ prefixed CIDs even in real mode', async () => {
const result = await service.getJson('mock_Qmabcdef');
expect(result).toMatchObject({ mock: true });
});

it('retries on fetch failure and throws after exhausting retries', async () => {
fetchSpy = jest.spyOn(global, 'fetch').mockRejectedValue(new Error('Network failure'));

await expect(service.getJson('QmRealCid123')).rejects.toThrow('Network failure');

// Should have been called 3 times (maxRetries=3)
expect(fetchSpy).toHaveBeenCalledTimes(3);
});

it('throws on non-OK HTTP response after retries', async () => {
fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
ok: false,
status: 404,
json: jest.fn(),
} as unknown as Response);

await expect(service.getJson('QmNotFound')).rejects.toThrow('IPFS fetch failed: 404');

expect(fetchSpy).toHaveBeenCalledTimes(3);
});

it('returns parsed JSON on successful fetch', async () => {
const mockData = { content: 'test content', lat: 9.0579, lon: 7.4951 };
fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
status: 200,
json: jest.fn().mockResolvedValue(mockData),
} as unknown as Response);

const result = await service.getJson('QmSuccess123');
expect(result).toEqual(mockData);
});
});
});
});
Loading
Loading