diff --git a/Backend/.env.example b/Backend/.env.example index 3ecf0a28..f22f8a48 100644 --- a/Backend/.env.example +++ b/Backend/.env.example @@ -17,6 +17,7 @@ CONTRACT_ID_GIST_REGISTRY= # IPFS (Pinata) — leave blank to use mock CIDs in dev PINATA_API_KEY= PINATA_SECRET_KEY= +IPFS_GATEWAY=https://gateway.pinata.cloud/ipfs # Optional: backend signing keypair for submitting txs to Soroban STELLAR_SECRET_KEY= diff --git a/Backend/src/config/configuration.ts b/Backend/src/config/configuration.ts index e2b43f35..ef0b8f79 100644 --- a/Backend/src/config/configuration.ts +++ b/Backend/src/config/configuration.ts @@ -21,5 +21,6 @@ export default () => ({ ipfs: { pinataApiKey: process.env.PINATA_API_KEY ?? '', pinataSecretKey: process.env.PINATA_SECRET_KEY ?? '', + gateway: process.env.IPFS_GATEWAY ?? 'https://gateway.pinata.cloud/ipfs', }, }); diff --git a/Backend/src/config/env.validation.ts b/Backend/src/config/env.validation.ts index 674bf108..8c11d26c 100644 --- a/Backend/src/config/env.validation.ts +++ b/Backend/src/config/env.validation.ts @@ -31,6 +31,7 @@ export const envValidationSchema = Joi.object({ // IPFS (Pinata) — optional; empty means mock CIDs in dev PINATA_API_KEY: Joi.string().allow('').default(''), PINATA_SECRET_KEY: Joi.string().allow('').default(''), + IPFS_GATEWAY: Joi.string().uri().default('https://gateway.pinata.cloud/ipfs'), // CORS CORS_ORIGINS: Joi.string().allow('').default(''), diff --git a/Backend/src/gists/gists.service.spec.ts b/Backend/src/gists/gists.service.spec.ts index ca7855a6..723aa3b4 100644 --- a/Backend/src/gists/gists.service.spec.ts +++ b/Backend/src/gists/gists.service.spec.ts @@ -21,13 +21,14 @@ jest.mock('../soroban/soroban.service', () => ({ describe('GistsService', () => { let service: GistsService; let gistRepository: jest.Mocked; + let ipfsService: jest.Mocked; let transactionMock: jest.Mock; const buildGist = (overrides: Partial = {}): Gist => ({ id: '00000000-0000-0000-0000-000000000001', content: 'hello', location_cell: 's1t7d8c', - content_hash: 'mock_cid', + content_hash: 'Qmrealcid', stellar_gist_id: 'gist-1', tx_hash: 'mock_tx', author_address: null, @@ -75,6 +76,7 @@ describe('GistsService', () => { service = module.get(GistsService); gistRepository = module.get(GistRepository) as jest.Mocked; + ipfsService = module.get(IpfsService) as jest.Mocked; jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); @@ -88,7 +90,7 @@ describe('GistsService', () => { // create // ────────────────────────────────────────────────────────────────────────── describe('create', () => { - it('sanitizes content, encodes the cell, pins IPFS, posts Soroban, and inserts in a transaction', async () => { + it('sanitizes, encodes, pins IPFS, posts Soroban, and inserts in a transaction', async () => { const created = buildGist(); gistRepository.create.mockResolvedValue(created); @@ -96,13 +98,13 @@ describe('GistsService', () => { expect(transactionMock).toHaveBeenCalledTimes(1); expect(gistRepository.create).toHaveBeenCalledTimes(1); - const writeArgs = gistRepository.create.mock.calls[0]; - expect(writeArgs[0]).toMatchObject({ + const [writeArg, managerArg] = gistRepository.create.mock.calls[0]; + expect(writeArg).toMatchObject({ content: 'hello', lat: 9.0579, lon: 7.4951, location_cell: 's1t7d8c', - content_hash: 'mock_cid', + content_hash: 'Qmrealcid', stellar_gist_id: 'gist-1', tx_hash: 'mock_tx', }); @@ -150,22 +152,21 @@ describe('GistsService', () => { const result = await service.create(buildDto()); expect(gistRepository.findByStellarGistId).toHaveBeenCalledWith('gist-1'); - expect(result).toBe(existing); }); it('throws when the INSERT fails with a non-23505 error', async () => { const driverError: Error & { code?: string } = new Error('connection lost'); driverError.code = '08006'; - gistRepository.create.mockRejectedValue(driverError); + await expect(service.create(buildDto())).rejects.toBe(err); + }); await expect(service.create(buildDto())).rejects.toBe(driverError); expect(gistRepository.findByStellarGistId).not.toHaveBeenCalled(); }); + }); - it('rethrows if the idempotent recovery lookup returns null', async () => { - const driverError: Error & { code?: string } = new Error('duplicate key value'); - driverError.code = PG_UNIQUE_VIOLATION; + // ── findOne ─────────────────────────────────────────────────────────────── gistRepository.create.mockRejectedValue(driverError); gistRepository.findByStellarGistId.mockResolvedValue(null); diff --git a/Backend/src/gists/gists.service.ts b/Backend/src/gists/gists.service.ts index f773b2b9..80f4a830 100644 --- a/Backend/src/gists/gists.service.ts +++ b/Backend/src/gists/gists.service.ts @@ -24,6 +24,8 @@ export interface CountNearbyResult { @Injectable() export class GistsService { private readonly logger = new Logger(GistsService.name); + private readonly contentCache = new Map(); + private static readonly CACHE_TTL_MS = 5 * 60 * 1000; constructor( @InjectDataSource() private readonly dataSource: DataSource, diff --git a/Backend/src/ipfs/ipfs.service.ts b/Backend/src/ipfs/ipfs.service.ts index 37cd1e4d..85b00095 100644 --- a/Backend/src/ipfs/ipfs.service.ts +++ b/Backend/src/ipfs/ipfs.service.ts @@ -22,7 +22,7 @@ async function withRetry( } catch (err) { lastError = err as Error; logger?.warn(`${label} attempt ${attempt}/${maxAttempts} failed: ${lastError.message}`); - if (attempt < maxAttempts) await sleep(200 * attempt); + if (attempt < maxAttempts) await sleep(500); } } throw lastError; @@ -33,6 +33,7 @@ export class IpfsService { private readonly logger = new Logger(IpfsService.name); private readonly devMode: boolean; private readonly maxRetries: number; + private readonly gateway: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any private pinata: any; @@ -40,6 +41,10 @@ export class IpfsService { const apiKey = this.config.get('PINATA_API_KEY'); const secretKey = this.config.get('PINATA_SECRET_KEY'); this.maxRetries = this.config.get('IPFS_RETRY_ATTEMPTS', 3); + this.gateway = this.config.get( + 'ipfs.gateway', + 'https://gateway.pinata.cloud/ipfs', + ); this.devMode = !apiKey || !secretKey; if (this.devMode) { @@ -74,7 +79,7 @@ export class IpfsService { return withRetry( async () => { - const url = `https://gateway.pinata.cloud/ipfs/${cid}`; + const url = `${this.gateway}/${cid}`; const res = await fetch(url); if (!res.ok) throw new Error(`IPFS fetch failed: ${res.status}`); return res.json() as Promise>;