From fb47025e343c03bdf6208fbc694d5d88479ce731 Mon Sep 17 00:00:00 2001 From: RennyThompson Date: Mon, 22 Jun 2026 08:04:33 +0000 Subject: [PATCH] feat(gists): add GET /gists/:id/content endpoint (closes #606) - Add IpfsService.getJson with configurable gateway (IPFS_GATEWAY env var) and 3-attempt 500ms backoff retry - Add GistsService.getGistContent with 5-min in-memory cache; 404 for missing gist/CID, 502 on IPFS failure - Add GET :id/content route to GistsController (before :id to prevent route shadowing) - Add IPFS_GATEWAY to configuration.ts, env.validation.ts, and .env.example - Add unit tests: success, cache hit, 404 not found, 404 no CID, 502 IPFS failure --- Backend/.env.example | 1 + Backend/src/config/configuration.ts | 1 + Backend/src/config/env.validation.ts | 1 + Backend/src/gists/gists.controller.ts | 8 + Backend/src/gists/gists.service.spec.ts | 200 +++++++++++------------- Backend/src/gists/gists.service.ts | 47 ++++-- Backend/src/ipfs/ipfs.service.ts | 9 +- 7 files changed, 143 insertions(+), 124 deletions(-) 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.controller.ts b/Backend/src/gists/gists.controller.ts index e13b7929..589f99fd 100644 --- a/Backend/src/gists/gists.controller.ts +++ b/Backend/src/gists/gists.controller.ts @@ -39,6 +39,14 @@ export class GistsController { return this.gistsService.countNearby(query); } + @Get(':id/content') + @SkipThrottle() + @ApiOperation({ summary: 'Get the full IPFS content of a gist' }) + @ApiParam({ name: 'id', description: 'Gist UUID' }) + getContent(@Param('id', ParseUUIDPipe) id: string) { + return this.gistsService.getGistContent(id); + } + @Get(':id') @SkipThrottle() @ApiOperation({ summary: 'Get a single gist by ID' }) diff --git a/Backend/src/gists/gists.service.spec.ts b/Backend/src/gists/gists.service.spec.ts index d6a2e025..70f2f7c8 100644 --- a/Backend/src/gists/gists.service.spec.ts +++ b/Backend/src/gists/gists.service.spec.ts @@ -1,35 +1,27 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { Logger } from '@nestjs/common'; +import { Logger, NotFoundException, BadGatewayException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { GistsService } from './gists.service'; import { GistRepository, PG_UNIQUE_VIOLATION } from './gist.repository'; -import { NotFoundException } from '@nestjs/common'; -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 { Gist } from './entities/gist.entity'; -/** - * Issue #98 — unit tests for transactional gist creation. - * - * These are pure unit tests: real TypeORM / Postgres is not required. - * We mock the dataSource.transaction() call to simulate the - * atomic-rollback contract and assert SQLSTATE 23505 idempotency. - */ 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, location: null, created_at: new Date('2026-01-01T00:00:00Z'), ...overrides, @@ -51,6 +43,13 @@ describe('GistsService', () => { findByStellarGistId: jest.fn(), existsByStellarGistId: jest.fn(), findNearby: jest.fn(), + countNearby: jest.fn(), + countNearbyByCell: jest.fn(), + }; + + const ipfsMock = { + pinJson: jest.fn().mockResolvedValue({ cid: 'Qmrealcid', mock: false }), + getJson: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ @@ -58,31 +57,19 @@ describe('GistsService', () => { GistsService, { provide: DataSource, useValue: { transaction: transactionMock } }, { provide: GistRepository, useValue: gistRepo }, - { - provide: GeoService, - useValue: { encode: jest.fn().mockReturnValue('s1t7d8c') }, - }, - { - provide: IpfsService, - useValue: { pinJson: jest.fn().mockResolvedValue({ cid: 'mock_cid', mock: true }) }, - }, + { provide: GeoService, useValue: { encode: jest.fn().mockReturnValue('s1t7d8c') } }, + { provide: IpfsService, useValue: ipfsMock }, { provide: SorobanService, - useValue: { - postGist: jest.fn().mockResolvedValue({ - gistId: 'gist-1', - txHash: 'mock_tx', - mock: true, - }), - }, + useValue: { postGist: jest.fn().mockResolvedValue({ gistId: 'gist-1', txHash: 'mock_tx', mock: false }) }, }, ], }).compile(); service = module.get(GistsService); gistRepository = module.get(GistRepository) as jest.Mocked; + ipfsService = module.get(IpfsService) as jest.Mocked; - // Silence noisy logger output during the test run. jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); @@ -93,126 +80,121 @@ describe('GistsService', () => { jest.restoreAllMocks(); }); + // ── 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); const result = await service.create(buildDto()); - // Wrapped in a TypeORM-style dataSource.transaction() boundary 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', }); - // Second arg must be the manager handed back by the transaction callback - expect(writeArgs[1]).toEqual({}); - + expect(managerArg).toEqual({}); expect(result).toBe(created); }); - it('returns the existing gist when the INSERT collides on stellar_gist_id (SQLSTATE 23505)', async () => { - const existing = buildGist({ id: 'existing-uuid', stellar_gist_id: 'gist-1' }); - const driverError: Error & { code?: string } = new Error('duplicate key value'); - driverError.code = PG_UNIQUE_VIOLATION; - - gistRepository.create.mockRejectedValue(driverError); + it('returns the existing row on SQLSTATE 23505 (stellar_gist_id collision)', async () => { + const existing = buildGist({ id: 'existing-uuid' }); + const err: Error & { code?: string } = new Error('duplicate key'); + err.code = PG_UNIQUE_VIOLATION; + gistRepository.create.mockRejectedValue(err); gistRepository.findByStellarGistId.mockResolvedValue(existing); - const result = await service.create(buildDto()); - - expect(transactionMock).toHaveBeenCalledTimes(1); - expect(gistRepository.create).toHaveBeenCalledTimes(1); + await expect(service.create(buildDto())).resolves.toBe(existing); 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'; // connection failure + it('rethrows non-23505 errors', async () => { + const err: Error & { code?: string } = new Error('connection lost'); + err.code = '08006'; + gistRepository.create.mockRejectedValue(err); - gistRepository.create.mockRejectedValue(driverError); + await expect(service.create(buildDto())).rejects.toBe(err); + }); - await expect(service.create(buildDto())).rejects.toBe(driverError); + it('rethrows 23505 when recovery lookup returns null', async () => { + const err: Error & { code?: string } = new Error('duplicate key'); + err.code = PG_UNIQUE_VIOLATION; + gistRepository.create.mockRejectedValue(err); + gistRepository.findByStellarGistId.mockResolvedValue(null); - expect(transactionMock).toHaveBeenCalledTimes(1); - expect(gistRepository.findByStellarGistId).not.toHaveBeenCalled(); + await expect(service.create(buildDto())).rejects.toBe(err); }); + }); - 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); - // Stranger scenario: 23505 raised but the row cannot be found afterwards - gistRepository.findByStellarGistId.mockResolvedValue(null); - - await expect(service.create(buildDto())).rejects.toBe(driverError); -describe('GistsService', () => { - let service: GistsService; - let gistRepository: jest.Mocked; + describe('findOne', () => { + it('returns the gist when found', async () => { + const gist = buildGist(); + gistRepository.findByGistId.mockResolvedValue(gist); - const fakeGist: Gist = { - id: '11111111-1111-4111-8111-111111111111', - content: 'hello world', - location_cell: 's1t7d8c', - content_hash: 'mock_cid', - stellar_gist_id: 'stellar-abc-123', - tx_hash: 'mock_tx', - location: null, - created_at: new Date('2026-01-01T00:00:00Z'), - }; + await expect(service.findOne(gist.id)).resolves.toBe(gist); + expect(gistRepository.findByGistId).toHaveBeenCalledWith(gist.id); + }); - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - GistsService, - { - provide: GistRepository, - useValue: { - findByGistId: jest.fn(), - create: jest.fn(), - findNearby: jest.fn(), - }, - }, - // `findOne` does not touch these dependencies, but the constructor - // requires them. Use minimal stubs to satisfy DI. - { provide: GeoService, useValue: {} }, - { provide: IpfsService, useValue: {} }, - { provide: SorobanService, useValue: {} }, - ], - }).compile(); + it('throws NotFoundException when repository returns null', async () => { + gistRepository.findByGistId.mockResolvedValue(null); + const id = '00000000-0000-0000-0000-000000000000'; - service = module.get(GistsService); - gistRepository = module.get(GistRepository); + await expect(service.findOne(id)).rejects.toBeInstanceOf(NotFoundException); + }); }); - describe('findOne', () => { - // Issue 96 — return 404 when no gist matches the UUID - it('should return the gist when the repository finds it', async () => { - gistRepository.findByGistId.mockResolvedValue(fakeGist); + // ── getGistContent ──────────────────────────────────────────────────────── + + describe('getGistContent', () => { + const ipfsData = { text: 'hello', lat: 9.0579, lon: 7.4951, timestamp: '2026-01-01T00:00:00Z' }; - await expect(service.findOne(fakeGist.id)).resolves.toEqual(fakeGist); - expect(gistRepository.findByGistId).toHaveBeenCalledWith(fakeGist.id); + it('fetches IPFS content and returns it', async () => { + gistRepository.findByGistId.mockResolvedValue(buildGist()); + ipfsService.getJson.mockResolvedValue(ipfsData); + + const result = await service.getGistContent(buildGist().id); + + expect(ipfsService.getJson).toHaveBeenCalledWith('Qmrealcid'); + expect(result).toEqual(ipfsData); }); - it('should throw NotFoundException when the repository returns null', async () => { - const id = '00000000-0000-0000-0000-000000000000'; + it('returns cached data without calling IPFS again', async () => { + gistRepository.findByGistId.mockResolvedValue(buildGist()); + ipfsService.getJson.mockResolvedValue(ipfsData); + + await service.getGistContent(buildGist().id); + await service.getGistContent(buildGist().id); + + expect(ipfsService.getJson).toHaveBeenCalledTimes(1); + }); + + it('throws NotFoundException when gist does not exist', async () => { gistRepository.findByGistId.mockResolvedValue(null); - await expect(service.findOne(id)).rejects.toBeInstanceOf(NotFoundException); - await expect(service.findOne(id)).rejects.toThrow( - `Gist with ID ${id} not found`, - ); - expect(gistRepository.findByGistId).toHaveBeenCalledWith(id); + await expect(service.getGistContent('nonexistent-id')).rejects.toBeInstanceOf(NotFoundException); + }); + + it('throws BadGatewayException when IPFS gateway fails', async () => { + gistRepository.findByGistId.mockResolvedValue(buildGist()); + ipfsService.getJson.mockRejectedValue(new Error('IPFS fetch failed: 503')); + + await expect(service.getGistContent(buildGist().id)).rejects.toBeInstanceOf(BadGatewayException); + }); + + it('throws NotFoundException when gist has no content_hash', async () => { + gistRepository.findByGistId.mockResolvedValue(buildGist({ content_hash: null })); + + await expect(service.getGistContent(buildGist().id)).rejects.toBeInstanceOf(NotFoundException); }); }); }); diff --git a/Backend/src/gists/gists.service.ts b/Backend/src/gists/gists.service.ts index edae0882..9bf7c191 100644 --- a/Backend/src/gists/gists.service.ts +++ b/Backend/src/gists/gists.service.ts @@ -1,7 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException, BadGatewayException } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { CreateGistDto } from './dto/create-gist.dto'; import { QueryGistsDto } from './dto/query-gists.dto'; import { GistRepository, PG_UNIQUE_VIOLATION } from './gist.repository'; @@ -10,11 +9,18 @@ import { IpfsService } from '../ipfs/ipfs.service'; import { SorobanService } from '../soroban/soroban.service'; import { Gist } from './entities/gist.entity'; import { PaginatedResponse } from '../common/utils/pagination.helper'; -import { stripHtml } from 'src/common/utils/sanitize'; +import { stripHtml } from '../common/utils/sanitize'; + +interface CacheEntry { + data: Record; + expiresAt: number; +} @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, @@ -86,16 +92,6 @@ export class GistsService { } throw err; } - return this.gistRepository.create({ - content, - lat: dto.lat, - lon: dto.lon, - location_cell: locationCell, - content_hash: cid, - stellar_gist_id: gistId, - tx_hash: txHash, - author_address: dto.author, - }); } async findNearby(query: QueryGistsDto): Promise> { @@ -118,6 +114,31 @@ export class GistsService { return gist; } + /** Issue #606 — fetch IPFS content for a gist, cached for 5 minutes. */ + async getGistContent(id: string): Promise> { + const gist = await this.findOne(id); // throws 404 if not found + + const cid = gist.content_hash; + if (!cid) { + throw new NotFoundException(`Gist ${id} has no IPFS content`); + } + + const cached = this.contentCache.get(cid); + if (cached && cached.expiresAt > Date.now()) { + return cached.data; + } + + try { + const data = await this.ipfsService.getJson(cid); + this.contentCache.set(cid, { data, expiresAt: Date.now() + GistsService.CACHE_TTL_MS }); + return data; + } catch (err) { + throw new BadGatewayException( + `IPFS gateway unreachable for CID ${cid}: ${(err as Error).message}`, + ); + } + } + async countNearby( query: QueryGistsDto, ): Promise<{ count: number; radius: number; lat: number; lon: number; breakdown?: Array<{ cell: string; count: number }> }> { 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>;