diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index d1410738..66ee795f 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -40,6 +40,11 @@ jobs: env: NODE_ENV: test + - name: Run integration tests + run: npx jest --testRegex=".*\\.integration-spec\\.ts$" --testPathPattern="contract-indexer" + env: + NODE_ENV: test + - name: Upload coverage report uses: actions/upload-artifact@v4 if: always() diff --git a/Backend/README.md b/Backend/README.md index cd314361..c262310c 100644 --- a/Backend/README.md +++ b/Backend/README.md @@ -262,6 +262,7 @@ Table: `gists` | `npm run start:prod` | Run compiled output | | `npm test` | Unit tests | | `npm run test:e2e` | End-to-end tests | +| `npm run test:integration` | Integration tests (including contract-indexer end-to-end flow) | --- diff --git a/Backend/src/indexer/contract-indexer.integration-spec.ts b/Backend/src/indexer/contract-indexer.integration-spec.ts new file mode 100644 index 00000000..c43f6397 --- /dev/null +++ b/Backend/src/indexer/contract-indexer.integration-spec.ts @@ -0,0 +1,153 @@ +import { IndexerService } from './indexer.service'; +import { SorobanService } from '../soroban/soroban.service'; +import { GistRepository } from '../gists/gist.repository'; +import { GeoService } from '../geo/geo.service'; +import { IndexerState } from './indexer-state.entity'; +import { Repository } from 'typeorm'; + +describe('Contract-Indexer Integration Flow (integration)', () => { + let indexerService: IndexerService; + let sorobanService: jest.Mocked; + let gistRepository: jest.Mocked; + let geoService: jest.Mocked; + let indexerStateRepo: jest.Mocked>; + + const store = new Map(); + + beforeEach(() => { + store.clear(); + + sorobanService = { + getEventsSince: jest.fn(), + getLatestLedger: jest.fn().mockResolvedValue(0), + } as unknown as jest.Mocked; + + gistRepository = { + findByStellarGistId: jest.fn(), + existsByStellarGistId: jest.fn(), + create: jest.fn(async (data: any) => { + const item = { + id: `uuid-${data.stellar_gist_id}`, + content: data.content, + location_cell: data.location_cell, + content_hash: data.content_hash, + stellar_gist_id: data.stellar_gist_id, + tx_hash: data.tx_hash, + author_address: data.author_address ?? null, + created_at: new Date(), + expires_at: data.expires_at ?? new Date(Date.now() + 86400000), + hidden: false, + report_count: 0, + is_active: true, + }; + store.set(data.stellar_gist_id, item); + return item; + }), + updateContentHash: jest.fn(async (id: string, hash: string) => { + const item = store.get(id); + if (item) { + item.content_hash = hash; + return true; + } + return false; + }), + setGistActive: jest.fn(async (id: string, active: boolean) => { + const item = store.get(id); + if (item) { + item.is_active = active; + return true; + } + return false; + }), + setGistHidden: jest.fn(async (id: string, hidden: boolean) => { + const item = store.get(id); + if (item) { + item.hidden = hidden; + return true; + } + return false; + }), + updateReportCount: jest.fn(async (id: string, count: number) => { + const item = store.get(id); + if (item) { + item.report_count = count; + return true; + } + return false; + }), + } as unknown as jest.Mocked; + + geoService = { + decode: jest.fn().mockReturnValue({ lat: 6.5244, lon: 3.3792 }), + } as unknown as jest.Mocked; + + indexerStateRepo = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + } as unknown as jest.Mocked>; + + indexerStateRepo.findOne.mockResolvedValue(null as never); + indexerStateRepo.create.mockImplementation((state: Partial) => state as any); + indexerStateRepo.save.mockImplementation(async (state: Partial) => state as any); + + indexerService = new IndexerService( + sorobanService, + gistRepository, + geoService, + indexerStateRepo as any, + ); + }); + + it('indexes a gist_posted event into the store', async () => { + const stellarGistId = 'gist-1042'; + + gistRepository.findByStellarGistId.mockResolvedValue(null); + sorobanService.getEventsSince.mockImplementation(async () => [ + { + type: 'gist_posted', + ledger: 100, + gist: { + gistId: stellarGistId, + locationCell: 's1t7d8c', + contentHash: 'QmInitialCid', + author: null, + createdAt: 1700000000, + expiresAt: 1700086400, + hidden: false, + }, + }, + ]); + + await indexerService.poll(); + expect(gistRepository.create).toHaveBeenCalledTimes(1); + expect(store.get(stellarGistId)).toMatchObject({ + content_hash: 'QmInitialCid', + is_active: true, + }); + }); + + it('skips an already-indexed gist', async () => { + const stellarGistId = 'gist-1042'; + + gistRepository.existsByStellarGistId.mockResolvedValue(true); + sorobanService.getEventsSince.mockImplementation(async () => [ + { + type: 'gist_posted', + ledger: 100, + gist: { + gistId: stellarGistId, + locationCell: 's1t7d8c', + contentHash: 'QmDuplicate', + author: null, + createdAt: 1700000000, + expiresAt: 1700086400, + hidden: false, + }, + }, + ]); + + await indexerService.poll(); + expect(gistRepository.create).not.toHaveBeenCalled(); + }); +});