Repository navigation
test(backend): add Testcontainers integration tests for the indexer p… #229
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
amankoli09
merged 6 commits into
LynxXProtocol:main
from
Mac-5:feature/indexer-testcontainers-integration-tests
Aug 26, 2026
Merged
Changes from 1 commit
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
c670784
test(backend): add Testcontainers integration tests for the indexer p…
Mac-5 0095f31
fix(backend): clean up container/pool if migration setup fails
Mac-5 c429463
fix(backend): run pool/container cleanup independently on setup failure
Mac-5 48b3c23
fix(contracts): register fraud_verifier as a workspace member
Mac-5 129c403
fix(frontend): remove duplicate wallet-connect step and dead switch c…
Mac-5 e8d01c2
Merge branch 'main' into feature/indexer-testcontainers-integration-t…
Mac-5 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,182 @@ | ||
| import assert from 'node:assert/strict'; | ||
| import { after, afterEach, before, describe, it } from 'node:test'; | ||
| import { Pool } from 'pg'; | ||
| import { createCommitmentIndexingHook, FinalityIndexer } from '../../src/indexer/listener'; | ||
| import { PostgresCommitmentIndex } from '../../src/indexer/commitments'; | ||
| import { PostgresIndexerStore } from '../../src/indexer/store'; | ||
| import { LedgerSnapshot, LedgerSource } from '../../src/indexer/types'; | ||
| import { IntegrationDatabase, startIntegrationDatabase, stopIntegrationDatabase } from '../setup'; | ||
|
|
||
| // The SDK ships as an ES module while the backend compiles to CommonJS, so it | ||
| // is imported lazily (mirrors src/indexer/commitments.test.ts). | ||
| const sdk = () => import('@stellar/stellar-sdk'); | ||
|
|
||
| // Deterministic, checksum-valid G-addresses -- nativeToScVal({type:'address'}) | ||
| // validates the strkey, so a fabricated "G..." string would be rejected. | ||
| async function address(fill: number): Promise<string> { | ||
| const { StrKey } = await sdk(); | ||
| return StrKey.encodeEd25519PublicKey(Buffer.alloc(32, fill)); | ||
| } | ||
|
|
||
| /** | ||
| * Builds a `commitment_created` contract event exactly as the on-chain | ||
| * registry emits it and the indexer decodes it: topics (symbol "created", | ||
| * issuer, counterparty, oracle) and (id, schema_id) as the base64 XDR value. | ||
| * See contracts/registry/src/events.rs and src/indexer/events.ts. | ||
| */ | ||
| async function commitmentCreatedEvent( | ||
| id: number, | ||
| issuer: string, | ||
| counterparty: string, | ||
| ): Promise<LedgerSnapshot['events'][number]> { | ||
| const { nativeToScVal, xdr } = await sdk(); | ||
| return { | ||
| id: `event-${id}`, | ||
| type: 'contract', | ||
| payload: { | ||
| topic: [ | ||
| nativeToScVal('created', { type: 'symbol' }).toXDR('base64'), | ||
| nativeToScVal(issuer, { type: 'address' }).toXDR('base64'), | ||
| nativeToScVal(counterparty, { type: 'address' }).toXDR('base64'), | ||
| xdr.ScVal.scvVoid().toXDR('base64'), // oracle: Option<Address> = None | ||
| ], | ||
| value: xdr.ScVal.scvVec([nativeToScVal(id, { type: 'u64' }), xdr.ScVal.scvVoid()]).toXDR( | ||
| 'base64', | ||
| ), | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| function ledgerOf(sequence: number, events: LedgerSnapshot['events']): LedgerSnapshot { | ||
| return { | ||
| sequence, | ||
| hash: `ledger-${sequence}`, | ||
| previousHash: sequence === 1 ? null : `ledger-${sequence - 1}`, | ||
| closedAt: new Date(sequence * 1000).toISOString(), | ||
| events, | ||
| }; | ||
| } | ||
|
|
||
| class FixedLedgerSource implements LedgerSource { | ||
| constructor(private readonly ledgers: LedgerSnapshot[]) {} | ||
|
|
||
| async getLatestLedger(): Promise<{ sequence: number }> { | ||
| return { sequence: this.ledgers[this.ledgers.length - 1].sequence }; | ||
| } | ||
|
|
||
| async getLedger(sequence: number): Promise<LedgerSnapshot | null> { | ||
| return this.ledgers.find((ledger) => ledger.sequence === sequence) ?? null; | ||
| } | ||
| } | ||
|
|
||
| describe('indexer integration: mock Soroban events -> FinalityIndexer -> PostgreSQL', () => { | ||
| let db: IntegrationDatabase; | ||
| let pool: Pool; | ||
|
|
||
| before(async () => { | ||
| db = await startIntegrationDatabase(); | ||
| pool = db.pool; | ||
| }); | ||
|
|
||
| after(async () => { | ||
| await stopIntegrationDatabase(db); | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| await pool.query('TRUNCATE TABLE indexed_ledgers, indexer_checkpoint, commitment_index'); | ||
| }); | ||
|
|
||
| it('indexes a mock commitment_created Soroban event end-to-end into Postgres', async () => { | ||
| const [issuer, counterparty] = await Promise.all([address(1), address(2)]); | ||
| const event = await commitmentCreatedEvent(42, issuer, counterparty); | ||
| const source = new FixedLedgerSource([ledgerOf(1, [event])]); | ||
|
|
||
| const store = new PostgresIndexerStore(pool); | ||
| const commitmentIndex = new PostgresCommitmentIndex(pool); | ||
| const indexer = new FinalityIndexer({ | ||
| source, | ||
| store, | ||
| finalityDepth: 0, | ||
| onLedgerCommitted: createCommitmentIndexingHook(commitmentIndex), | ||
| }); | ||
|
|
||
| const result = await indexer.sync(); | ||
|
|
||
| assert.equal(result.committed, 1); | ||
| assert.deepEqual(result.checkpoint, { sequence: 1, hash: 'ledger-1' }); | ||
|
|
||
| // Verified directly against the table, not just through the store | ||
| // abstraction, so the assertion proves the row actually landed in Postgres. | ||
| const ledgerRows = await pool.query( | ||
| 'SELECT sequence, ledger_hash FROM indexed_ledgers ORDER BY sequence', | ||
| ); | ||
| assert.deepEqual( | ||
| ledgerRows.rows.map((row) => [Number(row.sequence), row.ledger_hash]), | ||
| [[1, 'ledger-1']], | ||
| ); | ||
|
|
||
| const forIssuer = await commitmentIndex.findByAddress(issuer); | ||
| assert.equal(forIssuer.total, 1); | ||
| assert.equal(forIssuer.items[0].commitmentId, '42'); | ||
| assert.equal(forIssuer.items[0].issuer, issuer); | ||
| assert.equal(forIssuer.items[0].counterparty, counterparty); | ||
| assert.equal(forIssuer.items[0].ledgerSequence, 1); | ||
|
|
||
| const forCounterparty = await commitmentIndex.findByAddress(counterparty); | ||
| assert.equal(forCounterparty.total, 1); | ||
| assert.equal(forCounterparty.items[0].commitmentId, '42'); | ||
| }); | ||
|
|
||
| it('indexes multiple ledgers of mock events and advances the checkpoint across them', async () => { | ||
| const [a, b, c] = await Promise.all([address(3), address(4), address(5)]); | ||
| const source = new FixedLedgerSource([ | ||
| ledgerOf(1, [await commitmentCreatedEvent(1, a, b)]), | ||
| ledgerOf(2, [await commitmentCreatedEvent(2, b, c)]), | ||
| ]); | ||
|
|
||
| const store = new PostgresIndexerStore(pool); | ||
| const commitmentIndex = new PostgresCommitmentIndex(pool); | ||
| const indexer = new FinalityIndexer({ | ||
| source, | ||
| store, | ||
| finalityDepth: 0, | ||
| onLedgerCommitted: createCommitmentIndexingHook(commitmentIndex), | ||
| }); | ||
|
|
||
| const result = await indexer.sync(); | ||
|
|
||
| assert.equal(result.committed, 2); | ||
| assert.deepEqual(await store.getCheckpoint(), { sequence: 2, hash: 'ledger-2' }); | ||
|
|
||
| // b is party to both commitments -- proves the reverse index accumulates | ||
| // across ledgers rather than only keeping the latest one. | ||
| const forB = await commitmentIndex.findByAddress(b); | ||
| assert.equal(forB.total, 2); | ||
| assert.deepEqual(forB.items.map((item) => item.commitmentId).sort(), ['1', '2']); | ||
| }); | ||
|
|
||
| it('leaves the checkpoint and commitment index untouched when a ledger has no decodable commitment events', async () => { | ||
| const source = new FixedLedgerSource([ | ||
| ledgerOf(1, [{ id: 'noise-1', type: 'contract', payload: { topic: [], value: null } }]), | ||
| ]); | ||
|
|
||
| const store = new PostgresIndexerStore(pool); | ||
| const commitmentIndex = new PostgresCommitmentIndex(pool); | ||
| const indexer = new FinalityIndexer({ | ||
| source, | ||
| store, | ||
| finalityDepth: 0, | ||
| onLedgerCommitted: createCommitmentIndexingHook(commitmentIndex), | ||
| }); | ||
|
|
||
| const result = await indexer.sync(); | ||
|
|
||
| // The ledger is still canonical and gets committed to indexed_ledgers / | ||
| // the checkpoint -- only the derived commitment_index stays empty. | ||
| assert.equal(result.committed, 1); | ||
| assert.deepEqual(await store.getCheckpoint(), { sequence: 1, hash: 'ledger-1' }); | ||
|
|
||
| const rows = await pool.query('SELECT COUNT(*)::int AS count FROM commitment_index'); | ||
| assert.equal(rows.rows[0].count, 0); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import { readFile } from 'node:fs/promises'; | ||
| import path from 'node:path'; | ||
| import { Pool } from 'pg'; | ||
| import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql'; | ||
|
|
||
| export interface IntegrationDatabase { | ||
| container: StartedPostgreSqlContainer; | ||
| pool: Pool; | ||
| } | ||
|
|
||
| // Only the tables the indexer pipeline actually touches (indexed_ledgers, | ||
| // indexer_checkpoint, commitment_index). The TimescaleDB migrations | ||
| // (001, 007) require the timescale/timescaledb image rather than plain | ||
| // postgres, and nothing under test here reads them. | ||
| const INDEXER_MIGRATIONS = ['003_deterministic_indexer.sql', '005_commitment_index.sql']; | ||
|
|
||
| /** | ||
| * Boots an ephemeral, throwaway Postgres via Testcontainers and applies the | ||
| * indexer's schema to it. Call from a suite's `before`; pair with | ||
| * `stopIntegrationDatabase` in `after` so the container is torn down even if | ||
| * a test in between fails. | ||
| */ | ||
| export async function startIntegrationDatabase(): Promise<IntegrationDatabase> { | ||
| const container = await new PostgreSqlContainer('postgres:16') | ||
| .withDatabase('pactum_integration') | ||
| .withUsername('pactum') | ||
| .withPassword('pactum') | ||
| .start(); | ||
|
|
||
| const pool = new Pool({ connectionString: container.getConnectionUri() }); | ||
|
|
||
| // Resolved from the repo-relative cwd (mirrors src/indexer/commitments.test.ts | ||
| // and src/indexer/listener.test.ts) rather than __dirname, since __dirname | ||
| // points at compiled test output under dist-integration-test/, which the | ||
| // build does not copy the .sql migrations into. | ||
| const migrationsDir = path.join(process.cwd(), 'src', 'db', 'migrations'); | ||
| for (const file of INDEXER_MIGRATIONS) { | ||
| const sql = await readFile(path.join(migrationsDir, file), 'utf8'); | ||
| await pool.query(sql); | ||
| } | ||
|
|
||
| return { container, pool }; | ||
| } | ||
|
|
||
| /** Closes the pool and stops the container started by `startIntegrationDatabase`. */ | ||
| export async function stopIntegrationDatabase({ | ||
| container, | ||
| pool, | ||
| }: IntegrationDatabase): Promise<void> { | ||
| await pool.end(); | ||
| await container.stop(); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| { | ||
| "extends": "./tsconfig.json", | ||
| "compilerOptions": { | ||
| "module": "Node16", | ||
| "moduleResolution": "Node16", | ||
| "outDir": "./dist-integration-test", | ||
| "rootDir": "." | ||
| }, | ||
| "include": [ | ||
| "src/indexer/**/*.ts", | ||
| "src/db/**/*.ts", | ||
| "tests/setup.ts", | ||
| "tests/integration/**/*.ts" | ||
| ], | ||
| "exclude": ["node_modules", "dist", "dist-integration-test"] | ||
| } |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.