diff --git a/eslint.config.mjs b/eslint.config.mjs index 19ee9288..a596f6e1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,7 +12,6 @@ const eslintConfig = [ '**/node_modules/**', '**/coverage/**', '**/docs/**', - '**/test/declarations/**', '**/*.js', '**/*.mjs', '**/libs/**', @@ -20,6 +19,21 @@ const eslintConfig = [ '**/Open-Cap-Format-OCF/**', ], }, + // Fail closed when a TypeScript file is "disabled" by appending another suffix + // (for example, `example.test.ts.skip`). Such files otherwise escape both the + // TypeScript project and the `**/*.ts` ESLint configuration silently. + { + files: ['**/*.ts.*', '**/*.tsx.*'], + rules: { + 'no-restricted-syntax': [ + 'error', + { + selector: 'Program', + message: 'TypeScript source files must end in .ts or .tsx; do not append a suffix after the extension.', + }, + ], + }, + }, // Main configuration for TypeScript files { files: ['**/*.ts', '**/*.tsx'], @@ -31,6 +45,7 @@ const eslintConfig = [ './tsconfig.tests.json', './tsconfig.declaration-tests.json', './tsconfig.exact-public-config-tests.json', + './tsconfig.package-consumer-tests.json', ], ecmaVersion: 2020, sourceType: 'module', @@ -182,7 +197,6 @@ const eslintConfig = [ files: ['scripts/**/*', 'test/**/*'], rules: { 'no-console': 'off', - '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-require-imports': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-non-null-assertion': 'off', diff --git a/package.json b/package.json index d95ab252..b2895e18 100644 --- a/package.json +++ b/package.json @@ -49,10 +49,11 @@ "test": "npm run -s typecheck && jest --passWithNoTests", "test:ci": "npm run -s typecheck && jest --runInBand --coverage", "test:coverage": "jest --coverage --passWithNoTests", - "test:declarations": "ts-node --project tsconfig.tests.json scripts/check-declarations.ts && tsc -p tsconfig.declaration-tests.json --noEmit && npm run -s test:exact-public-config && node scripts/check-built-cardinality.cjs", + "test:declarations": "npm run -s clean && npm run -s build && ts-node --project tsconfig.tests.json scripts/check-declarations.ts && tsc -p tsconfig.declaration-tests.json --noEmit && npm run -s test:exact-public-config && node scripts/check-built-cardinality.cjs && npm run -s test:package-consumer", "test:exact-public-config": "tsc -p tsconfig.exact-public-config-tests.json --noEmit", "test:integration": "npm run -s typecheck && jest -c jest.integration.config.js --passWithNoTests", "test:integration:ci": "npm run -s typecheck && jest -c jest.integration.config.js --runInBand", + "test:package-consumer": "tsc -p tsconfig.package-consumer-tests.json --noEmit", "test:watch": "jest --watch", "typecheck": "tsc -p tsconfig.tests.json --noEmit" }, diff --git a/scripts/check-declarations.ts b/scripts/check-declarations.ts index c49ac6f1..6bf1512a 100644 --- a/scripts/check-declarations.ts +++ b/scripts/check-declarations.ts @@ -8,6 +8,7 @@ import { const projectRoot = process.cwd(); const configPath = path.join(projectRoot, 'tsconfig.json'); +const packageJsonPath = path.join(projectRoot, 'package.json'); const declarationEntryPoint = path.join(projectRoot, 'dist', 'index.d.ts'); const strictConsumerEntryPoint = path.join(projectRoot, 'test', 'declarations', 'publicApi.types.ts'); const declarationRoot = `${path.dirname(declarationEntryPoint)}${path.sep}`; @@ -20,10 +21,95 @@ const diagnosticHost: ts.FormatDiagnosticsHost = { getNewLine: () => ts.sys.newLine, }; +function asRecord(value: unknown, description: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`${description} must be an object`); + } + return value as Record; +} + +function requireString(record: Record, property: string, description: string): string { + const value = record[property]; + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`${description}.${property} must be a non-empty string`); + } + return value; +} + +function normalizedPackageTarget(target: string): string { + return target.startsWith('./') ? target.slice(2) : target; +} + +function verifyPackageTarget(label: string, target: string, expectedExtension: '.d.ts' | '.js'): string { + const normalizedTarget = normalizedPackageTarget(target); + if (!normalizedTarget.startsWith(`dist${path.sep}`) && !normalizedTarget.startsWith('dist/')) { + throw new Error(`${label} must reference a generated dist artifact, received: ${target}`); + } + if (!normalizedTarget.endsWith(expectedExtension)) { + throw new Error(`${label} must end in ${expectedExtension}, received: ${target}`); + } + + const absoluteTarget = path.resolve(projectRoot, normalizedTarget); + const distRoot = path.resolve(projectRoot, 'dist'); + if (absoluteTarget !== distRoot && !absoluteTarget.startsWith(`${distRoot}${path.sep}`)) { + throw new Error(`${label} escapes the generated dist directory: ${target}`); + } + if (!fs.existsSync(absoluteTarget)) { + throw new Error(`${label} references a missing package artifact: ${target}`); + } + return absoluteTarget; +} + +function verifyPackageEntryPoints(): void { + const packageJsonValue: unknown = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + const packageJson = asRecord(packageJsonValue, 'package.json'); + const exportsMap = asRecord(packageJson.exports, 'package.json exports'); + const rootExport = asRecord(exportsMap['.'], 'package.json exports["."]'); + + const rootTypes = verifyPackageTarget( + 'package.json exports["."].types', + requireString(rootExport, 'types', 'package.json exports["."]'), + '.d.ts' + ); + const topLevelTypes = verifyPackageTarget( + 'package.json types', + requireString(packageJson, 'types', 'package.json'), + '.d.ts' + ); + if (rootTypes !== topLevelTypes || rootTypes !== declarationEntryPoint) { + throw new Error('package.json types and exports["."].types must both reference dist/index.d.ts'); + } + + const rootImport = verifyPackageTarget( + 'package.json exports["."].import', + requireString(rootExport, 'import', 'package.json exports["."]'), + '.js' + ); + const rootRequire = verifyPackageTarget( + 'package.json exports["."].require', + requireString(rootExport, 'require', 'package.json exports["."]'), + '.js' + ); + const rootDefault = verifyPackageTarget( + 'package.json exports["."].default', + requireString(rootExport, 'default', 'package.json exports["."]'), + '.js' + ); + const topLevelMain = verifyPackageTarget( + 'package.json main', + requireString(packageJson, 'main', 'package.json'), + '.js' + ); + if (rootImport !== rootRequire || rootImport !== rootDefault || rootImport !== topLevelMain) { + throw new Error('package.json main and exports["."] runtime targets must reference the same built entry point'); + } +} + if (!fs.existsSync(declarationEntryPoint)) { throw new Error(`Declaration entry point not found: ${declarationEntryPoint}. Run npm run build first.`); } +verifyPackageEntryPoints(); const sourcePublicTypes = inventoryCanonicalOcfPublicTypes(projectRoot, 'source'); const builtPublicTypes = inventoryCanonicalOcfPublicTypes(projectRoot, 'built'); const publicTypeDrift = describeCanonicalOcfPublicTypeDrift(sourcePublicTypes, builtPublicTypes); diff --git a/scripts/optimize-fixtures.ts b/scripts/optimize-fixtures.ts index a36fbe11..ede67b8f 100755 --- a/scripts/optimize-fixtures.ts +++ b/scripts/optimize-fixtures.ts @@ -80,9 +80,9 @@ function runCoverage(): CoverageData { }); return parseCoverage(output); - } catch (error: any) { + } catch (error: unknown) { // Jest might exit with non-zero code even on successful coverage run - if (error.stdout) { + if (typeof error === 'object' && error !== null && 'stdout' in error && typeof error.stdout === 'string') { return parseCoverage(error.stdout); } throw error; diff --git a/test/batch/generatedOperationConstruction.test.ts b/test/batch/generatedOperationConstruction.test.ts index d8a621fc..3656c6f6 100644 --- a/test/batch/generatedOperationConstruction.test.ts +++ b/test/batch/generatedOperationConstruction.test.ts @@ -25,7 +25,7 @@ function loadEntityFixture( entityType: T, relativePath: string ): readonly [type: T, data: OcfWritableDataTypeFor] { - const fixture = stripSourceMetadata(loadFixture>(relativePath)); + const fixture = stripSourceMetadata(loadFixture(relativePath)); const canonicalFixture = parseOcfObject(fixture); return [entityType, parseOcfEntityInput(entityType, canonicalFixture)]; } @@ -183,9 +183,7 @@ describe('generated DAML batch operation construction', () => { test('constructs correlated operation objects without rebuilding asserted tuples', () => { const stakeholder = parseOcfEntityInput( 'stakeholder', - parseOcfObject( - stripSourceMetadata(loadFixture>('production/stakeholder/individual.json')) - ) + parseOcfObject(stripSourceMetadata(loadFixture('production/stakeholder/individual.json'))) ); const { command } = buildUpdateCapTableCommand( { capTableContractId: 'cap-table-generated-operation-1' }, diff --git a/test/converters/convertibleIssuanceConverters.test.ts b/test/converters/convertibleIssuanceConverters.test.ts index d912229a..7d22d820 100644 --- a/test/converters/convertibleIssuanceConverters.test.ts +++ b/test/converters/convertibleIssuanceConverters.test.ts @@ -18,9 +18,10 @@ import { } from '../../src/functions/OpenCapTable/convertibleIssuance/createConvertibleIssuance'; import { damlConvertibleIssuanceDataToNative } from '../../src/functions/OpenCapTable/convertibleIssuance/getConvertibleIssuanceAsOcf'; import type { ConvertibleConversionTrigger } from '../../src/types/native'; +import { parseOcfEntityInput } from '../../src/utils/ocfZodSchemas'; import { requireFirst } from '../../src/utils/requireDefined'; import { expectInvalidDate } from '../utils/dateValidationAssertions'; -import { loadProductionFixture } from '../utils/productionFixtures'; +import { loadProductionFixture, stripSourceMetadata } from '../utils/productionFixtures'; const BASE_INPUT = { id: 'conv-001', @@ -2022,37 +2023,11 @@ describe('convertible issuance write field boundaries', () => { */ describe('POST_MONEY SAFE – production fixture round-trip', () => { it('preserves POST_MONEY timing, trigger type, converts_to_future_round, and cap_def_rules from production fixture', () => { - const fixture = loadProductionFixture<{ - id: string; - date: string; - security_id: string; - custom_id: string; - stakeholder_id: string; - investment_amount: { amount: string; currency: string }; - convertible_type: 'SAFE'; - seniority: number; - security_law_exemptions: Array<{ description: string; jurisdiction: string }>; - conversion_triggers: Array<{ - type: string; - trigger_id: string; - trigger_condition: string; - conversion_right: { - type: string; - converts_to_future_round: boolean; - conversion_mechanism: { - type: string; - conversion_timing: string; - conversion_mfn: boolean; - conversion_valuation_cap: { amount: string; currency: string }; - capitalization_definition_rules: Record; - }; - }; - }>; - }>('convertibleIssuance', 'safe-post-money'); - - const daml = convertibleIssuanceDataToDaml( - fixture as unknown as Parameters[0] + const fixture = parseOcfEntityInput( + 'convertibleIssuance', + stripSourceMetadata(loadProductionFixture('convertibleIssuance', 'safe-post-money')) ); + const daml = convertibleIssuanceDataToDaml(fixture); const result = damlConvertibleIssuanceDataToNative(daml); const trigger = requireFirst(result.conversion_triggers, 'production fixture conversion trigger'); diff --git a/test/createOcf/dynamic.test.ts.skip b/test/createOcf/dynamic.test.ts.skip deleted file mode 100644 index 13d2b9c9..00000000 --- a/test/createOcf/dynamic.test.ts.skip +++ /dev/null @@ -1,395 +0,0 @@ -import type { ClientConfig } from '@fairmint/canton-node-sdk'; -import { getFeaturedAppRightContractDetails, ValidatorApiClient } from '@fairmint/canton-node-sdk'; -import type { SubmitAndWaitForTransactionTreeResponse } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/operations'; -import type { DisclosedContract } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/schemas'; -import * as fs from 'fs'; -import * as path from 'path'; -import { type OcfIssuer, OcpClient } from '../../src'; -import { - clearEventsFixture, - clearTransactionTreeFixture, - convertTransactionTreeToEventsResponse, - setEventsFixtureData, - setTransactionTreeFixtureData, -} from '../utils/fixtureHelpers'; -import { validateOcfObject } from '../utils/ocfSchemaValidator'; - -interface TestFixture { - functionName: string; - db: Record; - testContext: { - issuerContractId: string; - issuerParty: string; - issuerAuthorizationContractDetails?: DisclosedContract & { - response?: any; - synchronizerId?: string; - }; - }; - request: Record; - response?: any; - synchronizerId?: string; - onchain_ocf: Record; -} - -// Load all fixtures from the fixtures directory -function loadFixtures(): Array<{ name: string; fixture: TestFixture }> { - const fixturesDir = path.join(__dirname, '../fixtures/createOcf'); - - if (!fs.existsSync(fixturesDir)) { - return []; - } - - const files = fs.readdirSync(fixturesDir).filter((f) => f.endsWith('.json')); - - return files.map((filename) => { - const fixturePath = path.join(fixturesDir, filename); - const fileContent = fs.readFileSync(fixturePath, 'utf-8'); - const fixture = JSON.parse(fileContent) as TestFixture; - const testName = filename.replace('.json', ''); - - return { name: testName, fixture }; - }); -} - -describe('OCP Client - Dynamic Create Tests', () => { - const fixtures = loadFixtures(); - - if (fixtures.length === 0) { - test('No fixtures found', () => { - console.warn('No fixture files found in test/fixtures/createOcf/'); - }); - return; - } - - fixtures.forEach(({ name, fixture }) => { - describe(name, () => { - beforeEach(async () => { - const config: ClientConfig = { - network: 'devnet', - }; - const validatorApi = new ValidatorApiClient(config); - - // Set up the fixture data directly without file I/O - const featuredAppRight = await getFeaturedAppRightContractDetails(validatorApi); - - // Build the expected disclosed contracts array - const disclosedContracts: DisclosedContract[] = []; - - // For issuer creation, include the issuer authorization contract - if (fixture.db.object_type === 'ISSUER' && fixture.testContext.issuerAuthorizationContractDetails) { - // Extract only the required DisclosedContract fields - const authContract = fixture.testContext.issuerAuthorizationContractDetails; - disclosedContracts.push({ - contractId: authContract.contractId, - createdEventBlob: authContract.createdEventBlob, - synchronizerId: authContract.synchronizerId, - templateId: authContract.templateId, - }); - } - - // Always include the featured app right - disclosedContracts.push(featuredAppRight); - - const fixtureData = { - ...fixture, - timestamp: new Date().toISOString(), - url: 'https://ledger-api.validator.devnet.transfer-agent.xyz/v2/commands/submit-and-wait-for-transaction-tree', - request: { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - data: { - commands: [fixture.request], - actAs: [fixture.testContext.issuerParty], - disclosedContracts, - }, - }, - }; - - setTransactionTreeFixtureData(fixtureData); - }); - - afterEach(() => { - clearTransactionTreeFixture(); - }); - - test('validates request and returns expected response', async () => { - // Validate input OCF data against schema - await validateOcfObject(fixture.db); - - const config: ClientConfig = { - network: 'devnet', - }; - - const validatorApi = new ValidatorApiClient(config); - const featuredAppRight = await getFeaturedAppRightContractDetails(validatorApi); - const client = new OcpClient(config); - - // Build the command based on object type - const commandWithDisclosed = - fixture.db.object_type === 'ISSUER' - ? client.OpenCapTable.issuer.buildCreateIssuerCommand({ - featuredAppRightContractDetails: featuredAppRight, - issuerParty: fixture.testContext.issuerParty, - issuerData: fixture.db as unknown as OcfIssuerData, - issuerAuthorizationContractDetails: fixture.testContext.issuerAuthorizationContractDetails!, - }) - : client.buildCreateOcfObjectCommand({ - issuerContractId: fixture.testContext.issuerContractId, - featuredAppRightContractDetails: featuredAppRight, - issuerParty: fixture.testContext.issuerParty, - ocfData: fixture.db as { object_type: string; [key: string]: unknown }, - })[0]; // For non-ISSUER types, we get an array, take the first command - - // Execute the command - const response = (await client.client.submitAndWaitForTransactionTree({ - actAs: [fixture.testContext.issuerParty], - commands: [commandWithDisclosed.command], - disclosedContracts: commandWithDisclosed.disclosedContracts, - })) as SubmitAndWaitForTransactionTreeResponse; - - // Verify result structure - expect(response).toBeDefined(); - expect(response.transactionTree).toBeDefined(); - - // Access eventsById - fixtures have the structure transactionTree.transaction.eventsById - const transaction = (response.transactionTree as any).transaction ?? response.transactionTree; - expect(transaction.eventsById).toBeDefined(); - expect(Object.keys(transaction.eventsById).length).toBeGreaterThan(0); - - // Find any created event - const createdEvent = Object.values(transaction.eventsById).find((event: any) => event.CreatedTreeEvent); - - expect(createdEvent).toBeDefined(); - const { contractId } = (createdEvent as any).CreatedTreeEvent.value; - expect(contractId).toBeDefined(); - expect(typeof contractId).toBe('string'); - expect(contractId.length).toBeGreaterThan(0); - }); - - test('builds archive and create commands when previousContractId is provided', async () => { - // Skip this test for types that don't support archiving or have different requirements - const unsupportedTypes = ['ISSUER', 'TX_EQUITY_COMPENSATION_EXERCISE', 'TX_EQUITY_COMPENSATION_ISSUANCE']; - if (unsupportedTypes.includes(fixture.db.object_type as string)) { - return; - } - - const config: ClientConfig = { - network: 'devnet', - }; - - const validatorApi = new ValidatorApiClient(config); - const featuredAppRight = await getFeaturedAppRightContractDetails(validatorApi); - const client = new OcpClient(config); - - // Build commands without previousContractId - const commandsWithoutArchive = client.buildCreateOcfObjectCommand({ - issuerContractId: fixture.testContext.issuerContractId, - featuredAppRightContractDetails: featuredAppRight, - issuerParty: fixture.testContext.issuerParty, - ocfData: fixture.db as { object_type: string; [key: string]: unknown }, - }); - - // Should return array with 1 command (create only) - expect(Array.isArray(commandsWithoutArchive)).toBe(true); - expect(commandsWithoutArchive.length).toBe(1); - expect(commandsWithoutArchive[0]).toHaveProperty('command'); - expect(commandsWithoutArchive[0]).toHaveProperty('disclosedContracts'); - expect(commandsWithoutArchive[0].command).toHaveProperty('ExerciseCommand'); - - // Build commands with previousContractId - const previousContractId = 'placeholder-previous-contract-id-123'; - // Create mock issuer contract details for testing archive functionality - const issuerContractDetails: DisclosedContract = { - contractId: fixture.testContext.issuerContractId, - createdEventBlob: 'mock-created-event-blob', - synchronizerId: 'mock-synchronizer-id', - templateId: 'mock-template-id', - }; - const commandsWithArchive = client.buildCreateOcfObjectCommand({ - issuerContractId: fixture.testContext.issuerContractId, - issuerContractDetails, - featuredAppRightContractDetails: featuredAppRight, - issuerParty: fixture.testContext.issuerParty, - ocfData: fixture.db as { object_type: string; [key: string]: unknown }, - previousContractId, - }); - - // Should return array with 2 commands (archive + create) - expect(Array.isArray(commandsWithArchive)).toBe(true); - expect(commandsWithArchive.length).toBe(2); - - // First command should be archive - const archiveCommand = commandsWithArchive[0]; - expect(archiveCommand).toHaveProperty('command'); - expect(archiveCommand).toHaveProperty('disclosedContracts'); - expect(archiveCommand.command).toHaveProperty('ExerciseCommand'); - - // Type guard to ensure ExerciseCommand exists - if ('ExerciseCommand' in archiveCommand.command) { - expect(archiveCommand.command.ExerciseCommand).toHaveProperty('contractId', previousContractId); - // Archive command choice should contain 'Archive' - expect(archiveCommand.command.ExerciseCommand.choice).toContain('Archive'); - } else { - throw new Error('Expected archive command to be an ExerciseCommand'); - } - - // Second command should be create (same as without archive) - const createCommand = commandsWithArchive[1]; - expect(createCommand).toHaveProperty('command'); - expect(createCommand).toHaveProperty('disclosedContracts'); - expect(createCommand.command).toHaveProperty('ExerciseCommand'); - - // Create command should match the one built without archive - if ('ExerciseCommand' in createCommand.command && 'ExerciseCommand' in commandsWithoutArchive[0].command) { - expect(createCommand.command.ExerciseCommand.choice).toBe( - commandsWithoutArchive[0].command.ExerciseCommand.choice - ); - } else { - throw new Error('Expected create command to be an ExerciseCommand'); - } - - // Both commands should have disclosed contracts - expect(Array.isArray(archiveCommand.disclosedContracts)).toBe(true); - expect(archiveCommand.disclosedContracts.length).toBeGreaterThan(0); - expect(Array.isArray(createCommand.disclosedContracts)).toBe(true); - expect(createCommand.disclosedContracts.length).toBeGreaterThan(0); - }); - - test('get*AsOcf returns expected OCF data', async () => { - // Determine where the response is located - const response = fixture.response ?? fixture.testContext.issuerAuthorizationContractDetails?.response; - const synchronizerId = - fixture.synchronizerId ?? fixture.testContext.issuerAuthorizationContractDetails?.synchronizerId ?? ''; - - // Skip this test if there's no response data (required for events mock) - if (!response) { - return; - } - - const config: ClientConfig = { - network: 'devnet', - }; - - // Convert the transaction tree response to events format - const eventsResponse = convertTransactionTreeToEventsResponse(response, synchronizerId); - - // Set up the events fixture - setEventsFixtureData(eventsResponse); - - try { - const client = new OcpClient(config); - const contractId = 'test-contract-id'; - - let result: any; - let expectedOcf: any; - - switch (fixture.db.object_type) { - case 'ISSUER': - result = await client.OpenCapTable.issuer.getIssuerAsOcf({ contractId }); - expectedOcf = { issuer: fixture.onchain_ocf, contractId }; - break; - case 'STOCK_CLASS': - result = await client.OpenCapTable.stockClass.getStockClassAsOcf({ contractId }); - expectedOcf = { stockClass: fixture.onchain_ocf, contractId }; - break; - case 'STAKEHOLDER': - result = await client.OpenCapTable.stakeholder.getStakeholderAsOcf({ contractId }); - expectedOcf = { stakeholder: fixture.onchain_ocf, contractId }; - break; - case 'STOCK_LEGEND_TEMPLATE': - result = await client.OpenCapTable.stockLegendTemplate.getStockLegendTemplateAsOcf({ contractId }); - expectedOcf = { stockLegendTemplate: fixture.onchain_ocf, contractId }; - break; - case 'VESTING_TERMS': - result = await client.OpenCapTable.vestingTerms.getVestingTermsAsOcf({ contractId }); - expectedOcf = { vestingTerms: fixture.onchain_ocf, contractId }; - break; - case 'STOCK_PLAN': - result = await client.OpenCapTable.stockPlan.getStockPlanAsOcf({ contractId }); - expectedOcf = { stockPlan: fixture.onchain_ocf, contractId }; - break; - case 'TX_STOCK_ISSUANCE': - result = await client.OpenCapTable.stockIssuance.getStockIssuanceAsOcf({ contractId }); - expectedOcf = { stockIssuance: fixture.onchain_ocf, contractId }; - break; - case 'TX_STOCK_CANCELLATION': - result = await client.OpenCapTable.stockCancellation.getStockCancellationEventAsOcf({ - contractId, - }); - expectedOcf = { event: fixture.onchain_ocf, contractId }; - break; - case 'TX_STOCK_TRANSFER': - result = await client.OpenCapTable.stockTransfer.getStockTransferAsOcf({ - contractId, - }); - expectedOcf = { event: fixture.onchain_ocf, contractId }; - break; - case 'TX_ISSUER_AUTHORIZED_SHARES_ADJUSTMENT': - result = - await client.OpenCapTable.issuerAuthorizedSharesAdjustment.getIssuerAuthorizedSharesAdjustmentEventAsOcf( - { - contractId, - } - ); - expectedOcf = { event: fixture.onchain_ocf, contractId }; - break; - case 'TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT': - result = - await client.OpenCapTable.stockClassAuthorizedSharesAdjustment.getStockClassAuthorizedSharesAdjustmentEventAsOcf( - { - contractId, - } - ); - expectedOcf = { event: fixture.onchain_ocf, contractId }; - break; - case 'TX_STOCK_PLAN_POOL_ADJUSTMENT': - result = await client.OpenCapTable.stockPlanPoolAdjustment.getStockPlanPoolAdjustmentEventAsOcf({ - contractId, - }); - expectedOcf = { event: fixture.onchain_ocf, contractId }; - break; - case 'TX_EQUITY_COMPENSATION_ISSUANCE': - result = await client.OpenCapTable.equityCompensationIssuance.getEquityCompensationIssuanceEventAsOcf({ - contractId, - }); - expectedOcf = { event: fixture.onchain_ocf, contractId }; - break; - case 'TX_EQUITY_COMPENSATION_EXERCISE': - result = await client.OpenCapTable.equityCompensationExercise.getEquityCompensationExerciseEventAsOcf({ - contractId, - }); - expectedOcf = { event: fixture.onchain_ocf, contractId }; - break; - case 'DOCUMENT': - result = await client.OpenCapTable.document.getDocumentAsOcf({ contractId }); - expectedOcf = { document: fixture.onchain_ocf, contractId }; - break; - case 'TX_WARRANT_ISSUANCE': - result = await client.OpenCapTable.warrantIssuance.getWarrantIssuanceAsOcf({ contractId }); - expectedOcf = { event: fixture.onchain_ocf, contractId }; - break; - case 'TX_CONVERTIBLE_ISSUANCE': - result = await client.OpenCapTable.convertibleIssuance.getConvertibleIssuanceAsOcf({ contractId }); - expectedOcf = { event: fixture.onchain_ocf, contractId }; - break; - default: - throw new Error(`Unsupported object type: ${String(fixture.db.object_type)}`); - } - - // Verify the result matches expected OCF - expect(result).toEqual(expectedOcf); - - // Validate the returned OCF data against schema - // Extract the actual OCF object from the result (remove contractId wrapper) - const ocfData = Object.values(result).find( - (val) => typeof val === 'object' && val !== null && 'object_type' in val - ) as Record; - - await validateOcfObject(ocfData); - } finally { - clearEventsFixture(); - } - }); - }); - }); -}); diff --git a/test/exact/builtPublicConfig.types.ts b/test/exact/builtPublicConfig.types.ts index 1484e13a..f79ba40b 100644 --- a/test/exact/builtPublicConfig.types.ts +++ b/test/exact/builtPublicConfig.types.ts @@ -7,17 +7,52 @@ import { type CommandContext, type EnvironmentConfig, type EnvironmentConfigInput, + type NonLocalOAuth2EnvironmentConfigInput, type OcpClient, type OcpClientDependencies, type OcpClientEnvOptions, type OcpClientHostedPresetOptions, type OcpClientLocalNetOptions, + type OcpEnvironment, type OcpFactoryCoordinates, type OcpValidationError, + type SharedSecretEnvironmentConfigInput, type ValidationResult, } from '../../dist'; import type { Assert, IsExactly, IsOptional } from '../typeContracts/typeAssertions'; +const builtExactnessRejectsCompilerAny: Assert< + IsExactly, 'canonical'>, false> +> = true; + +interface NestedCompilerAny { + readonly config: { readonly authUrl: ReturnType }; +} + +interface NestedCanonicalConfig { + readonly config: { readonly authUrl: string }; +} + +const builtExactnessRejectsNestedCompilerAny: Assert< + IsExactly, false> +> = true; + +interface RequiredOAuth2Credentials { + readonly authUrl: string; + readonly clientId: string; + readonly clientSecret: string; +} + +const builtOAuth2CredentialsStayRequired: Assert< + IsExactly, RequiredOAuth2Credentials> +> = true; +const builtSharedSecretEnvironmentsStayExact: Assert< + IsExactly> +> = true; +const builtMainNetNeverSupportsSharedSecret: Assert< + IsExactly, never> +> = true; + declare const client: OcpClient; declare const dependencies: OcpClientDependencies; declare const resolved: EnvironmentConfig; @@ -93,31 +128,31 @@ const explicitUndefinedParentSpanId: CommandContext = { traceContext: { parentSp // @ts-expect-error Built environment inputs preserve omission-only properties. const explicitUndefinedInput: EnvironmentConfigInput = { environment: 'localnet', ledgerApiUrl: undefined }; -// @ts-expect-error Built OAuth2 authUrl is required in the OAuth2 branch. -const oauthMissingAuthUrl: EnvironmentConfigInput = { +// @ts-expect-error Built OAuth2 authUrl is required when every other field is valid. +const missingOAuthAuthUrl: EnvironmentConfigInput = { environment: 'devnet', ledgerApiUrl: 'https://ledger.devnet.example.com', authMode: 'oauth2', clientId: 'client-id', clientSecret: 'client-secret', }; -// @ts-expect-error Built OAuth2 clientId is required in the OAuth2 branch. -const oauthMissingClientId: EnvironmentConfigInput = { +// @ts-expect-error Built OAuth2 clientId is required when every other field is valid. +const missingOAuthClientId: EnvironmentConfigInput = { environment: 'devnet', ledgerApiUrl: 'https://ledger.devnet.example.com', authMode: 'oauth2', authUrl: 'https://auth.example.com/token', clientSecret: 'client-secret', }; -// @ts-expect-error Built OAuth2 clientSecret is required in the OAuth2 branch. -const oauthMissingClientSecret: EnvironmentConfigInput = { +// @ts-expect-error Built OAuth2 clientSecret is required when every other field is valid. +const missingOAuthClientSecret: EnvironmentConfigInput = { environment: 'devnet', ledgerApiUrl: 'https://ledger.devnet.example.com', authMode: 'oauth2', authUrl: 'https://auth.example.com/token', clientId: 'client-id', }; -// @ts-expect-error Built MainNet cannot use shared-secret authentication. +// @ts-expect-error Built MainNet inputs cannot use shared-secret authentication. const mainnetSharedSecret: EnvironmentConfigInput = { environment: 'mainnet', ledgerApiUrl: 'https://ledger.mainnet.example.com', @@ -201,13 +236,18 @@ void clientEnvironmentIsRequired; void errorStatusCodeIsRequired; void validationReceivedValueIsRequired; void validationReceivedValue; +void builtOAuth2CredentialsStayRequired; +void builtSharedSecretEnvironmentsStayExact; +void builtMainNetNeverSupportsSharedSecret; +void builtExactnessRejectsCompilerAny; +void builtExactnessRejectsNestedCompilerAny; void explicitUndefinedTraceId; void explicitUndefinedSpanId; void explicitUndefinedParentSpanId; void explicitUndefinedInput; -void oauthMissingAuthUrl; -void oauthMissingClientId; -void oauthMissingClientSecret; +void missingOAuthAuthUrl; +void missingOAuthClientId; +void missingOAuthClientSecret; void mainnetSharedSecret; void missingOAuthLedger; void missingSharedSecretLedger; diff --git a/test/exact/sourcePublicConfig.types.ts b/test/exact/sourcePublicConfig.types.ts index 1f4549fb..6b6a494d 100644 --- a/test/exact/sourcePublicConfig.types.ts +++ b/test/exact/sourcePublicConfig.types.ts @@ -10,6 +10,9 @@ import { ENVIRONMENT_PRESETS, type EnvironmentConfig, type EnvironmentConfigInput, + type NonLocalOAuth2EnvironmentConfigInput, + type OcpEnvironment, + type SharedSecretEnvironmentConfigInput, type ValidationResult, } from '../../src/environment'; import { OcpNetworkError, type OcpValidationError } from '../../src/errors'; @@ -18,6 +21,38 @@ import { applyCommandContext, type AppliedCommandContext } from '../../src/obser import type { CommandContext, OcpObservabilityOptions } from '../../src/observabilityTypes'; import type { Assert, IsExactly, IsOptional } from '../typeContracts/typeAssertions'; +const sourceExactnessRejectsCompilerAny: Assert< + IsExactly, 'canonical'>, false> +> = true; + +interface NestedCompilerAny { + readonly config: { readonly authUrl: ReturnType }; +} + +interface NestedCanonicalConfig { + readonly config: { readonly authUrl: string }; +} + +const sourceExactnessRejectsNestedCompilerAny: Assert< + IsExactly, false> +> = true; + +interface RequiredOAuth2Credentials { + readonly authUrl: string; + readonly clientId: string; + readonly clientSecret: string; +} + +const sourceOAuth2CredentialsStayRequired: Assert< + IsExactly, RequiredOAuth2Credentials> +> = true; +const sourceSharedSecretEnvironmentsStayExact: Assert< + IsExactly> +> = true; +const sourceMainNetNeverSupportsSharedSecret: Assert< + IsExactly, never> +> = true; + declare const ledger: LedgerJsonApiClient; declare const resolved: EnvironmentConfig; declare const validationResult: ValidationResult; @@ -99,6 +134,7 @@ const explicitUndefinedSpanId: CommandContext = { traceContext: { spanId: undefi // @ts-expect-error Nested trace parent span identifiers are omission-only under exact optional semantics. const explicitUndefinedParentSpanId: CommandContext = { traceContext: { parentSpanId: undefined } }; +const optionalValidatorUrl: string | undefined = resolved.validatorApiUrl; if (resolved.authMode === 'oauth2') { const { clientSecret, sharedSecret } = resolved; const oauthCredentials: readonly [string, undefined] = [clientSecret, sharedSecret]; @@ -109,24 +145,24 @@ if (resolved.authMode === 'oauth2') { void sharedSecretCredentials; } -// @ts-expect-error OAuth2 authUrl is required in the OAuth2 branch. -const oauthMissingAuthUrl: EnvironmentConfigInput = { +// @ts-expect-error OAuth2 authUrl is required when every other field is valid. +const missingOAuthAuthUrl: EnvironmentConfigInput = { environment: 'devnet', ledgerApiUrl: 'https://ledger.devnet.example.com', authMode: 'oauth2', clientId: 'client-id', clientSecret: 'client-secret', }; -// @ts-expect-error OAuth2 clientId is required in the OAuth2 branch. -const oauthMissingClientId: EnvironmentConfigInput = { +// @ts-expect-error OAuth2 clientId is required when every other field is valid. +const missingOAuthClientId: EnvironmentConfigInput = { environment: 'devnet', ledgerApiUrl: 'https://ledger.devnet.example.com', authMode: 'oauth2', authUrl: 'https://auth.example.com/token', clientSecret: 'client-secret', }; -// @ts-expect-error OAuth2 clientSecret is required in the OAuth2 branch. -const oauthMissingClientSecret: EnvironmentConfigInput = { +// @ts-expect-error OAuth2 clientSecret is required when every other field is valid. +const missingOAuthClientSecret: EnvironmentConfigInput = { environment: 'devnet', ledgerApiUrl: 'https://ledger.devnet.example.com', authMode: 'oauth2', @@ -223,9 +259,15 @@ void validationReceivedValue; void explicitUndefinedTraceId; void explicitUndefinedSpanId; void explicitUndefinedParentSpanId; -void oauthMissingAuthUrl; -void oauthMissingClientId; -void oauthMissingClientSecret; +void optionalValidatorUrl; +void sourceOAuth2CredentialsStayRequired; +void sourceSharedSecretEnvironmentsStayExact; +void sourceMainNetNeverSupportsSharedSecret; +void sourceExactnessRejectsCompilerAny; +void sourceExactnessRejectsNestedCompilerAny; +void missingOAuthAuthUrl; +void missingOAuthClientId; +void missingOAuthClientSecret; void mainnetSharedSecret; void missingOAuthLedger; void missingSharedSecretLedger; diff --git a/test/integration/production/productionDataRoundtrip.integration.test.ts b/test/integration/production/productionDataRoundtrip.integration.test.ts index 72533cfe..7a004a00 100644 --- a/test/integration/production/productionDataRoundtrip.integration.test.ts +++ b/test/integration/production/productionDataRoundtrip.integration.test.ts @@ -16,6 +16,7 @@ * ``` */ +import type { OcfContractId } from '../../../src'; import { DEFAULT_DEPRECATED_FIELDS, DEFAULT_INTERNAL_FIELDS, @@ -25,7 +26,11 @@ import { import { parseOcfEntityInput } from '../../../src/utils/ocfZodSchemas'; import { requireFirst } from '../../../src/utils/requireDefined'; import { validateOcfObject } from '../../utils/ocfSchemaValidator'; -import { loadProductionFixture, loadSyntheticFixture, stripSourceMetadata } from '../../utils/productionFixtures'; +import { + loadProductionFixture, + loadSyntheticFixture, + prepareFixtureForSubmission, +} from '../../utils/productionFixtures'; import { createIntegrationTestSuite, type IntegrationTestContext } from '../setup'; import { createTestStockPlanData, @@ -44,20 +49,23 @@ import { * Helper to prepare a fixture for submission to the API. * Strips metadata fields and generates unique IDs. * - * Note: Returns `any` because fixtures are dynamically loaded JSON. - * The batch API will validate structure at runtime. + * Fixtures remain unknown records until the batch API validates their structure at runtime. */ -function prepareFixture(fixture: Record, idPrefix: string): any { - const cleaned = stripSourceMetadata(fixture); - // Generate unique ID to avoid conflicts between test runs - const uniqueId = generateTestId(idPrefix); - return { - ...cleaned, - id: uniqueId, - // Also update security_id if present - ...(cleaned.security_id ? { security_id: generateTestId('security') } : {}), - }; +function prepareFixture(fixture: Record, idPrefix: string): Record { + return prepareFixtureForSubmission(fixture, { + // Generate unique IDs to avoid conflicts between test runs. + id: generateTestId(idPrefix), + securityId: generateTestId('security'), + }); +} + +function requireFixtureString(fixture: Record, field: string): string { + const value = fixture[field]; + if (typeof value !== 'string') { + throw new Error(`Fixture field ${field} must be a string`); + } + return value; } /** @@ -85,10 +93,10 @@ function compareOcfData(source: Record, readBack: Record } // ContractId is just a string in the JSON representation - return cid.value as string; + return cid.value; } async function getUpdatedCapTableDetails(ctx: IntegrationTestContext, contractId: string, synchronizerId: string) { @@ -187,7 +195,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { issuerParty: ctx.issuerParty, }); - const fixture = loadProductionFixture>('stockClass', 'common'); + const fixture = loadProductionFixture('stockClass', 'common'); const prepared = prepareFixture(fixture, 'stock-class'); const batch = ctx.ocp.OpenCapTable.capTable.update({ @@ -196,7 +204,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('stockClass', prepared).execute(); + const result = await batch.create('stockClass', parseOcfEntityInput('stockClass', prepared)).execute(); expect(result.createdCids).toHaveLength(1); }); @@ -214,7 +222,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { issuerParty: ctx.issuerParty, }); - const fixture = loadProductionFixture>('valuation', '409a'); + const fixture = loadProductionFixture('valuation', '409a'); const stockSecurity = await setupStockSecurity(ctx.ocp, { issuerContractId: issuerSetup.issuerContractId, issuerParty: ctx.issuerParty, @@ -236,7 +244,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('valuation', prepared).execute(); + const result = await batch.create('valuation', parseOcfEntityInput('valuation', prepared)).execute(); expect(result.createdCids).toHaveLength(1); }); @@ -251,7 +259,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { }); // Load and prepare fixture - const fixture = loadProductionFixture>('stakeholder', 'individual'); + const fixture = loadProductionFixture('stakeholder', 'individual'); const prepared = prepareFixture(fixture, 'stakeholder-individual'); // Create via batch API @@ -261,7 +269,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('stakeholder', prepared).execute(); + const result = await batch.create('stakeholder', parseOcfEntityInput('stakeholder', prepared)).execute(); expect(result.createdCids).toHaveLength(1); // Read back as OCF @@ -286,7 +294,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { issuerParty: ctx.issuerParty, }); - const fixture = loadProductionFixture>('stakeholder', 'institution'); + const fixture = loadProductionFixture('stakeholder', 'institution'); const prepared = prepareFixture(fixture, 'stakeholder-institution'); const batch = ctx.ocp.OpenCapTable.capTable.update({ @@ -295,7 +303,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('stakeholder', prepared).execute(); + const result = await batch.create('stakeholder', parseOcfEntityInput('stakeholder', prepared)).execute(); expect(result.createdCids).toHaveLength(1); const readBack = await ctx.ocp.OpenCapTable.stakeholder.get({ @@ -314,7 +322,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { issuerParty: ctx.issuerParty, }); - const fixture = loadProductionFixture>('stockLegendTemplate', 'rule-144'); + const fixture = loadProductionFixture('stockLegendTemplate', 'rule-144'); const prepared = prepareFixture(fixture, 'legend'); const batch = ctx.ocp.OpenCapTable.capTable.update({ @@ -323,7 +331,9 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('stockLegendTemplate', prepared).execute(); + const result = await batch + .create('stockLegendTemplate', parseOcfEntityInput('stockLegendTemplate', prepared)) + .execute(); expect(result.createdCids).toHaveLength(1); const readBack = await ctx.ocp.OpenCapTable.stockLegendTemplate.get({ @@ -342,7 +352,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { issuerParty: ctx.issuerParty, }); - const fixture = loadProductionFixture>('document', 'basic'); + const fixture = loadProductionFixture('document', 'basic'); const prepared = prepareFixture(fixture, 'document'); const batch = ctx.ocp.OpenCapTable.capTable.update({ @@ -351,7 +361,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('document', prepared).execute(); + const result = await batch.create('document', parseOcfEntityInput('document', prepared)).execute(); expect(result.createdCids).toHaveLength(1); const readBack = await ctx.ocp.OpenCapTable.document.get({ @@ -375,7 +385,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { issuerParty: ctx.issuerParty, }); - const fixture = loadProductionFixture>('vestingTerms', 'time-based-cliff'); + const fixture = loadProductionFixture('vestingTerms', 'time-based-cliff'); const prepared = prepareFixture(fixture, 'vesting-terms'); const batch = ctx.ocp.OpenCapTable.capTable.update({ @@ -384,7 +394,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('vestingTerms', prepared).execute(); + const result = await batch.create('vestingTerms', parseOcfEntityInput('vestingTerms', prepared)).execute(); expect(result.createdCids).toHaveLength(1); const readBack = await ctx.ocp.OpenCapTable.vestingTerms.get({ @@ -403,7 +413,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { issuerParty: ctx.issuerParty, }); - const fixture = loadProductionFixture>('stockPlan', 'basic'); + const fixture = loadProductionFixture('stockPlan', 'basic'); const prepared = prepareFixture(fixture, 'stock-plan'); const batch = ctx.ocp.OpenCapTable.capTable.update({ @@ -412,7 +422,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('stockPlan', prepared).execute(); + const result = await batch.create('stockPlan', parseOcfEntityInput('stockPlan', prepared)).execute(); expect(result.createdCids).toHaveLength(1); const readBack = await ctx.ocp.OpenCapTable.stockPlan.get({ @@ -442,7 +452,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { issuerParty: ctx.issuerParty, }); - const fixture = loadProductionFixture>('stockIssuance', 'founders-stock'); + const fixture = loadProductionFixture('stockIssuance', 'founders-stock'); const stockSecurity = await setupStockSecurity(ctx.ocp, { issuerContractId: issuerSetup.issuerContractId, issuerParty: ctx.issuerParty, @@ -465,7 +475,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('stockIssuance', prepared).execute(); + const result = await batch.create('stockIssuance', parseOcfEntityInput('stockIssuance', prepared)).execute(); expect(result.createdCids).toHaveLength(1); }); @@ -485,7 +495,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { capTableContractDetails: issuerSetup.capTableContractDetails, }); - const fixture = loadProductionFixture>('stockCancellation'); + const fixture = loadProductionFixture('stockCancellation'); const prepared = { ...prepareFixture(fixture, 'stock-cancel'), security_id: stockSecurity.securityId, // Use the real security_id from the issuance @@ -508,7 +518,9 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('stockCancellation', prepared).execute(); + const result = await batch + .create('stockCancellation', parseOcfEntityInput('stockCancellation', prepared)) + .execute(); expect(result.createdCids).toHaveLength(1); }); @@ -528,7 +540,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { capTableContractDetails: issuerSetup.capTableContractDetails, }); - const fixture = loadProductionFixture>('stockRepurchase'); + const fixture = loadProductionFixture('stockRepurchase'); const prepared = { ...prepareFixture(fixture, 'stock-repurchase'), security_id: stockSecurity.securityId, // Use the real security_id from the issuance @@ -551,7 +563,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('stockRepurchase', prepared).execute(); + const result = await batch.create('stockRepurchase', parseOcfEntityInput('stockRepurchase', prepared)).execute(); expect(result.createdCids).toHaveLength(1); }); @@ -571,7 +583,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { capTableContractDetails: issuerSetup.capTableContractDetails, }); - const fixture = loadProductionFixture>('stockTransfer'); + const fixture = loadProductionFixture('stockTransfer'); const prepared = { ...prepareFixture(fixture, 'stock-transfer'), security_id: stockSecurity.securityId, // Use the real security_id from the issuance @@ -594,7 +606,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('stockTransfer', prepared).execute(); + const result = await batch.create('stockTransfer', parseOcfEntityInput('stockTransfer', prepared)).execute(); expect(result.createdCids).toHaveLength(1); }); }); @@ -619,7 +631,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { issuerParty: ctx.issuerParty, }); - const fixture = loadProductionFixture>('convertibleIssuance', 'safe-post-money'); + const fixture = loadProductionFixture('convertibleIssuance', 'safe-post-money'); const stakeholder = await setupTestStakeholder(ctx.ocp, { issuerContractId: issuerSetup.issuerContractId, issuerParty: ctx.issuerParty, @@ -636,7 +648,9 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('convertibleIssuance', prepared).execute(); + const result = await batch + .create('convertibleIssuance', parseOcfEntityInput('convertibleIssuance', prepared)) + .execute(); expect(result.createdCids).toHaveLength(1); }); @@ -653,7 +667,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { issuerParty: ctx.issuerParty, }); - const fixture = loadProductionFixture>('convertibleCancellation'); + const fixture = loadProductionFixture('convertibleCancellation'); const convertibleSecurity = await setupConvertibleSecurity(ctx.ocp, { issuerContractId: issuerSetup.issuerContractId, issuerParty: ctx.issuerParty, @@ -675,7 +689,9 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('convertibleCancellation', prepared).execute(); + const result = await batch + .create('convertibleCancellation', parseOcfEntityInput('convertibleCancellation', prepared)) + .execute(); expect(result.createdCids).toHaveLength(1); }); @@ -692,7 +708,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { issuerParty: ctx.issuerParty, }); - const fixture = loadProductionFixture>('convertibleConversion'); + const fixture = loadProductionFixture('convertibleConversion'); const convertibleSecurity = await setupConvertibleSecurity(ctx.ocp, { issuerContractId: issuerSetup.issuerContractId, issuerParty: ctx.issuerParty, @@ -714,7 +730,9 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('convertibleConversion', prepared).execute(); + const result = await batch + .create('convertibleConversion', parseOcfEntityInput('convertibleConversion', prepared)) + .execute(); expect(result.createdCids).toHaveLength(1); }); @@ -734,7 +752,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { capTableContractDetails: issuerSetup.capTableContractDetails, }); - const fixture = loadProductionFixture>('convertibleTransfer'); + const fixture = loadProductionFixture('convertibleTransfer'); const prepared = { ...prepareFixture(fixture, 'convertible-transfer'), security_id: convertibleSecurity.securityId, // Use the real security_id from the issuance @@ -759,7 +777,9 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('convertibleTransfer', prepared).execute(); + const result = await batch + .create('convertibleTransfer', parseOcfEntityInput('convertibleTransfer', prepared)) + .execute(); expect(result.createdCids).toHaveLength(1); }); }); @@ -783,7 +803,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { issuerParty: ctx.issuerParty, }); - const fixture = loadProductionFixture>('equityCompensationIssuance', 'option-iso'); + const fixture = loadProductionFixture('equityCompensationIssuance', 'option-iso'); const eqCompSecurity = await setupEquityCompensationSecurity(ctx.ocp, { issuerContractId: issuerSetup.issuerContractId, issuerParty: ctx.issuerParty, @@ -819,7 +839,9 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('equityCompensationIssuance', prepared).execute(); + const result = await batch + .create('equityCompensationIssuance', parseOcfEntityInput('equityCompensationIssuance', prepared)) + .execute(); expect(result.createdCids).toHaveLength(1); }); @@ -839,7 +861,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { capTableContractDetails: issuerSetup.capTableContractDetails, }); - const fixture = loadProductionFixture>('equityCompensationCancellation'); + const fixture = loadProductionFixture('equityCompensationCancellation'); const prepared = { ...prepareFixture(fixture, 'equity-cancel'), security_id: eqCompSecurity.securityId, // Use the real security_id from the issuance @@ -864,7 +886,9 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('equityCompensationCancellation', prepared).execute(); + const result = await batch + .create('equityCompensationCancellation', parseOcfEntityInput('equityCompensationCancellation', prepared)) + .execute(); expect(result.createdCids).toHaveLength(1); }); @@ -884,7 +908,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { capTableContractDetails: issuerSetup.capTableContractDetails, }); - const fixture = loadProductionFixture>('equityCompensationExercise'); + const fixture = loadProductionFixture('equityCompensationExercise'); const prepared = { ...prepareFixture(fixture, 'equity-exercise'), security_id: eqCompSecurity.securityId, // Use the real security_id from the issuance @@ -909,7 +933,9 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('equityCompensationExercise', prepared).execute(); + const result = await batch + .create('equityCompensationExercise', parseOcfEntityInput('equityCompensationExercise', prepared)) + .execute(); expect(result.createdCids).toHaveLength(1); }); }); @@ -928,7 +954,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { issuerParty: ctx.issuerParty, }); - const fixture = loadProductionFixture>('issuerAuthorizedSharesAdjustment'); + const fixture = loadProductionFixture('issuerAuthorizedSharesAdjustment'); const prepared = prepareFixture(fixture, 'issuer-shares-adj'); const batch = ctx.ocp.OpenCapTable.capTable.update({ @@ -937,7 +963,9 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('issuerAuthorizedSharesAdjustment', prepared).execute(); + const result = await batch + .create('issuerAuthorizedSharesAdjustment', parseOcfEntityInput('issuerAuthorizedSharesAdjustment', prepared)) + .execute(); expect(result.createdCids).toHaveLength(1); }); @@ -954,7 +982,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { issuerParty: ctx.issuerParty, }); - const fixture = loadProductionFixture>('stockClassAuthorizedSharesAdjustment'); + const fixture = loadProductionFixture('stockClassAuthorizedSharesAdjustment'); const stockSecurity = await setupStockSecurity(ctx.ocp, { issuerContractId: issuerSetup.issuerContractId, issuerParty: ctx.issuerParty, @@ -976,7 +1004,12 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('stockClassAuthorizedSharesAdjustment', prepared).execute(); + const result = await batch + .create( + 'stockClassAuthorizedSharesAdjustment', + parseOcfEntityInput('stockClassAuthorizedSharesAdjustment', prepared) + ) + .execute(); expect(result.createdCids).toHaveLength(1); }); @@ -993,7 +1026,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { issuerParty: ctx.issuerParty, }); - const fixture = loadProductionFixture>('stockPlanPoolAdjustment'); + const fixture = loadProductionFixture('stockPlanPoolAdjustment'); const stockSecurity = await setupStockSecurity(ctx.ocp, { issuerContractId: issuerSetup.issuerContractId, issuerParty: ctx.issuerParty, @@ -1010,7 +1043,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { capTableContractDetails: stockSecurityCapTableDetails, issuerParty: ctx.issuerParty, stockClassId: stockSecurity.stockClassId, - stockPlanId: typeof fixturePrepared.stock_plan_id === 'string' ? fixturePrepared.stock_plan_id : undefined, + stockPlanId: requireFixtureString(fixturePrepared, 'stock_plan_id'), }); const prepared = { ...fixturePrepared, @@ -1023,7 +1056,9 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('stockPlanPoolAdjustment', prepared).execute(); + const result = await batch + .create('stockPlanPoolAdjustment', parseOcfEntityInput('stockPlanPoolAdjustment', prepared)) + .execute(); expect(result.createdCids).toHaveLength(1); }); @@ -1041,7 +1076,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { issuerParty: ctx.issuerParty, }); - const fixture = loadProductionFixture>('stockClassSplit'); + const fixture = loadProductionFixture('stockClassSplit'); const stockSecurity = await setupStockSecurity(ctx.ocp, { issuerContractId: issuerSetup.issuerContractId, issuerParty: ctx.issuerParty, @@ -1063,7 +1098,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('stockClassSplit', prepared).execute(); + const result = await batch.create('stockClassSplit', parseOcfEntityInput('stockClassSplit', prepared)).execute(); expect(result.createdCids).toHaveLength(1); }); }); @@ -1087,7 +1122,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { issuerParty: ctx.issuerParty, }); - const fixture = loadProductionFixture>('warrantIssuance'); + const fixture = loadProductionFixture('warrantIssuance'); const stakeholder = await setupTestStakeholder(ctx.ocp, { issuerContractId: issuerSetup.issuerContractId, issuerParty: ctx.issuerParty, @@ -1104,7 +1139,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('warrantIssuance', prepared).execute(); + const result = await batch.create('warrantIssuance', parseOcfEntityInput('warrantIssuance', prepared)).execute(); expect(result.createdCids).toHaveLength(1); }); }); @@ -1130,7 +1165,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { capTableContractDetails: issuerSetup.capTableContractDetails, }); - const fixture = loadProductionFixture>('vestingStart'); + const fixture = loadProductionFixture('vestingStart'); const prepared = { ...prepareFixture(fixture, 'vesting-start'), security_id: stockSecurity.securityId, // Use the real security_id from the issuance @@ -1153,7 +1188,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('vestingStart', prepared).execute(); + const result = await batch.create('vestingStart', parseOcfEntityInput('vestingStart', prepared)).execute(); expect(result.createdCids).toHaveLength(1); }); }); @@ -1179,7 +1214,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { capTableContractDetails: issuerSetup.capTableContractDetails, }); - const fixture = loadSyntheticFixture>('stockAcceptance'); + const fixture = loadSyntheticFixture('stockAcceptance'); const prepared = { ...prepareFixture(fixture, 'stock-acceptance'), security_id: stockSecurity.securityId, @@ -1201,7 +1236,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('stockAcceptance', prepared).execute(); + const result = await batch.create('stockAcceptance', parseOcfEntityInput('stockAcceptance', prepared)).execute(); expect(result.createdCids).toHaveLength(1); }); @@ -1221,7 +1256,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { capTableContractDetails: issuerSetup.capTableContractDetails, }); - const fixture = loadSyntheticFixture>('stockRetraction'); + const fixture = loadSyntheticFixture('stockRetraction'); const prepared = { ...prepareFixture(fixture, 'stock-retraction'), security_id: stockSecurity.securityId, @@ -1243,7 +1278,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('stockRetraction', prepared).execute(); + const result = await batch.create('stockRetraction', parseOcfEntityInput('stockRetraction', prepared)).execute(); expect(result.createdCids).toHaveLength(1); }); @@ -1260,7 +1295,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { issuerParty: ctx.issuerParty, }); - const fixture = loadSyntheticFixture>('stockConversion'); + const fixture = loadSyntheticFixture('stockConversion'); const stockSecurity = await setupStockSecurity(ctx.ocp, { issuerContractId: issuerSetup.issuerContractId, issuerParty: ctx.issuerParty, @@ -1282,7 +1317,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('stockConversion', prepared).execute(); + const result = await batch.create('stockConversion', parseOcfEntityInput('stockConversion', prepared)).execute(); expect(result.createdCids).toHaveLength(1); }); @@ -1302,7 +1337,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { capTableContractDetails: issuerSetup.capTableContractDetails, }); - const fixture = loadSyntheticFixture>('stockReissuance'); + const fixture = loadSyntheticFixture('stockReissuance'); const prepared = { ...prepareFixture(fixture, 'stock-reissuance'), security_id: stockSecurity.securityId, @@ -1324,7 +1359,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('stockReissuance', prepared).execute(); + const result = await batch.create('stockReissuance', parseOcfEntityInput('stockReissuance', prepared)).execute(); expect(result.createdCids).toHaveLength(1); }); @@ -1344,7 +1379,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { capTableContractDetails: issuerSetup.capTableContractDetails, }); - const fixture = loadSyntheticFixture>('stockConsolidation'); + const fixture = loadSyntheticFixture('stockConsolidation'); // StockConsolidation uses security_ids (plural array) not security_id const prepared = { ...prepareFixture(fixture, 'stock-consolidation'), @@ -1367,7 +1402,9 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('stockConsolidation', prepared).execute(); + const result = await batch + .create('stockConsolidation', parseOcfEntityInput('stockConsolidation', prepared)) + .execute(); expect(result.createdCids).toHaveLength(1); }); }); @@ -1389,7 +1426,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { capTableContractDetails: issuerSetup.capTableContractDetails, }); - const fixture = loadSyntheticFixture>('convertibleAcceptance'); + const fixture = loadSyntheticFixture('convertibleAcceptance'); const prepared = { ...prepareFixture(fixture, 'convertible-acceptance'), security_id: convertibleSecurity.securityId, @@ -1413,7 +1450,9 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('convertibleAcceptance', prepared).execute(); + const result = await batch + .create('convertibleAcceptance', parseOcfEntityInput('convertibleAcceptance', prepared)) + .execute(); expect(result.createdCids).toHaveLength(1); }); @@ -1433,7 +1472,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { capTableContractDetails: issuerSetup.capTableContractDetails, }); - const fixture = loadSyntheticFixture>('convertibleRetraction'); + const fixture = loadSyntheticFixture('convertibleRetraction'); const prepared = { ...prepareFixture(fixture, 'convertible-retraction'), security_id: convertibleSecurity.securityId, @@ -1457,7 +1496,9 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('convertibleRetraction', prepared).execute(); + const result = await batch + .create('convertibleRetraction', parseOcfEntityInput('convertibleRetraction', prepared)) + .execute(); expect(result.createdCids).toHaveLength(1); }); }); @@ -1479,7 +1520,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { capTableContractDetails: issuerSetup.capTableContractDetails, }); - const fixture = loadSyntheticFixture>('equityCompensationAcceptance'); + const fixture = loadSyntheticFixture('equityCompensationAcceptance'); const prepared = { ...prepareFixture(fixture, 'equity-acceptance'), security_id: eqCompSecurity.securityId, @@ -1501,7 +1542,9 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('equityCompensationAcceptance', prepared).execute(); + const result = await batch + .create('equityCompensationAcceptance', parseOcfEntityInput('equityCompensationAcceptance', prepared)) + .execute(); expect(result.createdCids).toHaveLength(1); }); @@ -1521,7 +1564,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { capTableContractDetails: issuerSetup.capTableContractDetails, }); - const fixture = loadSyntheticFixture>('equityCompensationTransfer'); + const fixture = loadSyntheticFixture('equityCompensationTransfer'); const prepared = { ...prepareFixture(fixture, 'equity-transfer'), security_id: eqCompSecurity.securityId, @@ -1543,7 +1586,9 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('equityCompensationTransfer', prepared).execute(); + const result = await batch + .create('equityCompensationTransfer', parseOcfEntityInput('equityCompensationTransfer', prepared)) + .execute(); expect(result.createdCids).toHaveLength(1); }); @@ -1563,7 +1608,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { capTableContractDetails: issuerSetup.capTableContractDetails, }); - const fixture = loadSyntheticFixture>('equityCompensationRetraction'); + const fixture = loadSyntheticFixture('equityCompensationRetraction'); const prepared = { ...prepareFixture(fixture, 'equity-retraction'), security_id: eqCompSecurity.securityId, @@ -1585,7 +1630,9 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('equityCompensationRetraction', prepared).execute(); + const result = await batch + .create('equityCompensationRetraction', parseOcfEntityInput('equityCompensationRetraction', prepared)) + .execute(); expect(result.createdCids).toHaveLength(1); }); @@ -1602,7 +1649,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { issuerParty: ctx.issuerParty, }); - const fixture = loadSyntheticFixture>('equityCompensationRelease'); + const fixture = loadSyntheticFixture('equityCompensationRelease'); const eqCompSecurity = await setupEquityCompensationSecurity(ctx.ocp, { issuerContractId: issuerSetup.issuerContractId, issuerParty: ctx.issuerParty, @@ -1624,7 +1671,9 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('equityCompensationRelease', prepared).execute(); + const result = await batch + .create('equityCompensationRelease', parseOcfEntityInput('equityCompensationRelease', prepared)) + .execute(); expect(result.createdCids).toHaveLength(1); }); @@ -1641,7 +1690,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { issuerParty: ctx.issuerParty, }); - const fixture = loadSyntheticFixture>('equityCompensationRepricing'); + const fixture = loadSyntheticFixture('equityCompensationRepricing'); const eqCompSecurity = await setupEquityCompensationSecurity(ctx.ocp, { issuerContractId: issuerSetup.issuerContractId, issuerParty: ctx.issuerParty, @@ -1663,7 +1712,9 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('equityCompensationRepricing', prepared).execute(); + const result = await batch + .create('equityCompensationRepricing', parseOcfEntityInput('equityCompensationRepricing', prepared)) + .execute(); expect(result.createdCids).toHaveLength(1); }); }); @@ -1685,7 +1736,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { capTableContractDetails: issuerSetup.capTableContractDetails, }); - const fixture = loadSyntheticFixture>('warrantAcceptance'); + const fixture = loadSyntheticFixture('warrantAcceptance'); const prepared = { ...prepareFixture(fixture, 'warrant-acceptance'), security_id: warrantSecurity.securityId, @@ -1707,7 +1758,9 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('warrantAcceptance', prepared).execute(); + const result = await batch + .create('warrantAcceptance', parseOcfEntityInput('warrantAcceptance', prepared)) + .execute(); expect(result.createdCids).toHaveLength(1); }); @@ -1727,7 +1780,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { capTableContractDetails: issuerSetup.capTableContractDetails, }); - const fixture = loadSyntheticFixture>('warrantTransfer'); + const fixture = loadSyntheticFixture('warrantTransfer'); const prepared = { ...prepareFixture(fixture, 'warrant-transfer'), security_id: warrantSecurity.securityId, @@ -1749,7 +1802,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('warrantTransfer', prepared).execute(); + const result = await batch.create('warrantTransfer', parseOcfEntityInput('warrantTransfer', prepared)).execute(); expect(result.createdCids).toHaveLength(1); }); @@ -1769,7 +1822,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { capTableContractDetails: issuerSetup.capTableContractDetails, }); - const fixture = loadSyntheticFixture>('warrantCancellation'); + const fixture = loadSyntheticFixture('warrantCancellation'); const prepared = { ...prepareFixture(fixture, 'warrant-cancellation'), security_id: warrantSecurity.securityId, @@ -1791,7 +1844,9 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('warrantCancellation', prepared).execute(); + const result = await batch + .create('warrantCancellation', parseOcfEntityInput('warrantCancellation', prepared)) + .execute(); expect(result.createdCids).toHaveLength(1); }); @@ -1808,7 +1863,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { issuerParty: ctx.issuerParty, }); - const fixture = loadSyntheticFixture>('warrantExercise'); + const fixture = loadSyntheticFixture('warrantExercise'); const warrantSecurity = await setupWarrantSecurity(ctx.ocp, { issuerContractId: issuerSetup.issuerContractId, issuerParty: ctx.issuerParty, @@ -1830,7 +1885,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('warrantExercise', prepared).execute(); + const result = await batch.create('warrantExercise', parseOcfEntityInput('warrantExercise', prepared)).execute(); expect(result.createdCids).toHaveLength(1); }); @@ -1850,7 +1905,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { capTableContractDetails: issuerSetup.capTableContractDetails, }); - const fixture = loadSyntheticFixture>('warrantRetraction'); + const fixture = loadSyntheticFixture('warrantRetraction'); const prepared = { ...prepareFixture(fixture, 'warrant-retraction'), security_id: warrantSecurity.securityId, @@ -1872,7 +1927,9 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('warrantRetraction', prepared).execute(); + const result = await batch + .create('warrantRetraction', parseOcfEntityInput('warrantRetraction', prepared)) + .execute(); expect(result.createdCids).toHaveLength(1); }); }); @@ -1894,7 +1951,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { capTableContractDetails: issuerSetup.capTableContractDetails, }); - const fixture = loadSyntheticFixture>('vestingEvent'); + const fixture = loadSyntheticFixture('vestingEvent'); const prepared = { ...prepareFixture(fixture, 'vesting-event'), security_id: stockSecurity.securityId, @@ -1916,7 +1973,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('vestingEvent', prepared).execute(); + const result = await batch.create('vestingEvent', parseOcfEntityInput('vestingEvent', prepared)).execute(); expect(result.createdCids).toHaveLength(1); }); @@ -1936,7 +1993,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { capTableContractDetails: issuerSetup.capTableContractDetails, }); - const fixture = loadSyntheticFixture>('vestingAcceleration'); + const fixture = loadSyntheticFixture('vestingAcceleration'); const prepared = { ...prepareFixture(fixture, 'vesting-acceleration'), security_id: stockSecurity.securityId, @@ -1958,7 +2015,9 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('vestingAcceleration', prepared).execute(); + const result = await batch + .create('vestingAcceleration', parseOcfEntityInput('vestingAcceleration', prepared)) + .execute(); expect(result.createdCids).toHaveLength(1); }); @@ -1977,11 +2036,12 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { capTableContractDetails: issuerSetup.capTableContractDetails, }); - const fixture = loadSyntheticFixture>('stakeholderRelationshipChangeEvent'); - const prepared = { + const fixture = loadSyntheticFixture('stakeholderRelationshipChangeEvent'); + const preparedInput: Record = { ...prepareFixture(fixture, 'relationship-change'), stakeholder_id: stakeholderSetup.stakeholderData.id, }; + const prepared = parseOcfEntityInput('stakeholderRelationshipChangeEvent', preparedInput); const batch = ctx.ocp.OpenCapTable.capTable.update({ capTableContractId: stakeholderSetup.newCapTableContractId, @@ -2020,8 +2080,8 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { capTableContractDetails: issuerSetup.capTableContractDetails, }); - const fixture = loadSyntheticFixture>('stakeholderStatusChangeEvent'); - const canonicalInput = { + const fixture = loadSyntheticFixture('stakeholderStatusChangeEvent'); + const canonicalInput: Record = { ...prepareFixture(fixture, 'status-change'), stakeholder_id: stakeholderSetup.stakeholderData.id, }; @@ -2067,7 +2127,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { issuerParty: ctx.issuerParty, }); - const fixture = loadSyntheticFixture>('stockPlanReturnToPool'); + const fixture = loadSyntheticFixture('stockPlanReturnToPool'); const eqCompSecurity = await setupEquityCompensationSecurity(ctx.ocp, { issuerContractId: issuerSetup.issuerContractId, issuerParty: ctx.issuerParty, @@ -2084,7 +2144,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { capTableContractDetails: eqCompCapTableDetails, issuerParty: ctx.issuerParty, stockClassId: eqCompSecurity.stockClassId, - stockPlanId: typeof fixturePrepared.stock_plan_id === 'string' ? fixturePrepared.stock_plan_id : undefined, + stockPlanId: requireFixtureString(fixturePrepared, 'stock_plan_id'), }); const prepared = { ...fixturePrepared, @@ -2098,7 +2158,9 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('stockPlanReturnToPool', prepared).execute(); + const result = await batch + .create('stockPlanReturnToPool', parseOcfEntityInput('stockPlanReturnToPool', prepared)) + .execute(); expect(result.createdCids).toHaveLength(1); }); }); @@ -2118,7 +2180,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { issuerParty: ctx.issuerParty, }); - const fixture = loadSyntheticFixture>('stockClassConversionRatioAdjustment'); + const fixture = loadSyntheticFixture('stockClassConversionRatioAdjustment'); const stockSecurity = await setupStockSecurity(ctx.ocp, { issuerContractId: issuerSetup.issuerContractId, issuerParty: ctx.issuerParty, @@ -2140,7 +2202,12 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { actAs: [ctx.issuerParty], }); - const result = await batch.create('stockClassConversionRatioAdjustment', prepared).execute(); + const result = await batch + .create( + 'stockClassConversionRatioAdjustment', + parseOcfEntityInput('stockClassConversionRatioAdjustment', prepared) + ) + .execute(); expect(result.createdCids).toHaveLength(1); }); }); diff --git a/test/integration/utils/setupTestData.ts b/test/integration/utils/setupTestData.ts index 92d25d72..10f5ec64 100644 --- a/test/integration/utils/setupTestData.ts +++ b/test/integration/utils/setupTestData.ts @@ -46,6 +46,7 @@ import type { } from '../../../src/types/native'; import { damlTimeToDateString } from '../../../src/utils/typeConversions'; import { createValidatorApiClient } from '../../utils/cantonNodeSdkCompat'; +import { extractContractIdFromTransactionTree } from '../../utils/fixtureHelpers'; import { authorizeIssuerWithFactory } from '../setup/contractDeployment'; import { requireCreatedEventBlob } from './transactionHelpers'; @@ -668,22 +669,7 @@ function extractContractIdFromResponse( response: SubmitAndWaitForTransactionTreeResponse, templateIdContains: string ): string { - const tree = response.transactionTree; - const treeAny = tree as any; - const eventsById: Record = treeAny.eventsById ?? treeAny.transaction?.eventsById ?? {}; - - for (const event of Object.values(eventsById)) { - const eventData = event as Record; - if (eventData.CreatedTreeEvent) { - const created = (eventData.CreatedTreeEvent as Record).value as Record; - const templateId = created.templateId as string; - const isMatch = templateId.includes(`:${templateIdContains}:`) || templateId.endsWith(`:${templateIdContains}`); - if (isMatch) { - return created.contractId as string; - } - } - } - return ''; + return extractContractIdFromTransactionTree(response, templateIdContains); } /** Extract the new CapTable contract details from a transaction result. */ diff --git a/test/mocks/fairmint-canton-node-sdk.ts b/test/mocks/fairmint-canton-node-sdk.ts index 1ffd7afc..d622efe0 100644 --- a/test/mocks/fairmint-canton-node-sdk.ts +++ b/test/mocks/fairmint-canton-node-sdk.ts @@ -1,25 +1,65 @@ // Minimal mock of @fairmint/canton-node-sdk to avoid real network import type { ClientConfig } from '@fairmint/canton-node-sdk'; -import type { SubmitAndWaitForTransactionTreeResponse } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/operations'; +import type { + SubmitAndWaitForTransactionTreeParams, + SubmitAndWaitForTransactionTreeResponse, +} from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/operations'; +import type { DisclosedContract } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/schemas/api/commands'; +import type { CreatedTreeEventWrapper } from '@fairmint/canton-node-sdk/build/src/utils/contracts/findCreatedEvent'; +import fs from 'fs'; +import path from 'path'; +import { getCurrentEventsFixture, getCurrentFixture, validateRequestMatchesFixture } from '../utils/fixtureHelpers'; + +function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function requireRecord(value: unknown, description: string): Record { + const record = asRecord(value); + if (!record) { + throw new Error(`Invalid mock fixture: ${description} must be an object`); + } + return record; +} + +function requireString(value: unknown, description: string): string { + if (typeof value !== 'string') { + throw new Error(`Invalid mock fixture: ${description} must be a string`); + } + return value; +} + +function isCreatedTreeEventWrapper(value: unknown): value is CreatedTreeEventWrapper { + const event = asRecord(value); + const createdTreeEvent = asRecord(event?.CreatedTreeEvent); + const createdValue = asRecord(createdTreeEvent?.value); + return typeof createdValue?.templateId === 'string' && typeof createdValue.contractId === 'string'; +} export class LedgerJsonApiClient { private readonly config: ClientConfig | undefined; public static __instances: LedgerJsonApiClient[] = []; public lastAuthToken?: string; private __getAuthToken?: () => Promise | string; + private __eventsResponseOverride?: unknown; + private __activeContractsResponseOverride?: readonly unknown[]; public submitAndWaitForTransactionTree = jest.fn( - async (req: any): Promise => { + async (req: SubmitAndWaitForTransactionTreeParams): Promise => { const provider = this.__getAuthToken; if (provider) { const tok = await provider(); this.lastAuthToken = typeof tok === 'string' ? tok : String(tok); } // Check if there's a fixture configured and validate request matches - const { getCurrentFixture, validateRequestMatchesFixture } = require('../utils/fixtureHelpers'); const fixture = getCurrentFixture(); if (fixture) { validateRequestMatchesFixture(req); + if (!fixture.response) { + throw new Error('Transaction fixture is missing its response payload'); + } return fixture.response; } @@ -33,28 +73,29 @@ export class LedgerJsonApiClient { public getEventsByContractId = jest.fn((req: { contractId: string }) => { // Allow tests to override via helper - const override = (this as any).__eventsResponseOverride; + const override = this.__eventsResponseOverride; if (override) return override; // Check if there's an events fixture configured - const { getCurrentEventsFixture } = require('../utils/fixtureHelpers'); const eventsFixture = getCurrentEventsFixture(); if (eventsFixture) { return eventsFixture; } // No fixture configured - this is an error - const error: any = new Error( - `No events fixture configured. Use setEventsFixtureData() in your test setup. ` + `Contract ID: ${req.contractId}` + const error = Object.assign( + new Error( + `No events fixture configured. Use setEventsFixtureData() in your test setup. ` + + `Contract ID: ${req.contractId}` + ), + { code: 404, body: { code: 'CONTRACT_EVENTS_NOT_FOUND' } } ); - error.code = 404; - error.body = { code: 'CONTRACT_EVENTS_NOT_FOUND' }; throw error; }); public getActiveContracts = jest.fn(async () => { // Allow tests to override via helper - const override = (this as any).__activeContractsResponseOverride; + const override = this.__activeContractsResponseOverride; if (override !== undefined) return Promise.resolve(override); // No fixture configured - this is an error (consistent with other mock methods) @@ -64,22 +105,22 @@ export class LedgerJsonApiClient { ); }); - __setActiveContractsResponse(resp: unknown[]) { - (this as any).__activeContractsResponseOverride = resp; + __setActiveContractsResponse(resp: readonly unknown[]): void { + this.__activeContractsResponseOverride = resp; (this.getActiveContracts as jest.Mock).mockResolvedValue(resp); } constructor(config?: ClientConfig) { this.config = config; - (LedgerJsonApiClient.__instances as any).push(this); + LedgerJsonApiClient.__instances.push(this); } public getNetwork(): string { return this.config?.network ?? 'dev'; } - __setEventsResponse(resp: any) { - (this as any).__eventsResponseOverride = resp; + __setEventsResponse(resp: unknown): void { + this.__eventsResponseOverride = resp; (this.getEventsByContractId as jest.Mock).mockResolvedValue(resp); } @@ -121,24 +162,20 @@ export class BaseClient { export class ValidatorApiClient extends BaseClient { public static __instances: ValidatorApiClient[] = []; - public lookupFeaturedAppRight = jest.fn(() => { - const path = require('path'); - const fs = require('fs'); + public lookupFeaturedAppRight = jest.fn((_params: { partyId: string }) => { const fixturePath = path.join(__dirname, '..', 'fixtures', 'validatorApi', 'featured-app-right.json'); - const data = JSON.parse(fs.readFileSync(fixturePath, 'utf-8')); + const data: unknown = JSON.parse(fs.readFileSync(fixturePath, 'utf-8')); return data; }); public getAmuletRules = jest.fn(() => { - const path = require('path'); - const fs = require('fs'); const fixturePath = path.join(__dirname, '..', 'fixtures', 'validatorApi', 'amulet-rules.json'); - const data = JSON.parse(fs.readFileSync(fixturePath, 'utf-8')); + const data: unknown = JSON.parse(fs.readFileSync(fixturePath, 'utf-8')); return data; }); constructor(config: ClientConfig) { super('VALIDATOR_API', config); - (ValidatorApiClient.__instances as any).push(this); + ValidatorApiClient.__instances.push(this); } public override async getAuthToken(): Promise { @@ -179,67 +216,49 @@ export class Canton { } // Export the getFeaturedAppRightContractDetails function -export async function getFeaturedAppRightContractDetails(validatorApi: ValidatorApiClient): Promise { - const featuredAppRight = await validatorApi.lookupFeaturedAppRight(); - if (!featuredAppRight?.featured_app_right) { - throw new Error(`No featured app right found for party ${validatorApi.getPartyId()}`); - } - // The featured-apps endpoint may not include the synchronizer/domain id. - // Fallback to amulet rules which reliably expose the domain_id to use as synchronizerId. - const amuletRules = await validatorApi.getAmuletRules(); - const synchronizerIdFromRules = amuletRules?.amulet_rules?.domain_id ?? ''; +export async function getFeaturedAppRightContractDetails(validatorApi: ValidatorApiClient): Promise { + const partyId = validatorApi.getPartyId(); + const response = requireRecord(await validatorApi.lookupFeaturedAppRight({ partyId }), 'featured app right response'); + const featuredAppRight = asRecord(response.featured_app_right); + if (!featuredAppRight) { + throw new Error(`No featured app right found for party ${partyId}`); + } + // Match canton-node-sdk: the featured-app endpoint is not the synchronizer source. + // Amulet rules reliably expose the domain_id used as synchronizerId. + const amuletRulesResponse = requireRecord(await validatorApi.getAmuletRules(), 'amulet rules response'); + const amuletRules = requireRecord(amuletRulesResponse.amulet_rules, 'amulet rules'); + const synchronizerId = requireString(amuletRules.domain_id, 'amulet rules domain_id'); return { - contractId: featuredAppRight.featured_app_right.contract_id, - createdEventBlob: featuredAppRight.featured_app_right.created_event_blob, - synchronizerId: featuredAppRight?.featured_app_right?.domain_id ?? synchronizerIdFromRules, - templateId: featuredAppRight.featured_app_right.template_id, + contractId: requireString(featuredAppRight.contract_id, 'featured app right contract_id'), + createdEventBlob: requireString(featuredAppRight.created_event_blob, 'featured app right created_event_blob'), + synchronizerId, + templateId: requireString(featuredAppRight.template_id, 'featured app right template_id'), }; } // Export the findCreatedEventByTemplateId function -export function findCreatedEventByTemplateId(response: any, templateId: string): any { - // Handle both direct structure and nested transaction structure - const { transactionTree } = response; - const eventsById = transactionTree?.eventsById ?? transactionTree?.transaction?.eventsById; - - // Mock implementation - look for CreatedTreeEvent in the transactionTree - if (eventsById) { - for (const [_key, event] of Object.entries(eventsById)) { - const eventData = event as any; - const eventTemplateId = eventData?.CreatedTreeEvent?.value?.templateId; - - // Handle different template ID formats - if (eventTemplateId === templateId) { - return eventData; - } - - // Handle the case where templateId starts with # (package name alias) but event has full hash - if (templateId.startsWith('#') && eventTemplateId) { - const templateNamePart = templateId.split(':').slice(1).join(':'); - const eventNamePart = eventTemplateId.split(':').slice(1).join(':'); - if (templateNamePart === eventNamePart) { - return eventData; - } - } - - // Handle the case where templateId is in pkg: format but event has full template ID - if (templateId.startsWith('pkg:') && eventTemplateId) { - const pkgName = templateId.replace('pkg:', ''); - // Check if the template name part matches (after the hash) - const templateNamePart = eventTemplateId.split(':').slice(1).join(':'); - if (templateNamePart === pkgName) { - return eventData; - } - } - } +export function findCreatedEventByTemplateId( + response: SubmitAndWaitForTransactionTreeResponse, + templateId: string +): CreatedTreeEventWrapper | undefined { + const expectedSuffix = templateId.includes(':') ? templateId.substring(templateId.indexOf(':') + 1) : templateId; + const transactionTree = asRecord(response.transactionTree); + const nestedTransaction = asRecord(transactionTree?.transaction); + const eventsById = asRecord(transactionTree?.eventsById) ?? asRecord(nestedTransaction?.eventsById); + if (!eventsById) { + return undefined; } - // If not found in transactionTree, try the old structure for backward compatibility - if (response?.transaction?.events) { - for (const event of response.transaction.events) { - if (event.kind?.JsCreated?.templateId === templateId) { + + for (const event of Object.values(eventsById)) { + if (isCreatedTreeEventWrapper(event)) { + const actualTemplateId = event.CreatedTreeEvent.value.templateId; + const actualSuffix = actualTemplateId.includes(':') + ? actualTemplateId.substring(actualTemplateId.indexOf(':') + 1) + : actualTemplateId; + if (actualSuffix === expectedSuffix) { return event; } } } - return null; + return undefined; } diff --git a/test/mocks/findCreatedEventByTemplateId.test.ts b/test/mocks/findCreatedEventByTemplateId.test.ts new file mode 100644 index 00000000..d8ff47c7 --- /dev/null +++ b/test/mocks/findCreatedEventByTemplateId.test.ts @@ -0,0 +1,54 @@ +import type { SubmitAndWaitForTransactionTreeResponse } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/operations'; +import { findCreatedEventByTemplateId } from '../mocks/fairmint-canton-node-sdk'; + +describe('findCreatedEventByTemplateId mock compatibility', () => { + const createdEvent = { + CreatedTreeEvent: { + value: { + templateId: 'pkg:Fairmint.OpenCapTable.Stakeholder:Stakeholder', + contractId: 'cid', + }, + }, + }; + + test.each([ + { + name: 'direct transaction tree', + transactionTree: { + eventsById: { + '0': createdEvent, + }, + }, + }, + { + name: 'legacy nested transaction tree', + transactionTree: { + transaction: { + eventsById: { + '0': createdEvent, + }, + }, + }, + }, + ])('finds a created event from the $name shape', ({ transactionTree }) => { + // The nested form is an intentionally supported legacy fixture shape outside the current SDK declaration. + const response = { transactionTree } as unknown as SubmitAndWaitForTransactionTreeResponse; + + const result = findCreatedEventByTemplateId(response, 'pkg:Fairmint.OpenCapTable.Stakeholder:Stakeholder'); + + expect(result).toEqual(createdEvent); + }); + + test('ignores malformed and non-created event wrappers', () => { + const response = { + transactionTree: { + eventsById: { + '0': { CreatedTreeEvent: { value: { templateId: 42, contractId: 'cid' } } }, + '1': { ExercisedTreeEvent: { value: {} } }, + }, + }, + } as unknown as SubmitAndWaitForTransactionTreeResponse; + + expect(findCreatedEventByTemplateId(response, 'Fairmint.OpenCapTable.Stakeholder:Stakeholder')).toBeUndefined(); + }); +}); diff --git a/test/package-consumer/publicEntrypoint.types.ts b/test/package-consumer/publicEntrypoint.types.ts new file mode 100644 index 00000000..2077d33e --- /dev/null +++ b/test/package-consumer/publicEntrypoint.types.ts @@ -0,0 +1,88 @@ +/** Compile the installed-package surface through package.json exports, not a direct dist path. */ +import type { + EnvironmentConfigInput, + NonLocalOAuth2EnvironmentConfigInput, + OcfObject, + OcpClient, + OcpEnvironment, + OcpValidationError, + SharedSecretEnvironmentConfigInput, + SubmitAndWaitForTransactionTreeResponse, +} from '@open-captable-protocol/canton'; +import type { Assert, IsExactly } from '../typeContracts/typeAssertions'; + +const packageExactnessRejectsCompilerAny: Assert< + IsExactly, 'canonical'>, false> +> = true; + +interface NestedCompilerAny { + readonly config: { readonly authUrl: ReturnType }; +} + +interface NestedCanonicalConfig { + readonly config: { readonly authUrl: string }; +} + +const packageExactnessRejectsNestedCompilerAny: Assert< + IsExactly, false> +> = true; + +interface RequiredOAuth2Credentials { + readonly authUrl: string; + readonly clientId: string; + readonly clientSecret: string; +} + +const packageOAuth2CredentialsStayRequired: Assert< + IsExactly, RequiredOAuth2Credentials> +> = true; +const packageSharedSecretEnvironmentsStayExact: Assert< + IsExactly> +> = true; +const packageMainNetNeverSupportsSharedSecret: Assert< + IsExactly, never> +> = true; + +declare const client: OcpClient; +declare const environmentInput: EnvironmentConfigInput; +declare const ocfObject: OcfObject; +declare const validationError: OcpValidationError; +declare const transactionResponse: SubmitAndWaitForTransactionTreeResponse; + +const packageEntryPointExposesValidationValue: unknown = validationError.receivedValue; +const packageEntryPointExposesTransactionTree: SubmitAndWaitForTransactionTreeResponse['transactionTree'] = + transactionResponse.transactionTree; + +interface ExactOptionalCompilerProbe { + readonly value?: string; +} + +const omittedOptionalProperty: ExactOptionalCompilerProbe = {}; +// @ts-expect-error Package-consumer checks must run with exactOptionalPropertyTypes enabled. +const explicitUndefinedOptionalProperty: ExactOptionalCompilerProbe = { value: undefined }; + +declare const indexedStrings: readonly string[]; +// @ts-expect-error Package-consumer checks must run with noUncheckedIndexedAccess enabled. +const uncheckedIndexedString: string = indexedStrings[0]; + +const packageEntryPointKeepsEnvironmentDiscriminant: Assert< + IsExactly< + EnvironmentConfigInput['environment'], + 'localnet' | 'scratchnet' | 'devnet' | 'testnet' | 'staging' | 'mainnet' | 'custom' + > +> = true; + +void client; +void environmentInput; +void ocfObject; +void packageEntryPointExposesValidationValue; +void packageEntryPointExposesTransactionTree; +void omittedOptionalProperty; +void explicitUndefinedOptionalProperty; +void uncheckedIndexedString; +void packageEntryPointKeepsEnvironmentDiscriminant; +void packageExactnessRejectsCompilerAny; +void packageExactnessRejectsNestedCompilerAny; +void packageOAuth2CredentialsStayRequired; +void packageSharedSecretEnvironmentsStayExact; +void packageMainNetNeverSupportsSharedSecret; diff --git a/test/schemaAlignment/conversionTriggerShapes.test.ts b/test/schemaAlignment/conversionTriggerShapes.test.ts index b997ec6b..b8667a8d 100644 --- a/test/schemaAlignment/conversionTriggerShapes.test.ts +++ b/test/schemaAlignment/conversionTriggerShapes.test.ts @@ -3,7 +3,7 @@ import { parseOcfObject } from '../../src/utils/ocfZodSchemas'; import { requireFirst } from '../../src/utils/requireDefined'; import { loadProductionFixture } from '../utils/productionFixtures'; -const convertibleFixture = loadProductionFixture>('convertibleIssuance', 'safe-post-money'); +const convertibleFixture = loadProductionFixture('convertibleIssuance', 'safe-post-money'); const fixtureTrigger = requireFirst( convertibleFixture.conversion_triggers as Array>, 'fixture conversion trigger' diff --git a/test/utils/fixtureHelpers.test.ts b/test/utils/fixtureHelpers.test.ts new file mode 100644 index 00000000..09dd24d2 --- /dev/null +++ b/test/utils/fixtureHelpers.test.ts @@ -0,0 +1,167 @@ +import { convertTransactionTreeToEventsResponse, extractContractIdFromTransactionTree } from './fixtureHelpers'; + +const createdValue = { + contractId: 'contract-1', + templateId: 'package-hash:Fairmint.OpenCapTable:CapTable', +}; + +function directTree(value: unknown): Record { + return { + transactionTree: { + eventsById: { + '0': { CreatedTreeEvent: { value } }, + }, + }, + }; +} + +function nestedTree(value: unknown): Record { + return { + transactionTree: { + transaction: { + eventsById: { + '0': { CreatedTreeEvent: { value } }, + }, + }, + }, + }; +} + +function createdEvent(value: unknown): Record { + return { CreatedTreeEvent: { value } }; +} + +describe('transaction-tree fixture guards', () => { + it.each([ + ['direct', directTree(createdValue)], + ['legacy nested', nestedTree(createdValue)], + ])('extracts a valid %s created event', (_name, response) => { + expect(convertTransactionTreeToEventsResponse(response, 'synchronizer-1')).toEqual({ + archived: null, + created: { + createdEvent: createdValue, + synchronizerId: 'synchronizer-1', + }, + }); + expect(extractContractIdFromTransactionTree(response, 'CapTable')).toBe('contract-1'); + }); + + it('selects multiple created events by node ID rather than object insertion order', () => { + const firstCreatedValue = { ...createdValue, contractId: 'contract-a' }; + const lastCreatedValue = { ...createdValue, contractId: 'contract-z' }; + const responses = [ + { + transactionTree: { + eventsById: { + 'node-z': createdEvent(lastCreatedValue), + 'node-a': createdEvent(firstCreatedValue), + }, + }, + }, + { + transactionTree: { + eventsById: { + 'node-a': createdEvent(firstCreatedValue), + 'node-z': createdEvent(lastCreatedValue), + }, + }, + }, + ]; + + for (const response of responses) { + expect(convertTransactionTreeToEventsResponse(response, 'synchronizer-1')).toEqual({ + archived: null, + created: { + createdEvent: lastCreatedValue, + synchronizerId: 'synchronizer-1', + }, + }); + expect(extractContractIdFromTransactionTree(response, 'CapTable')).toBe('contract-a'); + } + }); + + it('compares decimal ledger node IDs numerically', () => { + const nodeTwoValue = { ...createdValue, contractId: 'contract-2' }; + const nodeTenValue = { ...createdValue, contractId: 'contract-10' }; + const response = { + transactionTree: { + eventsById: { + '10': createdEvent(nodeTenValue), + '2': createdEvent(nodeTwoValue), + }, + }, + }; + + expect(convertTransactionTreeToEventsResponse(response, 'synchronizer-1')).toMatchObject({ + created: { createdEvent: nodeTenValue }, + }); + expect(extractContractIdFromTransactionTree(response, 'CapTable')).toBe('contract-2'); + }); + + it.each([ + { + name: 'null event', + response: { transactionTree: { eventsById: { '0': null } } }, + path: 'transactionTree.eventsById.0', + expectation: 'expected an object', + }, + { + name: 'non-object event', + response: { transactionTree: { eventsById: { '0': 42 } } }, + path: 'transactionTree.eventsById.0', + expectation: 'expected an object', + }, + { + name: 'non-object created value', + response: directTree('not an object'), + path: 'transactionTree.eventsById.0.CreatedTreeEvent.value', + expectation: 'expected an object', + }, + { + name: 'non-string template id', + response: directTree({ ...createdValue, templateId: 42 }), + path: 'transactionTree.eventsById.0.CreatedTreeEvent.value.templateId', + expectation: 'expected a string', + }, + { + name: 'non-string contract id', + response: directTree({ ...createdValue, contractId: 42 }), + path: 'transactionTree.eventsById.0.CreatedTreeEvent.value.contractId', + expectation: 'expected a string', + }, + ])('rejects a malformed $name with a field-specific error', ({ response, path, expectation }) => { + const message = `Invalid transaction tree at ${path}: ${expectation}`; + expect(() => convertTransactionTreeToEventsResponse(response, 'synchronizer-1')).toThrow(message); + expect(() => extractContractIdFromTransactionTree(response, 'CapTable')).toThrow(message); + }); + + it('rejects malformed nested events with the nested field path', () => { + const response = { + transactionTree: { + transaction: { + eventsById: { '7': null }, + }, + }, + }; + + expect(() => extractContractIdFromTransactionTree(response, 'CapTable')).toThrow( + 'Invalid transaction tree at transactionTree.transaction.eventsById.7: expected an object' + ); + }); + + it('bounds hostile node IDs in field-specific diagnostics', () => { + const hostileNodeId = 'x'.repeat(10_000); + const response = { transactionTree: { eventsById: { [hostileNodeId]: null } } }; + + try { + extractContractIdFromTransactionTree(response, 'CapTable'); + throw new Error('Expected malformed transaction tree to throw'); + } catch (error) { + expect(error).toBeInstanceOf(Error); + const { message } = error as Error; + expect(message).toContain('[10000 chars]'); + expect(message).not.toContain(hostileNodeId); + expect(message.length).toBeLessThan(700); + } + }); +}); diff --git a/test/utils/fixtureHelpers.ts b/test/utils/fixtureHelpers.ts index 78f32409..3d9dd8f2 100644 --- a/test/utils/fixtureHelpers.ts +++ b/test/utils/fixtureHelpers.ts @@ -1,4 +1,5 @@ import type { SubmitAndWaitForTransactionTreeResponse } from '@fairmint/canton-node-sdk/build/src/clients/ledger-json-api/operations'; +import { diagnosticPropertyPath } from '../../src/errors/diagnosticValue'; export interface TransactionTreeFixture { timestamp: string; @@ -19,6 +20,103 @@ export interface TransactionTreeFixture { let currentFixture: TransactionTreeFixture | null = null; let currentEventsFixture: Record | null = null; +function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function requireRecord(value: unknown, fieldPath: string): Record { + const record = asRecord(value); + if (!record) { + throw new Error(`Invalid transaction tree at ${fieldPath}: expected an object`); + } + return record; +} + +function requireString(value: unknown, fieldPath: string): string { + if (typeof value !== 'string') { + throw new Error(`Invalid transaction tree at ${fieldPath}: expected a string`); + } + return value; +} + +interface CreatedTreeEventRecord { + contractId: string; + value: Record; + templateId: string; +} + +function compareCodeUnits(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function isCanonicalDecimalNodeId(value: string): boolean { + return /^(?:0|[1-9]\d*)$/.test(value); +} + +/** Order ledger node IDs independently of object insertion order. */ +function compareTransactionNodeIds(left: string, right: string): number { + const leftIsDecimal = isCanonicalDecimalNodeId(left); + const rightIsDecimal = isCanonicalDecimalNodeId(right); + if (leftIsDecimal && rightIsDecimal) { + return left.length - right.length || compareCodeUnits(left, right); + } + if (leftIsDecimal !== rightIsDecimal) return leftIsDecimal ? -1 : 1; + return compareCodeUnits(left, right); +} + +function transactionTreeEventsById(response: unknown): { + eventsById: Record; + fieldPath: string; +} { + const responseRecord = requireRecord(response, 'response'); + const transactionTree = requireRecord(responseRecord.transactionTree, 'transactionTree'); + + if (Object.prototype.hasOwnProperty.call(transactionTree, 'eventsById')) { + return { + eventsById: requireRecord(transactionTree.eventsById, 'transactionTree.eventsById'), + fieldPath: 'transactionTree.eventsById', + }; + } + + if (Object.prototype.hasOwnProperty.call(transactionTree, 'transaction')) { + const nestedTransaction = requireRecord(transactionTree.transaction, 'transactionTree.transaction'); + if (Object.prototype.hasOwnProperty.call(nestedTransaction, 'eventsById')) { + return { + eventsById: requireRecord(nestedTransaction.eventsById, 'transactionTree.transaction.eventsById'), + fieldPath: 'transactionTree.transaction.eventsById', + }; + } + } + + throw new Error('No eventsById in transaction tree'); +} + +function createdTreeEventRecords(response: unknown): CreatedTreeEventRecord[] { + const { eventsById, fieldPath } = transactionTreeEventsById(response); + const createdEvents: CreatedTreeEventRecord[] = []; + + const entries = Object.entries(eventsById).sort(([leftNodeId], [rightNodeId]) => + compareTransactionNodeIds(leftNodeId, rightNodeId) + ); + for (const [nodeId, event] of entries) { + const eventPath = diagnosticPropertyPath(fieldPath, nodeId); + const eventRecord = requireRecord(event, eventPath); + if (!Object.prototype.hasOwnProperty.call(eventRecord, 'CreatedTreeEvent')) continue; + + const createdTreeEvent = requireRecord(eventRecord.CreatedTreeEvent, `${eventPath}.CreatedTreeEvent`); + const value = requireRecord(createdTreeEvent.value, `${eventPath}.CreatedTreeEvent.value`); + createdEvents.push({ + contractId: requireString(value.contractId, `${eventPath}.CreatedTreeEvent.value.contractId`), + templateId: requireString(value.templateId, `${eventPath}.CreatedTreeEvent.value.templateId`), + value, + }); + } + + return createdEvents; +} + /** * Configure the mock with a fixture object directly (no file I/O) * @@ -28,11 +126,6 @@ export function setTransactionTreeFixtureData(fixture: TransactionTreeFixture): currentFixture = fixture; } -/** Clear the current fixture configuration */ -export function clearTransactionTreeFixture(): void { - currentFixture = null; -} - /** Get the current fixture (used internally by mocks) */ export function getCurrentFixture(): TransactionTreeFixture | null { return currentFixture; @@ -47,37 +140,25 @@ export function setEventsFixtureData(eventsData: Record): void currentEventsFixture = eventsData; } -/** Clear the current events fixture configuration */ -export function clearEventsFixture(): void { - currentEventsFixture = null; -} - /** Get the current events fixture (used internally by mocks) */ export function getCurrentEventsFixture(): Record | null { return currentEventsFixture; } -/** Convert transaction tree response to events response format Extracts the created event from the transaction tree */ +/** + * Convert a transaction tree to an events response. + * + * When the tree contains multiple creates, the event with the greatest ledger node ID wins. + */ export function convertTransactionTreeToEventsResponse( response: SubmitAndWaitForTransactionTreeResponse | Record, synchronizerId: string ): Record { - // Handle both structures: response.transactionTree.eventsById and response.transactionTree.transaction.eventsById - const transactionTree = response.transactionTree as any; - const eventsById = transactionTree?.eventsById ?? transactionTree?.transaction?.eventsById; - - if (!eventsById) { - throw new Error('No eventsById in transaction tree'); - } - - // Find the created event (usually the last event with CreatedTreeEvent) - let createdEvent: Record | null = null; - for (const [_nodeId, event] of Object.entries(eventsById)) { - const eventData = event as Record; - if (eventData.CreatedTreeEvent) { - createdEvent = (eventData.CreatedTreeEvent as Record).value as Record; - } - } + // The result contract is the CreatedTreeEvent with the greatest ledger node ID. + // createdTreeEventRecords sorts node IDs explicitly, so this does not depend on + // JavaScript object insertion or integer-key enumeration order. + const createdEvents = createdTreeEventRecords(response); + const createdEvent = createdEvents[createdEvents.length - 1]?.value; if (!createdEvent) { throw new Error('No CreatedTreeEvent found in transaction tree'); @@ -92,11 +173,21 @@ export function convertTransactionTreeToEventsResponse( }; } +/** Find one created contract by template name in a direct or legacy-nested transaction tree. */ +export function extractContractIdFromTransactionTree(response: unknown, templateIdContains: string): string { + for (const event of createdTreeEventRecords(response)) { + const isMatch = + event.templateId.includes(`:${templateIdContains}:`) || event.templateId.endsWith(`:${templateIdContains}`); + if (isMatch) return event.contractId; + } + return ''; +} + /** * Validate that an actual request matches the expected request from the fixture Performs a flexible match that ignores * dynamic fields and format differences */ -export function validateRequestMatchesFixture(actualRequest: Record): void { +export function validateRequestMatchesFixture(actualRequest: unknown): void { if (!currentFixture) { return; // No fixture configured, skip validation } @@ -112,12 +203,3 @@ export function validateRequestMatchesFixture(actualRequest: Record { const validatorApi = createValidatorApiClient(config); const featured = await getFeaturedAppRightContractDetails(validatorApi); + expect(validatorApi.lookupFeaturedAppRight).toHaveBeenCalledWith({ partyId: 'party::issuer' }); + expect(validatorApi.getAmuletRules).toHaveBeenCalledTimes(1); + expect(featured).toEqual({ templateId: 'a5b055492fb8f08b2e7bc0fc94da6da50c39c2e1d7f24cd5ea8db12fc87c1332:Splice.Amulet:FeaturedAppRight', contractId: @@ -19,4 +22,30 @@ describe('getFeaturedAppRightContractDetails', () => { synchronizerId: 'global-domain::1220be58c29e65de40bf273be1dc2b266d43a9a002ea5b18955aeef7aac881bb471a', }); }); + + test('uses amulet rules as the synchronizer source even when the featured response has a domain id', async () => { + const config: ClientConfig = { + network: 'devnet', + }; + const validatorApi = createValidatorApiClient(config); + const featuredAppRightWithDomain = { + template_id: 'template', + contract_id: 'contract', + payload: {}, + created_event_blob: 'blob', + created_at: '2025-01-01T00:00:00Z', + domain_id: 'wrong-featured-domain', + }; + const responseWithDomain: Awaited> = { + featured_app_right: featuredAppRightWithDomain, + }; + jest.spyOn(validatorApi, 'lookupFeaturedAppRight').mockResolvedValue(responseWithDomain); + + const featured = await getFeaturedAppRightContractDetails(validatorApi); + + expect(featured.synchronizerId).toBe( + 'global-domain::1220be58c29e65de40bf273be1dc2b266d43a9a002ea5b18955aeef7aac881bb471a' + ); + expect(validatorApi.getAmuletRules).toHaveBeenCalledTimes(1); + }); }); diff --git a/test/utils/ocfZodSchemas.test.ts b/test/utils/ocfZodSchemas.test.ts index e28aeaba..6017e28b 100644 --- a/test/utils/ocfZodSchemas.test.ts +++ b/test/utils/ocfZodSchemas.test.ts @@ -75,7 +75,7 @@ describe('ocfZodSchemas', () => { }); it('parses strict source-of-truth OCF objects', () => { - const fixture = stripSourceMetadata(loadProductionFixture>('stakeholder', 'individual')); + const fixture = stripSourceMetadata(loadProductionFixture('stakeholder', 'individual')); const parsed = parseOcfObject(fixture); expect(parsed.object_type).toBe('STAKEHOLDER'); @@ -83,7 +83,7 @@ describe('ocfZodSchemas', () => { }); it('rejects unknown fields with strict validation', () => { - const fixture = stripSourceMetadata(loadProductionFixture>('stakeholder', 'individual')); + const fixture = stripSourceMetadata(loadProductionFixture('stakeholder', 'individual')); const invalidFixture = { ...fixture, __unexpected_field: 'not allowed', @@ -306,9 +306,7 @@ describe('ocfZodSchemas', () => { }); it('rejects non-schema plan_security_type on canonical equity compensation issuances', () => { - const fixture = stripSourceMetadata( - loadProductionFixture>('equityCompensationIssuance', 'option-nso') - ); + const fixture = stripSourceMetadata(loadProductionFixture('equityCompensationIssuance', 'option-nso')); const malformedFixture: Record = { ...fixture, plan_security_type: 'OPTION', @@ -318,7 +316,7 @@ describe('ocfZodSchemas', () => { }); it('rejects an unsupported stakeholder status object_type before inspecting its fields', () => { - const fixture = stripSourceMetadata(loadSyntheticFixture>('stakeholderStatusChangeEvent')); + const fixture = stripSourceMetadata(loadSyntheticFixture('stakeholderStatusChangeEvent')); const legacyFixture: Record = { ...fixture, object_type: 'TX_STAKEHOLDER_STATUS_CHANGE_EVENT', @@ -351,9 +349,7 @@ describe('ocfZodSchemas', () => { ); it('rejects non-schema new_relationships instead of rewriting it', () => { - const fixture = stripSourceMetadata( - loadSyntheticFixture>('stakeholderRelationshipChangeEvent') - ); + const fixture = stripSourceMetadata(loadSyntheticFixture('stakeholderRelationshipChangeEvent')); expect(() => parseOcfObject({ @@ -364,7 +360,7 @@ describe('ocfZodSchemas', () => { }); it('rejects non-schema reason_text instead of rewriting it', () => { - const fixture = stripSourceMetadata(loadSyntheticFixture>('stakeholderStatusChangeEvent')); + const fixture = stripSourceMetadata(loadSyntheticFixture('stakeholderStatusChangeEvent')); expect(() => parseOcfObject({ @@ -375,7 +371,7 @@ describe('ocfZodSchemas', () => { }); it('parses the canonical stock consolidation resulting_security_id field', () => { - const fixture = stripSourceMetadata(loadSyntheticFixture>('stockConsolidation')); + const fixture = stripSourceMetadata(loadSyntheticFixture('stockConsolidation')); const parsed = parseOcfEntityInput('stockConsolidation', fixture); const parsedRecord = toRecord(parsed); @@ -383,7 +379,7 @@ describe('ocfZodSchemas', () => { }); it('rejects stock consolidation legacy resulting_security_ids field', () => { - const fixture = stripSourceMetadata(loadSyntheticFixture>('stockConsolidation')); + const fixture = stripSourceMetadata(loadSyntheticFixture('stockConsolidation')); const legacyFixture: Record = { ...fixture, resulting_security_ids: [fixture.resulting_security_id], @@ -395,9 +391,7 @@ describe('ocfZodSchemas', () => { }); it('rejects entity/object_type mismatches', () => { - const stakeholderFixture = stripSourceMetadata( - loadProductionFixture>('stakeholder', 'individual') - ); + const stakeholderFixture = stripSourceMetadata(loadProductionFixture('stakeholder', 'individual')); const parseMismatched = () => parseOcfEntityInput('stockIssuance', stakeholderFixture); expect(parseMismatched).toThrow(OcpValidationError); @@ -405,9 +399,7 @@ describe('ocfZodSchemas', () => { }); it('typed parsing accepts the exact canonical object_type', () => { - const sourceFixture = stripSourceMetadata( - loadProductionFixture>('equityCompensationIssuance', 'option-iso') - ); + const sourceFixture = stripSourceMetadata(loadProductionFixture('equityCompensationIssuance', 'option-iso')); const { option_grant_type: _, ...fixture } = sourceFixture; const parsed = parseOcfEntityInput('equityCompensationIssuance', fixture); @@ -416,7 +408,7 @@ describe('ocfZodSchemas', () => { }); it('delegates conversion-right mechanism compatibility to the specialized pinned schema', () => { - const fixture = stripSourceMetadata(loadProductionFixture>('warrantIssuance')); + const fixture = stripSourceMetadata(loadProductionFixture('warrantIssuance')); const trigger = { type: 'ELECTIVE_AT_WILL', trigger_id: 'specialized-schema-trigger', @@ -431,7 +423,7 @@ describe('ocfZodSchemas', () => { }); it('typed parsing rejects a missing object_type', () => { - const fixture = stripSourceMetadata(loadProductionFixture>('stakeholder', 'individual')); + const fixture = stripSourceMetadata(loadProductionFixture('stakeholder', 'individual')); const { object_type: _, ...withoutObjectType } = fixture; const parseMissing = () => parseOcfEntityInput('stakeholder', withoutObjectType); @@ -440,9 +432,7 @@ describe('ocfZodSchemas', () => { }); it('typed parsing rejects legacy object_type aliases', () => { - const fixture = stripSourceMetadata( - loadProductionFixture>('equityCompensationIssuance', 'option-iso') - ); + const fixture = stripSourceMetadata(loadProductionFixture('equityCompensationIssuance', 'option-iso')); const legacyFixture = { ...fixture, object_type: 'TX_PLAN_SECURITY_ISSUANCE', diff --git a/test/utils/productionFixtures.test.ts b/test/utils/productionFixtures.test.ts new file mode 100644 index 00000000..436495af --- /dev/null +++ b/test/utils/productionFixtures.test.ts @@ -0,0 +1,77 @@ +import { loadFixture, loadProductionFixture, prepareFixtureForSubmission } from './productionFixtures'; + +describe('fixture loading', () => { + test('returns a runtime-validated record', () => { + const fixture = loadProductionFixture('stakeholder', 'individual'); + + expect(fixture.object_type).toBe('STAKEHOLDER'); + expect(typeof fixture.id).toBe('string'); + }); + + test('rejects a parsed fixture whose root is not an object', () => { + const parse = jest.spyOn(JSON, 'parse').mockReturnValueOnce([]); + try { + expect(() => loadFixture('production/stakeholder/individual.json')).toThrow( + 'Fixture production/stakeholder/individual.json must contain a JSON object' + ); + } finally { + parse.mockRestore(); + } + }); +}); + +describe('prepareFixtureForSubmission', () => { + const identifiers = { + id: 'run-specific-id', + securityId: 'run-specific-security-id', + }; + + test('strips source metadata and replaces valid fixture identifiers', () => { + expect( + prepareFixtureForSubmission( + { + _source: { environment: 'production' }, + id: 'source-id', + object_type: 'TX_STOCK_ISSUANCE', + security_id: 'source-security-id', + }, + identifiers + ) + ).toEqual({ + id: 'run-specific-id', + object_type: 'TX_STOCK_ISSUANCE', + security_id: 'run-specific-security-id', + }); + }); + + test('does not add security_id when the source fixture does not contain one', () => { + expect(prepareFixtureForSubmission({ id: 'source-id', object_type: 'STAKEHOLDER' }, identifiers)).toEqual({ + id: 'run-specific-id', + object_type: 'STAKEHOLDER', + }); + }); + + test('rejects a non-string security_id instead of hiding it with a generated value', () => { + expect(() => + prepareFixtureForSubmission( + { + id: 'source-id', + object_type: 'TX_STOCK_ISSUANCE', + security_id: 42, + }, + identifiers + ) + ).toThrow('Fixture field security_id must be a string when present'); + }); + + test('rejects a missing source id instead of hiding it with a generated value', () => { + expect(() => + prepareFixtureForSubmission( + { + object_type: 'STAKEHOLDER', + }, + identifiers + ) + ).toThrow('Fixture field id must be a string'); + }); +}); diff --git a/test/utils/productionFixtures.ts b/test/utils/productionFixtures.ts index af87a4e1..8b2fa235 100644 --- a/test/utils/productionFixtures.ts +++ b/test/utils/productionFixtures.ts @@ -15,8 +15,8 @@ const FIXTURES_BASE_DIR = path.join(__dirname, '../fixtures'); * Load a fixture from the production or synthetic directories. * * @param relativePath - Path relative to the fixtures directory (e.g., 'production/issuer/basic.json' or 'synthetic/stockAcceptance.json') - * @returns The parsed JSON fixture data - * @throws Error if the fixture file does not exist or cannot be parsed + * @returns The parsed JSON fixture as an unknown-valued object record + * @throws Error if the fixture file does not exist, cannot be parsed, or does not contain an object at its root * * @example * ```typescript @@ -27,7 +27,18 @@ const FIXTURES_BASE_DIR = path.join(__dirname, '../fixtures'); * const acceptance = loadFixture('synthetic/stockAcceptance.json'); * ``` */ -export function loadFixture(relativePath: string): T { +function isFixtureRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function requireFixtureRecord(value: unknown, relativePath: string): Record { + if (!isFixtureRecord(value)) { + throw new Error(`Fixture ${relativePath} must contain a JSON object`); + } + return value; +} + +export function loadFixture(relativePath: string): Record { const absolutePath = path.join(FIXTURES_BASE_DIR, relativePath); if (!fs.existsSync(absolutePath)) { @@ -37,7 +48,8 @@ export function loadFixture(relativePath: string): T { const content = fs.readFileSync(absolutePath, 'utf-8'); try { - return JSON.parse(content) as T; + const parsed: unknown = JSON.parse(content); + return requireFixtureRecord(parsed, relativePath); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Failed to parse fixture ${relativePath}: ${message}`); @@ -63,10 +75,10 @@ export function loadFixture(relativePath: string): T { * const transfer = loadProductionFixture('stockTransfer'); * ``` */ -export function loadProductionFixture(type: string, variant?: string): T { +export function loadProductionFixture(type: string, variant?: string): Record { const relativePath = variant ? `production/${type}/${variant}.json` : `production/${type}.json`; - return loadFixture(relativePath); + return loadFixture(relativePath); } /** @@ -80,8 +92,8 @@ export function loadProductionFixture(type: string, variant?: strin * const acceptance = loadSyntheticFixture('stockAcceptance'); * ``` */ -export function loadSyntheticFixture(type: string): T { - return loadFixture(`synthetic/${type}.json`); +export function loadSyntheticFixture(type: string): Record { + return loadFixture(`synthetic/${type}.json`); } /** @@ -94,3 +106,36 @@ export function stripSourceMetadata>(fixture: const { _source, ...rest } = fixture; return rest; } + +/** Identifiers used to make one fixture unique for an integration-test run. */ +export interface PreparedFixtureIdentifiers { + id: string; + securityId: string; +} + +/** + * Remove source-only metadata and replace identifiers that must be unique on the ledger. + * + * Validate every source identifier before replacing it so fixture preparation cannot turn + * malformed source data into a valid payload and hide a broken production fixture. + */ +export function prepareFixtureForSubmission( + fixture: Record, + identifiers: PreparedFixtureIdentifiers +): Record { + const cleaned = stripSourceMetadata(fixture); + if (typeof cleaned.id !== 'string') { + throw new Error('Fixture field id must be a string'); + } + + const hasSecurityId = Object.prototype.hasOwnProperty.call(cleaned, 'security_id'); + if (hasSecurityId && typeof cleaned.security_id !== 'string') { + throw new Error('Fixture field security_id must be a string when present'); + } + + return { + ...cleaned, + id: identifiers.id, + ...(hasSecurityId ? { security_id: identifiers.securityId } : {}), + }; +} diff --git a/test/utils/typeGuards.test.ts b/test/utils/typeGuards.test.ts index dd2fa987..3d7efbab 100644 --- a/test/utils/typeGuards.test.ts +++ b/test/utils/typeGuards.test.ts @@ -360,7 +360,7 @@ const missingRequiredFieldCases = ocfGuardCases.flatMap((guardCase) => ); function loadOcfGuardFixture(fixturePath: string): Record { - const fixture = stripSourceMetadata(loadFixture>(fixturePath)); + const fixture = stripSourceMetadata(loadFixture(fixturePath)); if (fixture.object_type === 'TX_EQUITY_COMPENSATION_ISSUANCE') { delete fixture.option_grant_type; } diff --git a/tsconfig.package-consumer-tests.json b/tsconfig.package-consumer-tests.json new file mode 100644 index 00000000..628e72ae --- /dev/null +++ b/tsconfig.package-consumer-tests.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "exactOptionalPropertyTypes": true, + "module": "Node16", + "moduleResolution": "Node16", + "noUncheckedIndexedAccess": true, + "rootDir": ".", + "types": ["node"] + }, + "exclude": [], + "extends": "./tsconfig.tests.json", + "include": ["test/package-consumer/**/*.ts"] +} diff --git a/tsconfig.tests.json b/tsconfig.tests.json index 50956067..b44873df 100644 --- a/tsconfig.tests.json +++ b/tsconfig.tests.json @@ -6,5 +6,5 @@ }, "extends": "./tsconfig.json", "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"], - "exclude": ["test/declarations/**/*.ts", "test/exact/**/*.ts"] + "exclude": ["test/declarations/**/*.ts", "test/exact/**/*.ts", "test/package-consumer/**/*.ts"] }