diff --git a/src/client.ts b/src/client.ts index 751a35a..c496c8b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -374,10 +374,31 @@ export class VeriTixClient extends EventEmitter { // ------------------------------------------------------------------------- /** - * Initialises the Soroban RPC server connection and verifies it is reachable + * Establishes a connection to the Soroban RPC and verifies it is reachable * by fetching the current ledger sequence. * - * Retries with exponential backoff up to `config.retries` times (default 3). + * Call this once when your application starts. All module method calls + * require an active connection — calling them before connect() resolves + * throws a {@link VeriTixError} with code `CLIENT_NOT_CONNECTED`, raised by + * the lazy server proxy the modules hold. + * + * Reconnection: call connect() again to re-establish. Note that a connection + * dropping mid-session is not detected up-front — `connected` stays true, so + * module calls fail with whatever transport error the RPC surfaces rather + * than a connection-specific code. + * + * For read-only usage, connect() is still required to fetch the current ledger. + * + * Contract existence is NOT checked here — connect() only proves the RPC + * endpoint answers. Use {@link healthCheck} to confirm the contract is + * actually deployed on the network. + * + * Lifecycle: `new VeriTixClient(config)` → `connect()` → module calls → + * `disconnect()`. {@link disconnect} tears the server down and clears the + * ledger cache, putting the client back in the pre-connect state; it is safe + * to call `connect()` again afterwards on the same instance. Reconnection is + * never automatic — the only retries are the exponential-backoff attempts + * made *within* a single `connect()` call (up to `config.retries`, default 3). * * @returns The current Stellar ledger sequence number. * @throws {VeriTixError} With code `CONNECTION_FAILED` if unreachable after all retries. diff --git a/tests/client.test.ts b/tests/client.test.ts index 89e694f..4618bea 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -14,6 +14,10 @@ import { RecurringModule } from '../src/modules/recurring'; import { AdminModule } from '../src/modules/admin'; import { BatchModule } from '../src/modules/batch'; import { VeriTixError, VeriTixErrorCode } from '../src/utils/errors'; +import { + makeConnectedClient as makeSharedConnectedClient, + makeMockServer, +} from './helpers/mocks'; // Mock the Freighter wallet API for the createFromFreighter unit tests (#482). const mockFreighter = { @@ -40,16 +44,17 @@ jest.mock('../src/utils/transaction', () => ({ const FAKE_CONTRACT_ID = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4'; -// Helper: create a client whose internal server is pre-mocked +// Helper: create a client whose internal server is pre-mocked. +// Builds on the shared factories in tests/helpers/mocks.ts, adding only the +// per-test ledger sequence and the mock-server handle these tests assert on. function makeConnectedClient(sequence = 100) { - const client = new VeriTixClient(getTestnetConfig(FAKE_CONTRACT_ID)); - // Inject a mock server directly - const mockServer = { getLatestLedger: jest.fn().mockResolvedValue({ sequence }) }; + const client = makeSharedConnectedClient(); + const mockServer = makeMockServer({ + getLatestLedger: jest.fn().mockResolvedValue({ sequence }), + }); // eslint-disable-next-line @typescript-eslint/no-explicit-any (client as any).server = mockServer; // eslint-disable-next-line @typescript-eslint/no-explicit-any - (client as any).connected = true; - // eslint-disable-next-line @typescript-eslint/no-explicit-any (client as any).ledgerCache = { sequence, fetchedAt: Date.now() }; return { client, mockServer }; } diff --git a/tests/helpers/mocks.ts b/tests/helpers/mocks.ts index 9810bf8..e20b7aa 100644 --- a/tests/helpers/mocks.ts +++ b/tests/helpers/mocks.ts @@ -6,7 +6,8 @@ * wire up a `SorobanRpc.Server`, `NetworkConfig`, and `VeriTixClient`. */ -import { Keypair } from '@stellar/stellar-sdk'; +import { Keypair, xdr } from '@stellar/stellar-sdk'; +import type { SorobanRpc } from '@stellar/stellar-sdk'; import type { NetworkConfig } from '../../src/types/index'; import { VeriTixClient } from '../../src/client'; @@ -15,8 +16,12 @@ import { VeriTixClient } from '../../src/client'; // --------------------------------------------------------------------------- const FAKE_CONTRACT = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4'; -/** Deterministic test secret — never use on real networks */ -const TEST_SECRET = 'SCZANGBA5RLRPGKVK3GS4TNCG7SXALKVXBKEB7DRMU5G9CPMZRSGKF7'; +/** + * Deterministic test secret — never use on real networks. + * Derived from a fixed all-`0x07` ed25519 seed, so the public key is always + * GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57. + */ +const TEST_SECRET = 'SADQOBYHA4DQOBYHA4DQOBYHA4DQOBYHA4DQOBYHA4DQOBYHA4DQP54X'; // --------------------------------------------------------------------------- // Factories @@ -75,3 +80,100 @@ export function createMockClient( export function createMockKeypair(): Keypair { return Keypair.fromSecret(TEST_SECRET); } + +// --------------------------------------------------------------------------- +// Issue #3 — canonical `make*` factories +// +// These are the names test files should reach for. They wrap the `create*` +// factories above (kept for the existing call-sites) and add helpers for the +// two mock shapes every write/read test needs: a successful transaction +// round-trip, and a simulation that returns a specific ScVal. +// --------------------------------------------------------------------------- + +/** + * Returns a jest-mocked `SorobanRpc.Server`. + * + * Defaults are the happy path: a ledger at sequence 1000, an empty simulation + * result, and a transaction that is accepted and confirmed. Override any + * method per-test with `server.simulateTransaction.mockResolvedValue(...)`. + */ +export function makeMockServer( + overrides: Record = {}, +): jest.Mocked { + return createMockServer(overrides) as unknown as jest.Mocked; +} + +/** + * Returns a deterministic `Keypair` — the same public key on every call, so + * tests can assert against it directly. + */ +export function makeMockKeypair(): Keypair { + return createMockKeypair(); +} + +/** + * Returns a `VeriTixClient` that is already "connected" (mock server injected, + * `connected` flag set, ledger cache warm) and able to sign. + * + * @param keypair - Signer to attach. Defaults to {@link makeMockKeypair}. + */ +export function makeConnectedClient(keypair: Keypair = makeMockKeypair()): VeriTixClient { + const client = new VeriTixClient(createMockConfig(), keypair); + attachMockServer(client, makeMockServer()); + return client; +} + +/** + * Returns a connected `VeriTixClient` with **no** keypair, so write operations + * throw `VeriTixErrorCode.ReadOnlyClient`. + */ +export function makeReadOnlyClient(): VeriTixClient { + const client = new VeriTixClient(createMockConfig()); + attachMockServer(client, makeMockServer()); + return client; +} + +/** + * Points a mock server at a transaction that is accepted (`PENDING`) and then + * confirmed (`SUCCESS`) on the next poll. + */ +export function mockSuccessfulTransaction(server: jest.Mocked): void { + const s = server as unknown as Record; + s.sendTransaction.mockResolvedValue({ hash: 'mock-hash', status: 'PENDING' }); + s.getTransaction.mockResolvedValue({ + status: 'SUCCESS', + hash: 'mock-hash', + ledger: 1000, + returnValue: xdr.ScVal.scvVoid(), + }); +} + +/** + * Points a mock server's `simulateTransaction` at a specific return value, + * shaped the way `SorobanRpc.Api.SimulateTransactionSuccessResponse` is read + * by the SDK's transaction helpers. + */ +export function mockSimulationResult( + server: jest.Mocked, + returnValue: xdr.ScVal, +): void { + const s = server as unknown as Record; + s.simulateTransaction.mockResolvedValue({ + result: { retval: returnValue, auth: [] }, + transactionData: {}, + minResourceFee: '100', + latestLedger: 1000, + }); +} + +/** + * Injects a mock server into a client and marks it connected. + * Mirrors the field-injection pattern used across the existing test files. + */ +function attachMockServer(client: VeriTixClient, server: jest.Mocked): void { + /* eslint-disable @typescript-eslint/no-explicit-any */ + (client as any).server = server; + (client as any).connected = true; + (client as any).ledgerCache = { sequence: 1000, fetchedAt: Date.now() }; + /* eslint-enable @typescript-eslint/no-explicit-any */ +} diff --git a/tests/issue-442-getLedgerInfo.test.ts b/tests/issue-442-getLedgerInfo.test.ts index 91ab492..dbd931f 100644 --- a/tests/issue-442-getLedgerInfo.test.ts +++ b/tests/issue-442-getLedgerInfo.test.ts @@ -7,20 +7,21 @@ * `VeriTixClient.getCurrentLedger()` method, so these tests target that. */ -import { VeriTixClient } from '../src/client'; -import { getTestnetConfig } from '../src/utils/network'; import { VeriTixError, VeriTixErrorCode } from '../src/utils/errors'; +import { makeConnectedClient as makeClient, makeMockServer } from './helpers/mocks'; -const FAKE_CONTRACT_ID = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4'; - +/** + * Wraps the shared {@link makeConnectedClient} factory so these tests can pin + * a specific ledger sequence and keep a handle on the mock server. + */ function makeConnectedClient(sequence = 100) { - const client = new VeriTixClient(getTestnetConfig(FAKE_CONTRACT_ID)); - const mockServer = { getLatestLedger: jest.fn().mockResolvedValue({ sequence }) }; + const mockServer = makeMockServer({ + getLatestLedger: jest.fn().mockResolvedValue({ sequence }), + }); + const client = makeClient(); // eslint-disable-next-line @typescript-eslint/no-explicit-any (client as any).server = mockServer; // eslint-disable-next-line @typescript-eslint/no-explicit-any - (client as any).connected = true; - // eslint-disable-next-line @typescript-eslint/no-explicit-any (client as any).ledgerCache = { sequence, fetchedAt: Date.now() }; return { client, mockServer }; } diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts new file mode 100644 index 0000000..5155661 --- /dev/null +++ b/tests/smoke.test.ts @@ -0,0 +1,28 @@ +/** + * @file tests/smoke.test.ts + * Module-load smoke test. + * + * A broken re-export in `src/index.ts` fails at runtime, not at build time — + * these tests import the public barrel and assert the headline exports are + * actually there, so a bad barrel is caught by CI instead of by consumers. + */ + +import { + VeriTixClient, + VeriTixError, + VeriTixErrorCode, + getTestnetConfig, +} from '../src/index'; + +describe('SDK module loads', () => { + it('exports VeriTixClient', () => expect(VeriTixClient).toBeDefined()); + it('exports VeriTixError', () => expect(VeriTixError).toBeDefined()); + it('exports VeriTixErrorCode', () => expect(VeriTixErrorCode).toBeDefined()); + it('exports getTestnetConfig', () => expect(getTestnetConfig).toBeDefined()); + + it('getTestnetConfig returns a valid config', () => { + const cfg = getTestnetConfig('CXXXXXXX...'); + expect(cfg.network).toBe('testnet'); + expect(cfg.rpcUrl).toContain('soroban'); + }); +}); diff --git a/tests/utils/errors.test.ts b/tests/utils/errors.test.ts index 8c3bbf3..35fc8c4 100644 --- a/tests/utils/errors.test.ts +++ b/tests/utils/errors.test.ts @@ -26,6 +26,21 @@ describe('VeriTixError', () => { const err = new VeriTixError(VeriTixErrorCode.Unknown, 'msg'); expect(err.name).toBe('VeriTixError'); }); + + // Property-structure regression guard — name / prototype chain / code / message + // are the four things consumers pattern-match on, so pin all four. + it('VeriTixError has name VeriTixError', () => { + const err = new VeriTixError(VeriTixErrorCode.ReadOnlyClient, 'test'); + expect(err.name).toBe('VeriTixError'); + expect(err instanceof Error).toBe(true); + expect(err instanceof VeriTixError).toBe(true); + }); + + it('VeriTixError.code is the enum value', () => { + const err = new VeriTixError(VeriTixErrorCode.EscrowNotFound, 'not found'); + expect(err.code).toBe(VeriTixErrorCode.EscrowNotFound); + expect(err.message).toBe('not found'); + }); }); describe('parseSorobanError', () => {