From 325fa1c9c6a5d871f82b54ae8a829c6ef309eab6 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 01:23:55 -0400 Subject: [PATCH 01/11] Harden test and tooling type boundaries --- eslint.config.mjs | 1 - scripts/optimize-fixtures.ts | 4 +- ...roductionDataRoundtrip.integration.test.ts | 171 ++++++++++++------ test/integration/utils/setupTestData.ts | 12 +- test/mocks/fairmint-canton-node-sdk.ts | 161 +++++++++-------- test/utils/fixtureHelpers.ts | 17 +- ...getFeaturedAppRightContractDetails.test.ts | 2 + 7 files changed, 231 insertions(+), 137 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index ae2485d8..5ddacac5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -181,7 +181,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/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/integration/production/productionDataRoundtrip.integration.test.ts b/test/integration/production/productionDataRoundtrip.integration.test.ts index 296f1e45..5c834bc8 100644 --- a/test/integration/production/productionDataRoundtrip.integration.test.ts +++ b/test/integration/production/productionDataRoundtrip.integration.test.ts @@ -45,11 +45,10 @@ 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 { +function prepareFixture(fixture: Record, idPrefix: string): Record { const cleaned = stripSourceMetadata(fixture); // Generate unique ID to avoid conflicts between test runs const uniqueId = generateTestId(idPrefix); @@ -61,6 +60,14 @@ function prepareFixture(fixture: Record, idPrefix: string): any }; } +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; +} + /** * Compare fixture data with read-back data, ignoring internal fields. */ @@ -197,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); }); @@ -237,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); }); @@ -262,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 @@ -296,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({ @@ -324,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({ @@ -352,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({ @@ -385,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({ @@ -413,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({ @@ -466,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); }); @@ -509,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); }); @@ -552,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); }); @@ -595,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); }); }); @@ -637,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); }); @@ -676,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); }); @@ -715,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); }); @@ -760,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); }); }); @@ -817,7 +836,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); }); @@ -862,7 +883,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); }); @@ -907,7 +930,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); }); }); @@ -935,7 +960,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); }); @@ -974,7 +1001,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); }); @@ -1008,7 +1040,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, @@ -1021,7 +1053,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); }); @@ -1061,7 +1095,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); }); }); @@ -1102,7 +1136,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); }); }); @@ -1151,7 +1185,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); }); }); @@ -1199,7 +1233,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); }); @@ -1241,7 +1275,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); }); @@ -1280,7 +1314,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); }); @@ -1322,7 +1356,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); }); @@ -1365,7 +1399,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); }); }); @@ -1411,7 +1447,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); }); @@ -1455,7 +1493,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); }); }); @@ -1499,7 +1539,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); }); @@ -1541,7 +1583,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); }); @@ -1583,7 +1627,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); }); @@ -1622,7 +1668,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); }); @@ -1661,7 +1709,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); }); }); @@ -1705,7 +1755,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); }); @@ -1747,7 +1799,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); }); @@ -1789,7 +1841,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); }); @@ -1828,7 +1882,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); }); @@ -1870,7 +1924,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); }); }); @@ -1914,7 +1970,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); }); @@ -1956,7 +2012,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); }); @@ -1976,7 +2034,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { }); const fixture = loadSyntheticFixture>('stakeholderRelationshipChangeEvent'); - const legacyPrepared = { + const legacyPrepared: Record = { ...prepareFixture(fixture, 'relationship-change'), stakeholder_id: stakeholderSetup.stakeholderData.id, }; @@ -2024,7 +2082,7 @@ createIntegrationTestSuite('Production Data Round-Trip Tests', (getContext) => { }); const fixture = loadSyntheticFixture>('stakeholderStatusChangeEvent'); - const legacyPrepared = { + const legacyPrepared: Record = { ...prepareFixture(fixture, 'status-change'), stakeholder_id: stakeholderSetup.stakeholderData.id, }; @@ -2090,7 +2148,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, @@ -2104,7 +2162,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); }); }); @@ -2146,7 +2206,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 46b304e0..4b00f25d 100644 --- a/test/integration/utils/setupTestData.ts +++ b/test/integration/utils/setupTestData.ts @@ -83,6 +83,12 @@ export function generateTestId(prefix: string): string { return `${prefix}-${timestamp}-${random}`; } +function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + /** Omit optional disclosed-contract input rather than materializing it as `undefined`. */ export function withCapTableContractDetails( capTableContractDetails: T | undefined @@ -668,9 +674,9 @@ function extractContractIdFromResponse( response: SubmitAndWaitForTransactionTreeResponse, templateIdContains: string ): string { - const tree = response.transactionTree; - const treeAny = tree as any; - const eventsById: Record = treeAny.eventsById ?? treeAny.transaction?.eventsById ?? {}; + const tree = asRecord(response.transactionTree); + const nestedTransaction = asRecord(tree?.transaction); + const eventsById = asRecord(tree?.eventsById) ?? asRecord(nestedTransaction?.eventsById) ?? {}; for (const event of Object.values(eventsById)) { const eventData = event as Record; diff --git a/test/mocks/fairmint-canton-node-sdk.ts b/test/mocks/fairmint-canton-node-sdk.ts index 43757bbe..aac4c5fb 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); } @@ -117,24 +158,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 { @@ -175,67 +212,43 @@ 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()}`); +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}`); } // 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 ?? ''; + const amuletRulesResponse = requireRecord(await validatorApi.getAmuletRules(), 'amulet rules response'); + const amuletRules = requireRecord(amuletRulesResponse.amulet_rules, 'amulet rules'); + const synchronizerIdFromRules = 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: synchronizerIdFromRules, + 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; - } - } - } - } - // 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) { +export function findCreatedEventByTemplateId( + response: SubmitAndWaitForTransactionTreeResponse, + templateId: string +): CreatedTreeEventWrapper | undefined { + const expectedSuffix = templateId.includes(':') ? templateId.substring(templateId.indexOf(':') + 1) : templateId; + + for (const event of Object.values(response.transactionTree.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/utils/fixtureHelpers.ts b/test/utils/fixtureHelpers.ts index 78f32409..ab6daca6 100644 --- a/test/utils/fixtureHelpers.ts +++ b/test/utils/fixtureHelpers.ts @@ -19,6 +19,12 @@ 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; +} + /** * Configure the mock with a fixture object directly (no file I/O) * @@ -63,8 +69,9 @@ export function convertTransactionTreeToEventsResponse( 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; + const transactionTree = asRecord(response.transactionTree); + const nestedTransaction = asRecord(transactionTree?.transaction); + const eventsById = asRecord(transactionTree?.eventsById) ?? asRecord(nestedTransaction?.eventsById); if (!eventsById) { throw new Error('No eventsById in transaction tree'); @@ -96,7 +103,7 @@ export function convertTransactionTreeToEventsResponse( * 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 } @@ -118,6 +125,8 @@ export function validateRequestMatchesFixture(actualRequest: Record unknown }; + }; return jest.spyOn(clientWithPrivateAccess.ledger, 'submitAndWaitForTransactionTree'); } diff --git a/test/utils/getFeaturedAppRightContractDetails.test.ts b/test/utils/getFeaturedAppRightContractDetails.test.ts index d120b950..6a5710f3 100644 --- a/test/utils/getFeaturedAppRightContractDetails.test.ts +++ b/test/utils/getFeaturedAppRightContractDetails.test.ts @@ -11,6 +11,8 @@ describe('getFeaturedAppRightContractDetails', () => { const validatorApi = createValidatorApiClient(config); const featured = await getFeaturedAppRightContractDetails(validatorApi); + expect(validatorApi.lookupFeaturedAppRight).toHaveBeenCalledWith({ partyId: 'party::issuer' }); + expect(featured).toEqual({ templateId: 'a5b055492fb8f08b2e7bc0fc94da6da50c39c2e1d7f24cd5ea8db12fc87c1332:Splice.Amulet:FeaturedAppRight', contractId: From 0fe31fc796e5e71e8d996cdb80bbbb42cef5d6ad Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 17:56:07 -0400 Subject: [PATCH 02/11] Harden typed transaction tree fixtures --- eslint.config.mjs | 1 - test/integration/utils/setupTestData.ts | 24 +----- test/utils/fixtureHelpers.test.ts | 95 ++++++++++++++++++++++++ test/utils/fixtureHelpers.ts | 97 +++++++++++++++++++++---- 4 files changed, 178 insertions(+), 39 deletions(-) create mode 100644 test/utils/fixtureHelpers.test.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index b4cde2b3..5ddacac5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,7 +12,6 @@ const eslintConfig = [ '**/node_modules/**', '**/coverage/**', '**/docs/**', - '**/test/declarations/**', '**/*.js', '**/*.mjs', '**/libs/**', diff --git a/test/integration/utils/setupTestData.ts b/test/integration/utils/setupTestData.ts index 4b00f25d..8cb9c24b 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'; @@ -83,12 +84,6 @@ export function generateTestId(prefix: string): string { return `${prefix}-${timestamp}-${random}`; } -function asRecord(value: unknown): Record | undefined { - return typeof value === 'object' && value !== null && !Array.isArray(value) - ? (value as Record) - : undefined; -} - /** Omit optional disclosed-contract input rather than materializing it as `undefined`. */ export function withCapTableContractDetails( capTableContractDetails: T | undefined @@ -674,22 +669,7 @@ function extractContractIdFromResponse( response: SubmitAndWaitForTransactionTreeResponse, templateIdContains: string ): string { - const tree = asRecord(response.transactionTree); - const nestedTransaction = asRecord(tree?.transaction); - const eventsById = asRecord(tree?.eventsById) ?? asRecord(nestedTransaction?.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/utils/fixtureHelpers.test.ts b/test/utils/fixtureHelpers.test.ts new file mode 100644 index 00000000..d6781aec --- /dev/null +++ b/test/utils/fixtureHelpers.test.ts @@ -0,0 +1,95 @@ +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 } }, + }, + }, + }, + }; +} + +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.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' + ); + }); +}); diff --git a/test/utils/fixtureHelpers.ts b/test/utils/fixtureHelpers.ts index ab6daca6..5402fbf7 100644 --- a/test/utils/fixtureHelpers.ts +++ b/test/utils/fixtureHelpers.ts @@ -25,6 +25,75 @@ function asRecord(value: unknown): Record | undefined { : 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 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[] = []; + + for (const [nodeId, event] of Object.entries(eventsById)) { + const eventPath = `${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) * @@ -68,23 +137,9 @@ export function convertTransactionTreeToEventsResponse( response: SubmitAndWaitForTransactionTreeResponse | Record, synchronizerId: string ): Record { - // Handle both structures: response.transactionTree.eventsById and response.transactionTree.transaction.eventsById - const transactionTree = asRecord(response.transactionTree); - const nestedTransaction = asRecord(transactionTree?.transaction); - const eventsById = asRecord(transactionTree?.eventsById) ?? asRecord(nestedTransaction?.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; - } - } + const createdEvents = createdTreeEventRecords(response); + const createdEvent = createdEvents[createdEvents.length - 1]?.value; if (!createdEvent) { throw new Error('No CreatedTreeEvent found in transaction tree'); @@ -99,6 +154,16 @@ 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 From ba1d5e1112345efe7ec807e3dba2e639d610735e Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 23:29:31 -0400 Subject: [PATCH 03/11] fix: close typed test boundary gaps --- eslint.config.mjs | 15 + test/createOcf/dynamic.test.ts.skip | 395 ------------------ ...roductionDataRoundtrip.integration.test.ts | 25 +- test/utils/productionFixtures.test.ts | 57 +++ test/utils/productionFixtures.ts | 33 ++ 5 files changed, 118 insertions(+), 407 deletions(-) delete mode 100644 test/createOcf/dynamic.test.ts.skip create mode 100644 test/utils/productionFixtures.test.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 5ddacac5..9cdeec78 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -19,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'], 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/integration/production/productionDataRoundtrip.integration.test.ts b/test/integration/production/productionDataRoundtrip.integration.test.ts index ba6085d9..9913bcf6 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, @@ -48,15 +53,11 @@ import { */ function prepareFixture(fixture: Record, idPrefix: string): Record { - 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') } : {}), - }; + 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 { @@ -92,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) { diff --git a/test/utils/productionFixtures.test.ts b/test/utils/productionFixtures.test.ts new file mode 100644 index 00000000..57cedf1e --- /dev/null +++ b/test/utils/productionFixtures.test.ts @@ -0,0 +1,57 @@ +import { prepareFixtureForSubmission } from './productionFixtures'; + +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..10db0aac 100644 --- a/test/utils/productionFixtures.ts +++ b/test/utils/productionFixtures.ts @@ -94,3 +94,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 } : {}), + }; +} From 885cdadf0b88079560644fa8a75ae545ec96c205 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:41:50 +0000 Subject: [PATCH 04/11] fix mock synchronizer fallback and legacy event tree lookup --- test/mocks/fairmint-canton-node-sdk.ts | 24 +++++++++++----- .../findCreatedEventByTemplateId.test.ts | 28 +++++++++++++++++++ ...getFeaturedAppRightContractDetails.test.ts | 25 +++++++++++++++++ 3 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 test/mocks/findCreatedEventByTemplateId.test.ts diff --git a/test/mocks/fairmint-canton-node-sdk.ts b/test/mocks/fairmint-canton-node-sdk.ts index aac4c5fb..0c4dcc89 100644 --- a/test/mocks/fairmint-canton-node-sdk.ts +++ b/test/mocks/fairmint-canton-node-sdk.ts @@ -219,15 +219,19 @@ export async function getFeaturedAppRightContractDetails(validatorApi: Validator if (!featuredAppRight) { throw new Error(`No featured app right found for party ${partyId}`); } - // 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 amuletRulesResponse = requireRecord(await validatorApi.getAmuletRules(), 'amulet rules response'); - const amuletRules = requireRecord(amuletRulesResponse.amulet_rules, 'amulet rules'); - const synchronizerIdFromRules = requireString(amuletRules.domain_id, 'amulet rules domain_id'); + const synchronizerIdFromFeatured = typeof featuredAppRight.domain_id === 'string' ? featuredAppRight.domain_id : undefined; + let synchronizerId = synchronizerIdFromFeatured; + if (!synchronizerId) { + // 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 amuletRulesResponse = requireRecord(await validatorApi.getAmuletRules(), 'amulet rules response'); + const amuletRules = requireRecord(amuletRulesResponse.amulet_rules, 'amulet rules'); + synchronizerId = requireString(amuletRules.domain_id, 'amulet rules domain_id'); + } return { contractId: requireString(featuredAppRight.contract_id, 'featured app right contract_id'), createdEventBlob: requireString(featuredAppRight.created_event_blob, 'featured app right created_event_blob'), - synchronizerId: synchronizerIdFromRules, + synchronizerId, templateId: requireString(featuredAppRight.template_id, 'featured app right template_id'), }; } @@ -238,8 +242,14 @@ export function findCreatedEventByTemplateId( 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; + } - for (const event of Object.values(response.transactionTree.eventsById)) { + for (const event of Object.values(eventsById)) { if (isCreatedTreeEventWrapper(event)) { const actualTemplateId = event.CreatedTreeEvent.value.templateId; const actualSuffix = actualTemplateId.includes(':') diff --git a/test/mocks/findCreatedEventByTemplateId.test.ts b/test/mocks/findCreatedEventByTemplateId.test.ts new file mode 100644 index 00000000..75aa7a90 --- /dev/null +++ b/test/mocks/findCreatedEventByTemplateId.test.ts @@ -0,0 +1,28 @@ +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', () => { + test('finds created event from legacy nested transaction tree shape', () => { + const createdEvent = { + CreatedTreeEvent: { + value: { + templateId: 'pkg:Fairmint.OpenCapTable.Stakeholder:Stakeholder', + contractId: 'cid', + }, + }, + }; + const response = { + transactionTree: { + transaction: { + eventsById: { + '0': createdEvent, + }, + }, + }, + } as unknown as SubmitAndWaitForTransactionTreeResponse; + + const result = findCreatedEventByTemplateId(response, 'pkg:Fairmint.OpenCapTable.Stakeholder:Stakeholder'); + + expect(result).toEqual(createdEvent); + }); +}); diff --git a/test/utils/getFeaturedAppRightContractDetails.test.ts b/test/utils/getFeaturedAppRightContractDetails.test.ts index 6a5710f3..2d021441 100644 --- a/test/utils/getFeaturedAppRightContractDetails.test.ts +++ b/test/utils/getFeaturedAppRightContractDetails.test.ts @@ -21,4 +21,29 @@ describe('getFeaturedAppRightContractDetails', () => { synchronizerId: 'global-domain::1220be58c29e65de40bf273be1dc2b266d43a9a002ea5b18955aeef7aac881bb471a', }); }); + + test('uses featured app synchronizer id when present', async () => { + const config: ClientConfig = { + network: 'devnet', + }; + const validatorApi = createValidatorApiClient(config); + const responseWithDomain: Awaited> = { + featured_app_right: { + template_id: 'template', + contract_id: 'contract', + payload: {}, + created_event_blob: 'blob', + created_at: '2025-01-01T00:00:00Z', + }, + }; + (responseWithDomain.featured_app_right as Record).domain_id = 'featured-domain'; + jest + .spyOn(validatorApi, 'lookupFeaturedAppRight') + .mockResolvedValue(responseWithDomain); + + const featured = await getFeaturedAppRightContractDetails(validatorApi); + + expect(featured.synchronizerId).toBe('featured-domain'); + expect(validatorApi.getAmuletRules).not.toHaveBeenCalled(); + }); }); From a695d5ab7d5782d1f549e37256297ebae72cb714 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 11 Jul 2026 19:45:32 +0000 Subject: [PATCH 05/11] Auto-fix: build changes --- test/mocks/fairmint-canton-node-sdk.ts | 3 ++- test/utils/getFeaturedAppRightContractDetails.test.ts | 4 +--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/test/mocks/fairmint-canton-node-sdk.ts b/test/mocks/fairmint-canton-node-sdk.ts index 0c4dcc89..7f7be419 100644 --- a/test/mocks/fairmint-canton-node-sdk.ts +++ b/test/mocks/fairmint-canton-node-sdk.ts @@ -219,7 +219,8 @@ export async function getFeaturedAppRightContractDetails(validatorApi: Validator if (!featuredAppRight) { throw new Error(`No featured app right found for party ${partyId}`); } - const synchronizerIdFromFeatured = typeof featuredAppRight.domain_id === 'string' ? featuredAppRight.domain_id : undefined; + const synchronizerIdFromFeatured = + typeof featuredAppRight.domain_id === 'string' ? featuredAppRight.domain_id : undefined; let synchronizerId = synchronizerIdFromFeatured; if (!synchronizerId) { // The featured-apps endpoint may not include the synchronizer/domain id. diff --git a/test/utils/getFeaturedAppRightContractDetails.test.ts b/test/utils/getFeaturedAppRightContractDetails.test.ts index 2d021441..f563591a 100644 --- a/test/utils/getFeaturedAppRightContractDetails.test.ts +++ b/test/utils/getFeaturedAppRightContractDetails.test.ts @@ -37,9 +37,7 @@ describe('getFeaturedAppRightContractDetails', () => { }, }; (responseWithDomain.featured_app_right as Record).domain_id = 'featured-domain'; - jest - .spyOn(validatorApi, 'lookupFeaturedAppRight') - .mockResolvedValue(responseWithDomain); + jest.spyOn(validatorApi, 'lookupFeaturedAppRight').mockResolvedValue(responseWithDomain); const featured = await getFeaturedAppRightContractDetails(validatorApi); From 868fe9d7b12fddc08f2743c6fd3b32f5b425c4a7 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 21:40:48 -0400 Subject: [PATCH 06/11] Harden declaration harness against false greens --- eslint.config.mjs | 1 + package.json | 3 +- scripts/check-declarations.ts | 87 +++++++++++++++++++ test/exact/builtPublicConfig.types.ts | 59 +++++++++++++ test/exact/sourcePublicConfig.types.ts | 60 ++++++++++++- .../publicEntrypoint.types.ts | 50 +++++++++++ tsconfig.package-consumer-tests.json | 13 +++ tsconfig.tests.json | 2 +- 8 files changed, 269 insertions(+), 6 deletions(-) create mode 100644 test/package-consumer/publicEntrypoint.types.ts create mode 100644 tsconfig.package-consumer-tests.json diff --git a/eslint.config.mjs b/eslint.config.mjs index 9cdeec78..a596f6e1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -45,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', diff --git a/package.json b/package.json index c47d3646..f7bb5aa6 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", + "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 && 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 103b76b9..c8103f21 100644 --- a/scripts/check-declarations.ts +++ b/scripts/check-declarations.ts @@ -4,6 +4,7 @@ import ts from 'typescript'; 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}`; @@ -16,10 +17,96 @@ 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 configFile = ts.readConfigFile(configPath, (fileName) => ts.sys.readFile(fileName)); if (configFile.error) { throw new Error(ts.formatDiagnostic(configFile.error, diagnosticHost)); diff --git a/test/exact/builtPublicConfig.types.ts b/test/exact/builtPublicConfig.types.ts index 36223e76..6ca1122a 100644 --- a/test/exact/builtPublicConfig.types.ts +++ b/test/exact/builtPublicConfig.types.ts @@ -2,15 +2,36 @@ import { OcpNetworkError, type AuthorizeIssuerParams, type EnvironmentConfigInput, + type NonLocalOAuth2EnvironmentConfigInput, type OcpClient, type OcpClientDependencies, type OcpClientEnvOptions, type OcpClientHostedPresetOptions, type OcpClientLocalNetOptions, + type OcpEnvironment, type OcpValidationError, + type SharedSecretEnvironmentConfigInput, } from '../../dist'; type IsOptional = {} extends Pick ? true : false; +type Assert = T; +type IsExactly = [Left] extends [Right] ? ([Right] extends [Left] ? true : false) : false; + +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; @@ -43,6 +64,37 @@ const validationReceivedValue: unknown = validationError.receivedValue; // @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 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 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 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 inputs cannot use shared-secret authentication. +const mainnetSharedSecret: EnvironmentConfigInput = { + environment: 'mainnet', + ledgerApiUrl: 'https://ledger.mainnet.example.com', + authMode: 'shared-secret', + sharedSecret: 'secret', +}; // @ts-expect-error Built non-LocalNet OAuth2 input requires an explicit ledger endpoint. const missingOAuthLedger: EnvironmentConfigInput = { environment: 'devnet', @@ -85,7 +137,14 @@ void clientEnvironmentIsRequired; void errorStatusCodeIsRequired; void validationReceivedValueIsRequired; void validationReceivedValue; +void builtOAuth2CredentialsStayRequired; +void builtSharedSecretEnvironmentsStayExact; +void builtMainNetNeverSupportsSharedSecret; void explicitUndefinedInput; +void missingOAuthAuthUrl; +void missingOAuthClientId; +void missingOAuthClientSecret; +void mainnetSharedSecret; void missingOAuthLedger; void missingSharedSecretLedger; void missingHostedLedger; diff --git a/test/exact/sourcePublicConfig.types.ts b/test/exact/sourcePublicConfig.types.ts index 84d96ec8..25312907 100644 --- a/test/exact/sourcePublicConfig.types.ts +++ b/test/exact/sourcePublicConfig.types.ts @@ -6,11 +6,35 @@ import type { OcpClientLocalNetOptions, OcpFactoryCoordinates, } from '../../src/clientOptions'; -import type { EnvironmentConfig, EnvironmentConfigInput } from '../../src/environment'; +import type { + EnvironmentConfig, + EnvironmentConfigInput, + NonLocalOAuth2EnvironmentConfigInput, + OcpEnvironment, + SharedSecretEnvironmentConfigInput, +} from '../../src/environment'; import { OcpNetworkError, type OcpValidationError } from '../../src/errors'; import type { AuthorizeIssuerParams } from '../../src/functions/OpenCapTable/issuerAuthorization/types'; type IsOptional = {} extends Pick ? true : false; +type Assert = T; +type IsExactly = [Left] extends [Right] ? ([Right] extends [Left] ? true : false) : false; + +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; @@ -71,11 +95,34 @@ if (resolved.authMode === 'oauth2') { void sharedSecretCredentials; } -// @ts-expect-error OAuth2 credentials are required in the OAuth2 branch. -const incompleteOAuth: EnvironmentConfigInput = { environment: 'devnet', authMode: 'oauth2' }; +// @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 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 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 MainNet cannot use shared-secret authentication. const mainnetSharedSecret: EnvironmentConfigInput = { environment: 'mainnet', + ledgerApiUrl: 'https://ledger.mainnet.example.com', authMode: 'shared-secret', sharedSecret: 'secret', }; @@ -129,7 +176,12 @@ void errorEndpointIsRequired; void validationReceivedValueIsRequired; void validationReceivedValue; void optionalValidatorUrl; -void incompleteOAuth; +void sourceOAuth2CredentialsStayRequired; +void sourceSharedSecretEnvironmentsStayExact; +void sourceMainNetNeverSupportsSharedSecret; +void missingOAuthAuthUrl; +void missingOAuthClientId; +void missingOAuthClientSecret; void mainnetSharedSecret; void missingOAuthLedger; void missingSharedSecretLedger; diff --git a/test/package-consumer/publicEntrypoint.types.ts b/test/package-consumer/publicEntrypoint.types.ts new file mode 100644 index 00000000..c1a773ea --- /dev/null +++ b/test/package-consumer/publicEntrypoint.types.ts @@ -0,0 +1,50 @@ +/** Compile the installed-package surface through package.json exports, not a direct dist path. */ +import type { + EnvironmentConfigInput, + OcfObject, + OcpClient, + OcpValidationError, + SubmitAndWaitForTransactionTreeResponse, +} from '@open-captable-protocol/canton'; + +type Assert = T; +type IsExactly = [Left] extends [Right] ? ([Right] extends [Left] ? true : false) : false; + +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' | 'mainnet' | 'custom' + > +> = true; + +void client; +void environmentInput; +void ocfObject; +void packageEntryPointExposesValidationValue; +void packageEntryPointExposesTransactionTree; +void omittedOptionalProperty; +void explicitUndefinedOptionalProperty; +void uncheckedIndexedString; +void packageEntryPointKeepsEnvironmentDiscriminant; 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"] } From 8b2caa991c0ad8ace14edaf8f00bb4c811eaf0c2 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sun, 12 Jul 2026 04:04:30 -0400 Subject: [PATCH 07/11] test: make config exactness any-resistant --- test/exact/builtPublicConfig.types.ts | 17 ++++++++++++++++- test/exact/sourcePublicConfig.types.ts | 17 ++++++++++++++++- test/package-consumer/publicEntrypoint.types.ts | 17 ++++++++++++++++- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/test/exact/builtPublicConfig.types.ts b/test/exact/builtPublicConfig.types.ts index 20a50d7b..62b0e2be 100644 --- a/test/exact/builtPublicConfig.types.ts +++ b/test/exact/builtPublicConfig.types.ts @@ -21,7 +21,21 @@ import { type IsOptional = {} extends Pick ? true : false; type Assert = T; -type IsExactly = [Left] extends [Right] ? ([Right] extends [Left] ? true : false) : false; +type IsAny = 0 extends 1 & T ? true : false; +type IsExactly = + IsAny extends true + ? false + : IsAny extends true + ? false + : [Left] extends [Right] + ? [Right] extends [Left] + ? true + : false + : false; + +const builtExactnessRejectsCompilerAny: Assert< + IsExactly, 'canonical'>, false> +> = true; interface RequiredOAuth2Credentials { readonly authUrl: string; @@ -217,6 +231,7 @@ void validationReceivedValue; void builtOAuth2CredentialsStayRequired; void builtSharedSecretEnvironmentsStayExact; void builtMainNetNeverSupportsSharedSecret; +void builtExactnessRejectsCompilerAny; void explicitUndefinedTraceId; void explicitUndefinedSpanId; void explicitUndefinedParentSpanId; diff --git a/test/exact/sourcePublicConfig.types.ts b/test/exact/sourcePublicConfig.types.ts index 0228f235..abf25d61 100644 --- a/test/exact/sourcePublicConfig.types.ts +++ b/test/exact/sourcePublicConfig.types.ts @@ -22,7 +22,21 @@ import type { CommandContext, OcpObservabilityOptions } from '../../src/observab type IsOptional = {} extends Pick ? true : false; type Assert = T; -type IsExactly = [Left] extends [Right] ? ([Right] extends [Left] ? true : false) : false; +type IsAny = 0 extends 1 & T ? true : false; +type IsExactly = + IsAny extends true + ? false + : IsAny extends true + ? false + : [Left] extends [Right] + ? [Right] extends [Left] + ? true + : false + : false; + +const sourceExactnessRejectsCompilerAny: Assert< + IsExactly, 'canonical'>, false> +> = true; interface RequiredOAuth2Credentials { readonly authUrl: string; @@ -255,6 +269,7 @@ void optionalValidatorUrl; void sourceOAuth2CredentialsStayRequired; void sourceSharedSecretEnvironmentsStayExact; void sourceMainNetNeverSupportsSharedSecret; +void sourceExactnessRejectsCompilerAny; void missingOAuthAuthUrl; void missingOAuthClientId; void missingOAuthClientSecret; diff --git a/test/package-consumer/publicEntrypoint.types.ts b/test/package-consumer/publicEntrypoint.types.ts index 965a2bb0..67f75770 100644 --- a/test/package-consumer/publicEntrypoint.types.ts +++ b/test/package-consumer/publicEntrypoint.types.ts @@ -8,7 +8,21 @@ import type { } from '@open-captable-protocol/canton'; type Assert = T; -type IsExactly = [Left] extends [Right] ? ([Right] extends [Left] ? true : false) : false; +type IsAny = 0 extends 1 & T ? true : false; +type IsExactly = + IsAny extends true + ? false + : IsAny extends true + ? false + : [Left] extends [Right] + ? [Right] extends [Left] + ? true + : false + : false; + +const packageExactnessRejectsCompilerAny: Assert< + IsExactly, 'canonical'>, false> +> = true; declare const client: OcpClient; declare const environmentInput: EnvironmentConfigInput; @@ -48,3 +62,4 @@ void omittedOptionalProperty; void explicitUndefinedOptionalProperty; void uncheckedIndexedString; void packageEntryPointKeepsEnvironmentDiscriminant; +void packageExactnessRejectsCompilerAny; From f13f0a64dbbb8bf6ee4e7a95cea6711a405dcac0 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sun, 12 Jul 2026 09:22:15 -0400 Subject: [PATCH 08/11] fix: align typed test mocks with runtime --- test/mocks/fairmint-canton-node-sdk.ts | 15 +++---- .../findCreatedEventByTemplateId.test.ts | 44 +++++++++++++++---- test/utils/fixtureHelpers.ts | 21 --------- ...getFeaturedAppRightContractDetails.test.ts | 26 ++++++----- 4 files changed, 55 insertions(+), 51 deletions(-) diff --git a/test/mocks/fairmint-canton-node-sdk.ts b/test/mocks/fairmint-canton-node-sdk.ts index 210de7bd..d622efe0 100644 --- a/test/mocks/fairmint-canton-node-sdk.ts +++ b/test/mocks/fairmint-canton-node-sdk.ts @@ -223,16 +223,11 @@ export async function getFeaturedAppRightContractDetails(validatorApi: Validator if (!featuredAppRight) { throw new Error(`No featured app right found for party ${partyId}`); } - const synchronizerIdFromFeatured = - typeof featuredAppRight.domain_id === 'string' ? featuredAppRight.domain_id : undefined; - let synchronizerId = synchronizerIdFromFeatured; - if (!synchronizerId) { - // 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 amuletRulesResponse = requireRecord(await validatorApi.getAmuletRules(), 'amulet rules response'); - const amuletRules = requireRecord(amuletRulesResponse.amulet_rules, 'amulet rules'); - synchronizerId = requireString(amuletRules.domain_id, 'amulet rules domain_id'); - } + // 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: requireString(featuredAppRight.contract_id, 'featured app right contract_id'), createdEventBlob: requireString(featuredAppRight.created_event_blob, 'featured app right created_event_blob'), diff --git a/test/mocks/findCreatedEventByTemplateId.test.ts b/test/mocks/findCreatedEventByTemplateId.test.ts index 75aa7a90..d8ff47c7 100644 --- a/test/mocks/findCreatedEventByTemplateId.test.ts +++ b/test/mocks/findCreatedEventByTemplateId.test.ts @@ -2,16 +2,26 @@ import type { SubmitAndWaitForTransactionTreeResponse } from '@fairmint/canton-n import { findCreatedEventByTemplateId } from '../mocks/fairmint-canton-node-sdk'; describe('findCreatedEventByTemplateId mock compatibility', () => { - test('finds created event from legacy nested transaction tree shape', () => { - const createdEvent = { - CreatedTreeEvent: { - value: { - templateId: 'pkg:Fairmint.OpenCapTable.Stakeholder:Stakeholder', - contractId: 'cid', + const createdEvent = { + CreatedTreeEvent: { + value: { + templateId: 'pkg:Fairmint.OpenCapTable.Stakeholder:Stakeholder', + contractId: 'cid', + }, + }, + }; + + test.each([ + { + name: 'direct transaction tree', + transactionTree: { + eventsById: { + '0': createdEvent, }, }, - }; - const response = { + }, + { + name: 'legacy nested transaction tree', transactionTree: { transaction: { eventsById: { @@ -19,10 +29,26 @@ describe('findCreatedEventByTemplateId mock compatibility', () => { }, }, }, - } as unknown as SubmitAndWaitForTransactionTreeResponse; + }, + ])('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/utils/fixtureHelpers.ts b/test/utils/fixtureHelpers.ts index 5af67839..3d9dd8f2 100644 --- a/test/utils/fixtureHelpers.ts +++ b/test/utils/fixtureHelpers.ts @@ -126,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; @@ -145,11 +140,6 @@ 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; @@ -213,14 +203,3 @@ export function validateRequestMatchesFixture(actualRequest: unknown): void { throw error; } } - -/** - * Configure a client instance to use fixture-based mocking This spy function will validate requests and return fixture - * responses - */ -export function configureClientWithFixture(client: unknown): jest.SpyInstance { - const clientWithPrivateAccess = client as { - ledger: { submitAndWaitForTransactionTree: (...args: unknown[]) => unknown }; - }; - return jest.spyOn(clientWithPrivateAccess.ledger, 'submitAndWaitForTransactionTree'); -} diff --git a/test/utils/getFeaturedAppRightContractDetails.test.ts b/test/utils/getFeaturedAppRightContractDetails.test.ts index f563591a..f2f20f63 100644 --- a/test/utils/getFeaturedAppRightContractDetails.test.ts +++ b/test/utils/getFeaturedAppRightContractDetails.test.ts @@ -12,6 +12,7 @@ describe('getFeaturedAppRightContractDetails', () => { 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', @@ -22,26 +23,29 @@ describe('getFeaturedAppRightContractDetails', () => { }); }); - test('uses featured app synchronizer id when present', async () => { + 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: { - template_id: 'template', - contract_id: 'contract', - payload: {}, - created_event_blob: 'blob', - created_at: '2025-01-01T00:00:00Z', - }, + featured_app_right: featuredAppRightWithDomain, }; - (responseWithDomain.featured_app_right as Record).domain_id = 'featured-domain'; jest.spyOn(validatorApi, 'lookupFeaturedAppRight').mockResolvedValue(responseWithDomain); const featured = await getFeaturedAppRightContractDetails(validatorApi); - expect(featured.synchronizerId).toBe('featured-domain'); - expect(validatorApi.getAmuletRules).not.toHaveBeenCalled(); + expect(featured.synchronizerId).toBe( + 'global-domain::1220be58c29e65de40bf273be1dc2b266d43a9a002ea5b18955aeef7aac881bb471a' + ); + expect(validatorApi.getAmuletRules).toHaveBeenCalledTimes(1); }); }); From 3726f8009938d753228cd8f23bb79be1d101906d Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sun, 12 Jul 2026 09:26:56 -0400 Subject: [PATCH 09/11] test: harden package config exactness --- .../publicEntrypoint.types.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/package-consumer/publicEntrypoint.types.ts b/test/package-consumer/publicEntrypoint.types.ts index df732589..2077d33e 100644 --- a/test/package-consumer/publicEntrypoint.types.ts +++ b/test/package-consumer/publicEntrypoint.types.ts @@ -1,9 +1,12 @@ /** 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'; @@ -24,6 +27,22 @@ 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; @@ -64,3 +83,6 @@ void uncheckedIndexedString; void packageEntryPointKeepsEnvironmentDiscriminant; void packageExactnessRejectsCompilerAny; void packageExactnessRejectsNestedCompilerAny; +void packageOAuth2CredentialsStayRequired; +void packageSharedSecretEnvironmentsStayExact; +void packageMainNetNeverSupportsSharedSecret; From cb4c3a5b29ab3bfed3c0a649aae0d1c1ea6f00ff Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sun, 12 Jul 2026 09:28:28 -0400 Subject: [PATCH 10/11] test: refresh canonical type inventory --- test/schemaAlignment/canonicalOcfObjectInventory.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/schemaAlignment/canonicalOcfObjectInventory.json b/test/schemaAlignment/canonicalOcfObjectInventory.json index 5a575055..21ceaf14 100644 --- a/test/schemaAlignment/canonicalOcfObjectInventory.json +++ b/test/schemaAlignment/canonicalOcfObjectInventory.json @@ -1,5 +1,5 @@ { - "fingerprint": "39418162cb257fc316d7a15efc24d825223fac9a9d9d4d2d5104cc53ebb444c0", + "fingerprint": "1caabaa91de95a131908411cae0834824b73336bc7c7262dc945d24c948117d4", "objects": [ { "discriminator": "CE_STAKEHOLDER_RELATIONSHIP", From 22d8fb1b0bdf3ea34e43617d0c6e81d39c5b16bf Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sun, 12 Jul 2026 09:41:59 -0400 Subject: [PATCH 11/11] test: align schema fixture helper usage --- test/utils/ocfZodSchemas.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/utils/ocfZodSchemas.test.ts b/test/utils/ocfZodSchemas.test.ts index 34e264ea..6017e28b 100644 --- a/test/utils/ocfZodSchemas.test.ts +++ b/test/utils/ocfZodSchemas.test.ts @@ -408,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',