From aa8d9f4d5bc45ec22961116945241c43e9115194 Mon Sep 17 00:00:00 2001 From: 7udah <289478833+7udah@users.noreply.github.com> Date: Sat, 27 Jun 2026 07:36:51 +0000 Subject: [PATCH] test: add unit tests for GistsService, SorobanService, IpfsService (#31) --- Backend/src/gists/gists.service.spec.ts | 239 ++++++++++++++++++++ Backend/src/ipfs/ipfs.service.spec.ts | 129 +++++++++++ Backend/src/soroban/soroban.service.spec.ts | 131 +++++++++++ 3 files changed, 499 insertions(+) create mode 100644 Backend/src/gists/gists.service.spec.ts create mode 100644 Backend/src/ipfs/ipfs.service.spec.ts create mode 100644 Backend/src/soroban/soroban.service.spec.ts diff --git a/Backend/src/gists/gists.service.spec.ts b/Backend/src/gists/gists.service.spec.ts new file mode 100644 index 0000000..d622b6a --- /dev/null +++ b/Backend/src/gists/gists.service.spec.ts @@ -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; + let geoService: jest.Mocked; + let ipfsService: jest.Mocked; + let sorobanService: jest.Mocked; + let cacheService: jest.Mocked; + + 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); + 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(); + }); + }); +}); diff --git a/Backend/src/ipfs/ipfs.service.spec.ts b/Backend/src/ipfs/ipfs.service.spec.ts new file mode 100644 index 0000000..6d37f9a --- /dev/null +++ b/Backend/src/ipfs/ipfs.service.spec.ts @@ -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 => { + 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); + }; + + 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); + }); + }); + }); +}); diff --git a/Backend/src/soroban/soroban.service.spec.ts b/Backend/src/soroban/soroban.service.spec.ts new file mode 100644 index 0000000..49ab7df --- /dev/null +++ b/Backend/src/soroban/soroban.service.spec.ts @@ -0,0 +1,131 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { SorobanService } from './soroban.service'; + +describe('SorobanService', () => { + let service: SorobanService; + let configGet: jest.Mock; + + const buildService = async (contractId?: string, retries = 3): Promise => { + configGet = jest.fn().mockImplementation((key: string, def?: unknown) => { + if (key === 'CONTRACT_ID_GIST_REGISTRY') return contractId; + if (key === 'SOROBAN_RETRY_ATTEMPTS') return retries; + return def; + }); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SorobanService, + { provide: ConfigService, useValue: { get: configGet } }, + ], + }).compile(); + + return module.get(SorobanService); + }; + + describe('mock mode (no CONTRACT_ID_GIST_REGISTRY)', () => { + beforeEach(async () => { + service = await buildService(undefined); + }); + + describe('postGist()', () => { + it('returns a result with mock=true', async () => { + const result = await service.postGist('s1t7d8c', 'mock_Qmabc', 'GABC'); + expect(result.mock).toBe(true); + }); + + it('returns a numeric string gistId', async () => { + const result = await service.postGist('s1t7d8c', 'mock_Qmabc'); + expect(typeof result.gistId).toBe('string'); + expect(Number(result.gistId)).toBeGreaterThan(0); + }); + + it('returns a txHash prefixed with mock_tx_', async () => { + const result = await service.postGist('s1t7d8c', 'mock_Qmabc'); + expect(result.txHash).toMatch(/^mock_tx_/); + }); + + it('generates unique gistId and txHash across calls', async () => { + const r1 = await service.postGist('cell1', 'cid1'); + const r2 = await service.postGist('cell1', 'cid1'); + expect(r1.txHash).not.toBe(r2.txHash); + }); + }); + + describe('getGist()', () => { + it('returns a result with mock=true', async () => { + const result = await service.getGist('42'); + expect(result.mock).toBe(true); + expect(result.gistId).toBe('42'); + }); + + it('returns a contentHash prefixed with mock_Qm', async () => { + const result = await service.getGist('1'); + expect(result.contentHash).toMatch(/^mock_Qm/); + }); + }); + + describe('getEventsSince()', () => { + it('returns an empty array in mock mode', async () => { + const events = await service.getEventsSince(1000); + expect(events).toEqual([]); + }); + }); + }); + + describe('real mode (CONTRACT_ID_GIST_REGISTRY set)', () => { + beforeEach(async () => { + service = await buildService('CAABC123DEF456', 3); + }); + + describe('postGist() retry logic', () => { + it('throws after exhausting all 3 retries', async () => { + await expect(service.postGist('cell', 'cid')).rejects.toThrow( + 'Real Soroban integration not yet implemented', + ); + }); + + it('attempts exactly maxRetries times before throwing', async () => { + const warnSpy = jest.spyOn((service as any).logger, 'warn').mockImplementation(() => {}); + + await expect(service.postGist('cell', 'cid')).rejects.toThrow(); + + // withRetry warns on every failed attempt (1/3, 2/3, 3/3) → 3 warnings + expect(warnSpy).toHaveBeenCalledTimes(3); + warnSpy.mockRestore(); + }); + }); + + describe('getGist() in real mode', () => { + it('throws after retries', async () => { + await expect(service.getGist('42')).rejects.toThrow( + 'Real Soroban integration not yet implemented', + ); + }); + }); + + describe('getEventsSince() in real mode', () => { + it('throws after retries', async () => { + await expect(service.getEventsSince(100)).rejects.toThrow( + 'Real Soroban getEvents not yet implemented', + ); + }); + }); + }); + + describe('retry with 1 attempt', () => { + beforeEach(async () => { + service = await buildService('CONTRACT_ID', 1); + }); + + it('postGist throws immediately without warning logs', async () => { + const warnSpy = jest.spyOn((service as any).logger, 'warn').mockImplementation(() => {}); + + await expect(service.postGist('cell', 'cid')).rejects.toThrow(); + + // withRetry warns on every failed attempt; with 1 attempt → 1 warning + expect(warnSpy).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); + }); + }); +});