diff --git a/Backend/src/gists/dto/create-gist.dto.ts b/Backend/src/gists/dto/create-gist.dto.ts index fbfda849..be2dc003 100644 --- a/Backend/src/gists/dto/create-gist.dto.ts +++ b/Backend/src/gists/dto/create-gist.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsLatitude, IsLongitude, IsString, MaxLength, IsOptional, IsInt, Min, Max } from 'class-validator'; +import { IsInt, IsLatitude, IsLongitude, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; export class CreateGistDto { @ApiProperty({ @@ -28,6 +28,15 @@ export class CreateGistDto { @MaxLength(80) author?: string; + @ApiPropertyOptional({ + description: 'Optional Stellar address alias for the author', + example: 'GABC...XYZ', + }) + @IsOptional() + @IsString() + @MaxLength(80) + authorAddress?: string; + @ApiPropertyOptional({ description: 'Time-to-live in hours (default: 24, max: 168)', example: 24, diff --git a/Backend/src/gists/gists.controller.ts b/Backend/src/gists/gists.controller.ts index 554e5a47..1728d107 100644 --- a/Backend/src/gists/gists.controller.ts +++ b/Backend/src/gists/gists.controller.ts @@ -1,17 +1,11 @@ -import { - Controller, - Get, - Post, - Body, - Param, - Query, - ParseUUIDPipe, -} from '@nestjs/common'; +import { Controller, Get, Post, Body, Param, Query, ParseUUIDPipe } from '@nestjs/common'; import { Throttle, SkipThrottle } from '@nestjs/throttler'; import { ApiOperation, ApiTags, ApiParam } from '@nestjs/swagger'; import { GistsService } from './gists.service'; import { CreateGistDto } from './dto/create-gist.dto'; import { QueryGistsDto } from './dto/query-gists.dto'; +import { Gist } from './entities/gist.entity'; +import { PaginatedResponse } from '../common/utils/pagination.helper'; @ApiTags('gists') @Controller({ path: 'gists', version: '1' }) @@ -21,15 +15,16 @@ export class GistsController { @Post() @Throttle({ default: { limit: 10, ttl: 60000 } }) @ApiOperation({ summary: 'Post a new anonymous gist at a location' }) - create(@Body() dto: CreateGistDto) { - return this.gistsService.create(dto); + async create(@Body() dto: CreateGistDto) { + return this.decorateGist(await this.gistsService.create(dto)); } @Get() @SkipThrottle() @ApiOperation({ summary: 'Find gists near a location' }) - findNearby(@Query() query: QueryGistsDto) { - return this.gistsService.findNearby(query); + async findNearby(@Query() query: QueryGistsDto) { + const response = await this.gistsService.findNearby(query); + return this.decoratePaginatedResponse(response); } // IMPORTANT: must be registered before @Get(':id') so NestJS does not @@ -41,11 +36,34 @@ export class GistsController { return this.gistsService.countNearby(query); } + @Get(':id/content') + @SkipThrottle() + @ApiOperation({ summary: 'Get the raw IPFS content for a gist' }) + @ApiParam({ name: 'id', description: 'Gist UUID' }) + findContent(@Param('id', ParseUUIDPipe) id: string) { + return this.gistsService.getContent(id); + } + @Get(':id') @SkipThrottle() @ApiOperation({ summary: 'Get a single gist by ID' }) @ApiParam({ name: 'id', description: 'Gist UUID' }) - findOne(@Param('id', ParseUUIDPipe) id: string) { - return this.gistsService.findOne(id); + async findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.decorateGist(await this.gistsService.findOne(id)); + } + + private decorateGist(gist: Gist) { + return { + ...gist, + gist_id: gist.stellar_gist_id, + content_cid: gist.content_hash, + }; + } + + private decoratePaginatedResponse(response: PaginatedResponse) { + return { + ...response, + data: response.data.map((gist) => this.decorateGist(gist)), + }; } } diff --git a/Backend/src/gists/gists.service.ts b/Backend/src/gists/gists.service.ts index 763659b3..f773b2b9 100644 --- a/Backend/src/gists/gists.service.ts +++ b/Backend/src/gists/gists.service.ts @@ -45,7 +45,8 @@ export class GistsService { created_at: new Date().toISOString(), }); - const { gistId, txHash } = await this.sorobanService.postGist(locationCell, cid, dto.author); + const author = dto.authorAddress ?? dto.author; + const { gistId, txHash } = await this.sorobanService.postGist(locationCell, cid, author); this.logger.log(`Gist posted → cell=${locationCell} cid=${cid} gistId=${gistId}`); @@ -63,7 +64,7 @@ export class GistsService { content_hash: cid, stellar_gist_id: gistId, tx_hash: txHash, - author_address: dto.author, + author_address: author, expires_at: expiresAt, }, manager, @@ -101,6 +102,19 @@ export class GistsService { return gist; } + async getContent(id: string): Promise> { + const gist = await this.gistRepository.findByGistId(id); + if (!gist) { + throw new NotFoundException(`Gist with ID ${id} not found`); + } + + if (!gist.content_hash) { + throw new NotFoundException(`Content for gist ${id} not found`); + } + + return this.ipfsService.getJson(gist.content_hash); + } + async countNearby(query: QueryGistsDto): Promise { const { lat, lon, radius = 500, breakdown } = query; diff --git a/Backend/test/app.e2e-spec.ts b/Backend/test/app.e2e-spec.ts index 67ea4ae2..88d9430a 100644 --- a/Backend/test/app.e2e-spec.ts +++ b/Backend/test/app.e2e-spec.ts @@ -1,7 +1,8 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; +import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common'; import * as request from 'supertest'; import { AppModule } from '../src/app.module'; +import { AllExceptionsFilter } from '../src/common/filters/all-exceptions.filter'; describe('AppModule (e2e)', () => { let app: INestApplication; @@ -12,6 +13,15 @@ describe('AppModule (e2e)', () => { }).compile(); app = moduleFixture.createNestApplication(); + app.enableVersioning({ type: VersioningType.URI }); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + }), + ); + app.useGlobalFilters(new AllExceptionsFilter()); await app.init(); }); @@ -20,6 +30,6 @@ describe('AppModule (e2e)', () => { }); it('/health (GET)', () => { - return request(app.getHttpServer()).get('/health').expect(200); + return request(app.getHttpServer()).get('/v1/health').expect(200); }); }); diff --git a/Backend/test/gists.e2e-spec.ts b/Backend/test/gists.e2e-spec.ts new file mode 100644 index 00000000..54755468 --- /dev/null +++ b/Backend/test/gists.e2e-spec.ts @@ -0,0 +1,371 @@ +import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AllExceptionsFilter } from '../src/common/filters/all-exceptions.filter'; +import { AppModule } from '../src/app.module'; +import { DataSource } from 'typeorm'; +import * as request from 'supertest'; +import { GistRepository } from '../src/gists/gist.repository'; +import { Gist } from '../src/gists/entities/gist.entity'; +import { IpfsService } from '../src/ipfs/ipfs.service'; +import { SorobanService } from '../src/soroban/soroban.service'; +import { createIpfsServiceMock } from './mocks/ipfs.service.mock'; +import { createSorobanServiceMock } from './mocks/soroban.service.mock'; +import { prepareTestDatabase, truncateGistsTable } from './setup'; + +const API_ROOT = '/v1/gists'; +const HEALTH_ROOT = '/v1/health'; + +jest.setTimeout(30000); + +describe('Gists lifecycle (e2e)', () => { + let app: INestApplication; + let moduleFixture: TestingModule; + let dataSource: DataSource; + let gistRepository: GistRepository; + let ipfsMock = createIpfsServiceMock(); + let sorobanMock = createSorobanServiceMock(); + + const seedGist = async (overrides: Partial & { lat: number; lon: number }) => { + return gistRepository.create({ + content: overrides.content ?? 'seeded gist', + lat: overrides.lat, + lon: overrides.lon, + location_cell: overrides.location_cell ?? 'seed_cell', + content_hash: overrides.content_hash ?? `mock_cid_${Math.random().toString(36).slice(2)}`, + stellar_gist_id: overrides.stellar_gist_id ?? `seed-gist-${Date.now()}-${Math.random()}`, + tx_hash: overrides.tx_hash ?? `mock_tx_${Date.now()}`, + author_address: overrides.author_address ?? null, + expires_at: overrides.expires_at, + }); + }; + + beforeAll(async () => { + moduleFixture = await Test.createTestingModule({ + imports: [AppModule], + }) + .overrideProvider(IpfsService) + .useValue(ipfsMock) + .overrideProvider(SorobanService) + .useValue(sorobanMock) + .compile(); + + app = moduleFixture.createNestApplication(); + app.enableVersioning({ type: VersioningType.URI }); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + }), + ); + app.useGlobalFilters(new AllExceptionsFilter()); + + await app.init(); + + dataSource = moduleFixture.get(DataSource); + gistRepository = moduleFixture.get(GistRepository); + await prepareTestDatabase(dataSource); + }); + + afterEach(async () => { + ipfsMock.reset(); + sorobanMock.reset(); + await truncateGistsTable(dataSource); + }); + + afterAll(async () => { + await truncateGistsTable(dataSource); + await app.close(); + }); + + describe('POST /gists', () => { + it('creates a gist and returns the API aliases', async () => { + const res = await request(app.getHttpServer()) + .post(API_ROOT) + .send({ + content: 'great spot', + lat: 9.0579, + lon: 7.4951, + authorAddress: 'GAUTHOR123', + }) + .expect(201); + + expect(res.body).toMatchObject({ + id: expect.any(String), + gist_id: '1', + tx_hash: expect.stringMatching(/^mock_tx_/), + content_cid: expect.stringMatching(/^mock_cid_/), + content: 'great spot', + author_address: 'GAUTHOR123', + }); + expect(res.body.content_hash).toBe(res.body.content_cid); + expect(res.body.stellar_gist_id).toBe(res.body.gist_id); + }); + + it('rejects missing latitude', async () => { + const res = await request(app.getHttpServer()) + .post(API_ROOT) + .send({ + content: 'missing lat', + lon: 7.4951, + }) + .expect(400); + + expect(res.body.statusCode).toBe(400); + }); + + it('rejects invalid authorAddress', async () => { + const res = await request(app.getHttpServer()) + .post(API_ROOT) + .send({ + content: 'bad author', + lat: 9.0579, + lon: 7.4951, + authorAddress: 42, + }) + .expect(400); + + expect(res.body.statusCode).toBe(400); + }); + + it('rejects content longer than 280 characters', async () => { + const res = await request(app.getHttpServer()) + .post(API_ROOT) + .send({ + content: 'x'.repeat(281), + lat: 9.0579, + lon: 7.4951, + }) + .expect(400); + + expect(res.body.statusCode).toBe(400); + }); + }); + + describe('GET /gists', () => { + it('returns gists within radius and orders them by distance', async () => { + await seedGist({ + content: 'far', + lat: 9.08, + lon: 7.52, + location_cell: 'far', + content_hash: 'mock_cid_far', + stellar_gist_id: 'g-far', + }); + await seedGist({ + content: 'mid', + lat: 9.066, + lon: 7.505, + location_cell: 'mid', + content_hash: 'mock_cid_mid', + stellar_gist_id: 'g-mid', + }); + await seedGist({ + content: 'near', + lat: 9.0579, + lon: 7.4951, + location_cell: 'near', + content_hash: 'mock_cid_near', + stellar_gist_id: 'g-near', + }); + await seedGist({ + content: 'outside', + lat: 9.5, + lon: 8.1, + location_cell: 'outside', + content_hash: 'mock_cid_outside', + stellar_gist_id: 'g-outside', + }); + await seedGist({ + content: 'expired', + lat: 9.058, + lon: 7.4952, + location_cell: 'expired', + content_hash: 'mock_cid_expired', + stellar_gist_id: 'g-expired', + expires_at: new Date(Date.now() - 60_000), + }); + + const res = await request(app.getHttpServer()) + .get(API_ROOT) + .query({ lat: 9.0579, lon: 7.4951, radius: 5000, limit: 10 }) + .expect(200); + + const contents = res.body.data.map((row: { content: string }) => row.content); + expect(contents).toEqual(['near', 'mid', 'far']); + expect(res.body.data[0].distance_meters).toBeLessThanOrEqual(res.body.data[1].distance_meters); + expect(res.body.data[1].distance_meters).toBeLessThanOrEqual(res.body.data[2].distance_meters); + expect(contents).not.toContain('outside'); + expect(contents).not.toContain('expired'); + }); + + it('paginates with a next page cursor', async () => { + await seedGist({ + content: 'far-page', + lat: 9.08, + lon: 7.52, + location_cell: 'far-page', + content_hash: 'mock_cid_far_page', + stellar_gist_id: 'g-far-page', + }); + await seedGist({ + content: 'mid-page', + lat: 9.066, + lon: 7.505, + location_cell: 'mid-page', + content_hash: 'mock_cid_mid_page', + stellar_gist_id: 'g-mid-page', + }); + await seedGist({ + content: 'near-page', + lat: 9.0579, + lon: 7.4951, + location_cell: 'near-page', + content_hash: 'mock_cid_near_page', + stellar_gist_id: 'g-near-page', + }); + + const page1 = await request(app.getHttpServer()) + .get(API_ROOT) + .query({ lat: 9.0579, lon: 7.4951, radius: 5000, limit: 1 }) + .expect(200); + + expect(page1.body.pagination.hasMore).toBe(true); + expect(page1.body.pagination.cursor).toEqual(expect.any(String)); + + const page2 = await request(app.getHttpServer()) + .get(API_ROOT) + .query({ + lat: 9.0579, + lon: 7.4951, + radius: 5000, + limit: 1, + cursor: page1.body.pagination.cursor, + }) + .expect(200); + + expect(page2.body.data[0].content).not.toBe(page1.body.data[0].content); + }); + }); + + describe('GET /gists/:id', () => { + it('returns the gist by UUID', async () => { + const created = await request(app.getHttpServer()) + .post(API_ROOT) + .send({ content: 'lookup me', lat: 9.0579, lon: 7.4951 }) + .expect(201); + + const res = await request(app.getHttpServer()).get(`${API_ROOT}/${created.body.id}`).expect(200); + + expect(res.body).toMatchObject({ + id: created.body.id, + gist_id: created.body.gist_id, + content_cid: created.body.content_cid, + content_hash: created.body.content_hash, + tx_hash: created.body.tx_hash, + }); + }); + + it('returns 404 for an unknown UUID', async () => { + const res = await request(app.getHttpServer()) + .get(`${API_ROOT}/00000000-0000-0000-0000-000000000000`) + .expect(404); + + expect(res.body.statusCode).toBe(404); + }); + + it('returns 404 for an expired gist', async () => { + const expired = await seedGist({ + content: 'expired-by-id', + lat: 9.0579, + lon: 7.4951, + location_cell: 'expired-by-id', + content_hash: 'mock_cid_expired_by_id', + stellar_gist_id: 'g-expired-by-id', + expires_at: new Date(Date.now() - 60_000), + }); + + const res = await request(app.getHttpServer()).get(`${API_ROOT}/${expired.id}`).expect(404); + expect(res.body.statusCode).toBe(404); + }); + }); + + describe('GET /gists/:id/content', () => { + it('returns IPFS content for a gist', async () => { + const created = await request(app.getHttpServer()) + .post(API_ROOT) + .send({ + content: 'content route', + lat: 9.0579, + lon: 7.4951, + authorAddress: 'GAUTHCONTENT', + }) + .expect(201); + + const res = await request(app.getHttpServer()) + .get(`${API_ROOT}/${created.body.id}/content`) + .expect(200); + + expect(res.body).toMatchObject({ + content: 'content route', + lat: 9.0579, + lon: 7.4951, + location_cell: expect.any(String), + }); + }); + + it('returns 404 for an unknown gist', async () => { + await request(app.getHttpServer()) + .get(`${API_ROOT}/00000000-0000-0000-0000-000000000000/content`) + .expect(404); + }); + }); + + describe('GET /gists/count', () => { + it('returns the correct count excluding expired gists', async () => { + await seedGist({ + content: 'count-near', + lat: 9.0579, + lon: 7.4951, + location_cell: 'count-near', + content_hash: 'mock_cid_count_near', + stellar_gist_id: 'g-count-near', + }); + await seedGist({ + content: 'count-far', + lat: 9.08, + lon: 7.52, + location_cell: 'count-far', + content_hash: 'mock_cid_count_far', + stellar_gist_id: 'g-count-far', + }); + await seedGist({ + content: 'count-expired', + lat: 9.0578, + lon: 7.4952, + location_cell: 'count-expired', + content_hash: 'mock_cid_count_expired', + stellar_gist_id: 'g-count-expired', + expires_at: new Date(Date.now() - 60_000), + }); + + const res = await request(app.getHttpServer()) + .get(`${API_ROOT}/count`) + .query({ lat: 9.0579, lon: 7.4951, radius: 5000 }) + .expect(200); + + expect(res.body.count).toBe(2); + expect(res.body.breakdown).toBeUndefined(); + }); + }); + + describe('GET /health', () => { + it('returns status ok', async () => { + const res = await request(app.getHttpServer()).get(HEALTH_ROOT).expect(200); + + expect(res.body.status).toBe('ok'); + expect(res.body.services.database.status).toBe('ok'); + expect(res.body.services.postgis.status).toBe('ok'); + }); + }); +}); diff --git a/Backend/test/jest-e2e.json b/Backend/test/jest-e2e.json index 5d4c7c0f..cd58a10f 100644 --- a/Backend/test/jest-e2e.json +++ b/Backend/test/jest-e2e.json @@ -2,7 +2,8 @@ "moduleFileExtensions": ["js", "json", "ts"], "rootDir": ".", "testEnvironment": "node", - "testRegex": "\\.(e2e-spec|e2e\\.spec)\\.ts$", + "setupFiles": ["/setup.ts"], + "testRegex": ".*\\.e2e-spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, diff --git a/Backend/test/mocks/ipfs.service.mock.ts b/Backend/test/mocks/ipfs.service.mock.ts new file mode 100644 index 00000000..87e4e532 --- /dev/null +++ b/Backend/test/mocks/ipfs.service.mock.ts @@ -0,0 +1,33 @@ +type JsonRecord = Record; + +export interface IpfsServiceMock { + pinJson: jest.Mock, [JsonRecord]>; + getJson: jest.Mock, [string]>; + seedJson: (cid: string, payload: JsonRecord) => void; + reset: () => void; +} + +export function createIpfsServiceMock(): IpfsServiceMock { + const contentByCid = new Map(); + let nextCid = 1; + + return { + pinJson: jest.fn(async (payload: JsonRecord) => { + const cid = `mock_cid_${String(nextCid++).padStart(4, '0')}`; + contentByCid.set(cid, payload); + return { cid, mock: true as const }; + }), + getJson: jest.fn(async (cid: string) => { + return contentByCid.get(cid) ?? { cid, mock: true }; + }), + seedJson(cid: string, payload: JsonRecord) { + contentByCid.set(cid, payload); + }, + reset() { + contentByCid.clear(); + nextCid = 1; + this.pinJson.mockClear(); + this.getJson.mockClear(); + }, + }; +} diff --git a/Backend/test/mocks/soroban.service.mock.ts b/Backend/test/mocks/soroban.service.mock.ts new file mode 100644 index 00000000..ec7f5c35 --- /dev/null +++ b/Backend/test/mocks/soroban.service.mock.ts @@ -0,0 +1,39 @@ +export interface SorobanServiceMock { + postGist: jest.Mock, [string, string, string | undefined]>; + getGist: jest.Mock< + Promise<{ gistId: string; locationCell: string; contentHash: string; createdAt: number; mock: true }>, + [string] + >; + getEventsSince: jest.Mock, [number]>; + reset: () => void; +} + +export function createSorobanServiceMock(): SorobanServiceMock { + let nextGistId = 1; + let nextTx = 1; + + return { + postGist: jest.fn, [string, string, string | undefined]>( + async () => { + const gistId = String(nextGistId++); + const txHash = `mock_tx_${String(nextTx++).padStart(4, '0')}`; + return { gistId, txHash, mock: true as const }; + }, + ), + getGist: jest.fn(async (gistId: string) => ({ + gistId, + locationCell: 'mock_cell', + contentHash: `mock_cid_${gistId}`, + createdAt: Date.now(), + mock: true as const, + })), + getEventsSince: jest.fn, [number]>(async (_sinceBlock: number) => [] as never[]), + reset() { + nextGistId = 1; + nextTx = 1; + this.postGist.mockClear(); + this.getGist.mockClear(); + this.getEventsSince.mockClear(); + }, + }; +} diff --git a/Backend/test/setup.ts b/Backend/test/setup.ts new file mode 100644 index 00000000..9ce26495 --- /dev/null +++ b/Backend/test/setup.ts @@ -0,0 +1,35 @@ +import { DataSource } from 'typeorm'; + +const defaults: Record = { + NODE_ENV: 'test', + PORT: '3000', + DATABASE_HOST: 'localhost', + DATABASE_PORT: '5432', + DATABASE_USER: 'gist', + DATABASE_PASSWORD: 'gist', + DATABASE_NAME: 'gist_test', + DB_POOL_MAX: '5', + DB_POOL_MIN: '1', + SOROBAN_RPC_URL: 'https://soroban-testnet.stellar.org', + STELLAR_NETWORK_PASSPHRASE: 'Test SDF Network ; September 2015', + CONTRACT_ID_GIST_REGISTRY: '', + STELLAR_SECRET_KEY: '', + PINATA_API_KEY: '', + PINATA_SECRET_KEY: '', + CORS_ORIGINS: '', +}; + +for (const [key, value] of Object.entries(defaults)) { + if (!process.env[key]) { + process.env[key] = value; + } +} + +export async function prepareTestDatabase(dataSource: DataSource): Promise { + await dataSource.query('CREATE EXTENSION IF NOT EXISTS postgis'); + await dataSource.runMigrations(); +} + +export async function truncateGistsTable(dataSource: DataSource): Promise { + await dataSource.query('TRUNCATE TABLE gists RESTART IDENTITY CASCADE'); +}