@fintekkers/ledger-models 0.4.47 → 0.4.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,8 @@
3
3
  import * as fs from 'fs';
4
4
  import * as path from 'path';
5
5
  import { Decimal } from 'decimal.js';
6
+ import * as ts from 'typescript';
7
+ import * as jspb from 'google-protobuf';
6
8
 
7
9
  import { ObjectTypeProto } from '../../fintekkers/models/util/object_type_pb';
8
10
  import { VersionProto } from '../../fintekkers/models/util/version_pb';
@@ -16,12 +18,28 @@ import { QuerySecurityResponseProto } from '../../fintekkers/requests/security/q
16
18
  import { QueryPortfolioResponseProto } from '../../fintekkers/requests/portfolio/query_portfolio_response_pb';
17
19
  import { QueryTransactionResponseProto } from '../../fintekkers/requests/transaction/query_transaction_response_pb';
18
20
  import { SummaryProto } from '../../fintekkers/requests/util/errors/summary_pb';
21
+ import { ProductTypeProto } from '../../fintekkers/models/security/product_type_pb';
22
+ import { IdentifierProto } from '../../fintekkers/models/security/identifier/identifier_pb';
23
+ import { CouponTypeProto } from '../../fintekkers/models/security/coupon_type_pb';
24
+ import { CouponFrequencyProto } from '../../fintekkers/models/security/coupon_frequency_pb';
25
+ import { IndexTypeProto } from '../../fintekkers/models/security/index/index_type_pb';
26
+ import { AgencyProto } from '../../fintekkers/models/security/bond/agency_pb';
27
+ import { CreatePriceResponseProto } from '../../fintekkers/requests/price/create_price_response_pb';
28
+ import { CreateSecurityRequestProto } from '../../fintekkers/requests/security/create_security_request_pb';
29
+ import { CreateSecurityResponseProto } from '../../fintekkers/requests/security/create_security_response_pb';
30
+ import { CreateTransactionResponseProto } from '../../fintekkers/requests/transaction/create_transaction_response_pb';
31
+ import { CreatePortfolioResponseProto } from '../../fintekkers/requests/portfolio/create_portfolio_response_pb';
19
32
 
20
33
  import * as objectTypes from './object-types';
21
34
  import { ObjectTyped } from './object-types';
22
35
  import LinkResolver from './link-resolver';
23
36
  import Price from '../models/price/Price';
24
37
  import Security from '../models/security/security';
38
+ import BondSecurity, { buildBondDetails } from '../models/security/BondSecurity';
39
+ import TIPSBond from '../models/security/TIPSBond';
40
+ import FloatingRateNote from '../models/security/FloatingRateNote';
41
+ import MortgageBackedSecurity from '../models/security/MortgageBackedSecurity';
42
+ import { Identifier } from '../models/security/identifier';
25
43
  import Portfolio from '../models/portfolio/portfolio';
26
44
  import Transaction from '../models/transaction/transaction';
27
45
  import { TransactionType } from '../models/transaction/transaction_type';
@@ -33,6 +51,7 @@ import { PriceService } from '../services/price-service/PriceService';
33
51
  import { SecurityService } from '../services/security-service/SecurityService';
34
52
  import { TransactionService } from '../services/transaction-service/TransactionService';
35
53
  import { PortfolioService } from '../services/portfolio-service/PortfolioService';
54
+ import { PositionService } from '../services/position-service/PositionService';
36
55
 
37
56
  // A PriceProto written by ledger-models v0.4.33: object_class / version
38
57
  // strings in fields 1 and 2.
@@ -70,7 +89,7 @@ test('bytes from v0.4.33 are not readable', () => {
70
89
  expect(() => PriceProto.deserializeBinary(old)).toThrow();
71
90
  });
72
91
 
73
- // ---- Every library writer stamps the new form (metric 2) ----
92
+ // ---- Every library builder stamps what it builds (metric 2; LM-324) ----
74
93
 
75
94
  /** A service with its gRPC client swapped for one that records each request. */
76
95
  function capturingService<S extends object>(cls: { prototype: S }, response: () => unknown): { service: S; requests: ObjectTyped[] } {
@@ -87,7 +106,7 @@ function capturingService<S extends object>(cls: { prototype: S }, response: ()
87
106
  };
88
107
  const service = Object.create(cls.prototype) as S;
89
108
  (service as unknown as { client: unknown }).client = {
90
- validateCreateOrUpdate: unary, search: stream,
109
+ validateCreateOrUpdate: unary, createOrUpdate: unary, validateQueryRequest: unary, search: stream,
91
110
  };
92
111
  return { service, requests };
93
112
  }
@@ -95,15 +114,46 @@ function capturingService<S extends object>(cls: { prototype: S }, response: ()
95
114
  const asOf = ZonedDateTime.now().toProto();
96
115
  const filter = (): PositionFilter => new PositionFilter();
97
116
 
98
- function security(): Security {
99
- return Security.create(new SecurityProto().setUuid(UUID.random().toUUIDProto()).setAssetClass('Equity'));
117
+ /** Marks a proto the caller passes in: it is passed through as is, never stamped. */
118
+ type Caller = <M extends jspb.Message>(proto: M) => M;
119
+
120
+ function security(caller: Caller): Security {
121
+ return Security.create(caller(new SecurityProto().setUuid(UUID.random().toUUIDProto()).setAssetClass('Equity')));
122
+ }
123
+
124
+ function portfolio(caller: Caller): Portfolio {
125
+ return new Portfolio(caller(new PortfolioProto().setUuid(UUID.random().toUUIDProto()).setPortfolioName('P').setAsOf(asOf)));
126
+ }
127
+
128
+ const BOND_INPUTS = {
129
+ faceValue: new Decimal('1000'),
130
+ couponRate: new Decimal('0.05'),
131
+ couponType: CouponTypeProto.FIXED,
132
+ couponFrequency: CouponFrequencyProto.SEMIANNUALLY,
133
+ issueDate: new Date(2024, 0, 1),
134
+ maturityDate: new Date(2034, 0, 1),
135
+ };
136
+
137
+ function bond(caller: Caller): Security {
138
+ return Security.create(caller(new SecurityProto().setUuid(UUID.random().toUUIDProto())
139
+ .setProductType(ProductTypeProto.TREASURY_NOTE).setBondDetails(buildBondDetails(BOND_INPUTS))));
100
140
  }
101
141
 
102
- function portfolio(): Portfolio {
103
- return new Portfolio(new PortfolioProto().setUuid(UUID.random().toUUIDProto()).setPortfolioName('P').setAsOf(asOf));
142
+ function transaction(caller: Caller, onSecurity: Security = security(caller)): Transaction {
143
+ return new Transaction({
144
+ tradeDate: new Date(2024, 0, 15),
145
+ settlementDate: new Date(2024, 0, 17),
146
+ asOfDate: new Date(2024, 0, 15, 10, 30, 0),
147
+ price: new Decimal('100.50'),
148
+ security: onSecurity,
149
+ transactionType: new TransactionType(TransactionTypeProto.BUY),
150
+ portfolio: portfolio(caller),
151
+ quantity: new Decimal('10'),
152
+ });
104
153
  }
105
154
 
106
- async function linkResolverRequests(): Promise<ObjectTyped[]> {
155
+ /** A LinkResolver whose clients record each request and find nothing. */
156
+ function capturingResolver(): { resolver: LinkResolver; requests: ObjectTyped[] } {
107
157
  const requests: ObjectTyped[] = [];
108
158
  const client = (response: () => unknown) => ({
109
159
  getByIds: (request: ObjectTyped, callback: (err: unknown, res: unknown) => void) => {
@@ -116,77 +166,469 @@ async function linkResolverRequests(): Promise<ObjectTyped[]> {
116
166
  portfolioClient: client(() => new QueryPortfolioResponseProto()) as never,
117
167
  transactionClient: client(() => new QueryTransactionResponseProto()) as never,
118
168
  });
119
- // Nothing comes back, so each lookup rejects as not found.
120
- await expect(resolver.getSecurity(UUID.random())).rejects.toThrow();
121
- await expect(resolver.getPortfolio(UUID.random())).rejects.toThrow();
122
- await expect(resolver.getTransaction(UUID.random())).rejects.toThrow();
123
- return requests;
169
+ return { resolver, requests };
124
170
  }
125
171
 
126
- type Writer = [string, () => Promise<ObjectTyped>, ObjectTypeProto];
172
+ /** The request a LinkResolver call sends. Nothing comes back, so a lookup may reject as not found. */
173
+ async function resolverRequest(call: (resolver: LinkResolver) => Promise<unknown>): Promise<ObjectTyped> {
174
+ const { resolver, requests } = capturingResolver();
175
+ await call(resolver).catch(() => undefined);
176
+ expect(requests).toHaveLength(1);
177
+ return requests[0];
178
+ }
127
179
 
128
- async function lastRequest<S extends object>(cls: { prototype: S }, call: (service: S) => Promise<unknown>): Promise<ObjectTyped> {
129
- const { service, requests } = capturingService(cls, () => new SummaryProto());
180
+ async function lastRequest<S extends object>(cls: { prototype: S }, call: (service: S) => Promise<unknown>,
181
+ response: () => unknown = () => new SummaryProto()): Promise<ObjectTyped> {
182
+ const { service, requests } = capturingService(cls, response);
130
183
  await call(service);
131
184
  return requests[requests.length - 1];
132
185
  }
133
186
 
134
- const LIBRARY_WRITERS: Writer[] = [
135
- ['Price.fromSimple', async () => Price.fromSimple(UUID.random().toString(), 1, new Date()).proto,
136
- ObjectTypeProto.OBJECT_TYPE_PRICE],
137
- ['Price.create', async () => Price.create(new Decimal(1), security(), ZonedDateTime.now()).proto,
138
- ObjectTypeProto.OBJECT_TYPE_PRICE],
139
- ['PositionFilter.toProto', async () => filter().toProto(),
140
- ObjectTypeProto.OBJECT_TYPE_POSITION_FILTER],
141
- ['QueryPositionRequest.toProto', async () => new QueryPositionRequest(
142
- filter(), PositionTypeProto.TRANSACTION, PositionViewProto.DEFAULT_VIEW, [], [], ZonedDateTime.now()).toProto(),
143
- ObjectTypeProto.OBJECT_TYPE_QUERY_POSITION_REQUEST],
144
- ['Transaction', async () => transaction().proto,
145
- ObjectTypeProto.OBJECT_TYPE_TRANSACTION],
146
- ['Transaction strategy allocation', async () => transaction().proto.getStrategyAllocation()!,
147
- ObjectTypeProto.OBJECT_TYPE_STRATEGY_ALLOCATION],
148
- ['PriceService.validateCreateOrUpdate', () => lastRequest(PriceService, (s) => s.validateCreateOrUpdate(new PriceProto())),
149
- ObjectTypeProto.OBJECT_TYPE_CREATE_PRICE_REQUEST],
150
- ['PriceService.search', () => lastRequest(PriceService, (s) => s.search(asOf, filter())),
151
- ObjectTypeProto.OBJECT_TYPE_QUERY_PRICE_REQUEST],
152
- ['SecurityService.validateCreateSecurity', () => lastRequest(SecurityService, (s) => s.validateCreateSecurity(new SecurityProto())),
153
- ObjectTypeProto.OBJECT_TYPE_CREATE_SECURITY_REQUEST],
154
- ['SecurityService.searchSecurity', () => lastRequest(SecurityService, (s) => s.searchSecurity(asOf, filter())),
155
- ObjectTypeProto.OBJECT_TYPE_QUERY_SECURITY_REQUEST],
156
- ['TransactionService.validateCreateTransaction', () => lastRequest(TransactionService,
157
- (s) => s.validateCreateTransaction({ proto: new TransactionProto() } as Transaction)),
158
- ObjectTypeProto.OBJECT_TYPE_CREATE_TRANSACTION_REQUEST],
159
- ['TransactionService.searchTransaction', () => lastRequest(TransactionService, (s) => s.searchTransaction(asOf, filter())),
160
- ObjectTypeProto.OBJECT_TYPE_QUERY_TRANSACTION_REQUEST],
161
- ['PortfolioService.validateCreatePortfolio', () => lastRequest(PortfolioService, (s) => s.validateCreatePortfolio(new PortfolioProto())),
162
- ObjectTypeProto.OBJECT_TYPE_CREATE_PORTFOLIO_REQUEST],
163
- ['PortfolioService.searchPortfolio', () => lastRequest(PortfolioService, (s) => s.searchPortfolio(asOf, filter())),
164
- ObjectTypeProto.OBJECT_TYPE_QUERY_PORTFOLIO_REQUEST],
165
- ['LinkResolver securities', async () => (await linkResolverRequests())[0],
166
- ObjectTypeProto.OBJECT_TYPE_QUERY_SECURITY_REQUEST],
167
- ['LinkResolver portfolios', async () => (await linkResolverRequests())[1],
168
- ObjectTypeProto.OBJECT_TYPE_QUERY_PORTFOLIO_REQUEST],
169
- ['LinkResolver transactions', async () => (await linkResolverRequests())[2],
170
- ObjectTypeProto.OBJECT_TYPE_QUERY_TRANSACTION_REQUEST],
187
+ interface Builder {
188
+ /** The publicBuilders() key. */
189
+ name: string;
190
+ /** The top-level message's own type. */
191
+ type: ObjectTypeProto;
192
+ /** Calls the builder; returns a message, a wrapper with `proto`, or an array of either. */
193
+ build: (caller: Caller) => unknown;
194
+ }
195
+
196
+ const B = (name: string, type: ObjectTypeProto, build: (caller: Caller) => unknown): Builder => ({ name, type, build });
197
+ const T = ObjectTypeProto;
198
+ const linkUuid = () => UUID.random();
199
+ const linkedPrice = (caller: Caller) => new Price(caller(new PriceProto().setSecurity(Security.linkOfLatest(linkUuid()))));
200
+ const linkedTransaction = (caller: Caller) => new Transaction(caller(new TransactionProto().setIsLink(true)
201
+ .setPortfolio(new PortfolioProto().setIsLink(true).setUuid(linkUuid().toUUIDProto()))));
202
+
203
+ /** Every public builder, by publicBuilders() key. */
204
+ const BUILDERS: Builder[] = [
205
+ B('Security.linkOf', T.OBJECT_TYPE_SECURITY, () => Security.linkOf(linkUuid(), ZonedDateTime.now())),
206
+ B('Security.linkOfLatest', T.OBJECT_TYPE_SECURITY, () => Security.linkOfLatest(linkUuid())),
207
+ B('BondSecurity.fromPricerInputs', T.OBJECT_TYPE_SECURITY, () => BondSecurity.fromPricerInputs(BOND_INPUTS)),
208
+ B('TIPSBond.fromPricerInputs', T.OBJECT_TYPE_SECURITY, () => TIPSBond.fromPricerInputs({
209
+ ...BOND_INPUTS, baseCpi: new Decimal('300'), indexDate: new Date(2024, 0, 1), inflationIndexType: IndexTypeProto.CPI_U })),
210
+ B('FloatingRateNote.fromPricerInputs', T.OBJECT_TYPE_SECURITY, () => FloatingRateNote.fromPricerInputs({
211
+ ...BOND_INPUTS, spread: new Decimal('0.001'), referenceRateIndex: IndexTypeProto.SOFR, resetFrequency: CouponFrequencyProto.MONTHLY })),
212
+ B('MortgageBackedSecurity.fromPricerInputs', T.OBJECT_TYPE_SECURITY, () => MortgageBackedSecurity.fromPricerInputs({
213
+ ...BOND_INPUTS, poolNumber: 'FN AS1234', agency: AgencyProto.FNMA, wac: new Decimal('0.045'), wam: 358,
214
+ passThroughRate: new Decimal('0.04'), currentFactor: new Decimal('0.95'), originalFaceValue: new Decimal('1000'),
215
+ currentUpb: new Decimal('950'), psaSpeed: new Decimal('150') })),
216
+ B('Identifier.fromName', T.OBJECT_TYPE_IDENTIFIER, () => Identifier.fromName('ISIN', 'US0378331005')),
217
+ B('Price.fromSimple', T.OBJECT_TYPE_PRICE, () => Price.fromSimple(UUID.random().toString(), 1, new Date())),
218
+ B('Price.create', T.OBJECT_TYPE_PRICE, (c) => Price.create(new Decimal(1), security(c), ZonedDateTime.now())),
219
+ B('Price.getCashPrice', T.OBJECT_TYPE_PRICE, (c) => Price.getCashPrice(security(c), ZonedDateTime.now())),
220
+ B('PositionFilter.toProto', T.OBJECT_TYPE_POSITION_FILTER, () => filter().toProto()),
221
+ B('QueryPositionRequest.toProto', T.OBJECT_TYPE_QUERY_POSITION_REQUEST, () => new QueryPositionRequest(
222
+ filter(), PositionTypeProto.TRANSACTION, PositionViewProto.DEFAULT_VIEW, [], [], ZonedDateTime.now()).toProto()),
223
+ B('Transaction.constructor', T.OBJECT_TYPE_TRANSACTION, (c) => transaction(c)),
224
+ B('Transaction.createCashTransaction', T.OBJECT_TYPE_TRANSACTION, (c) => Transaction.createCashTransaction(security(c), transaction(c))),
225
+ B('Transaction.addCashImpact', T.OBJECT_TYPE_TRANSACTION, (c) => {
226
+ const parent = transaction(c);
227
+ Transaction.addCashImpact(parent, security(c));
228
+ return parent.getChildrenTransactions();
229
+ }),
230
+ B('Transaction.addMaturationTransaction', T.OBJECT_TYPE_TRANSACTION, (c) => {
231
+ const parent = transaction(c, bond(c));
232
+ Transaction.addMaturationTransaction(parent, new TransactionType(TransactionTypeProto.MATURATION), security(c));
233
+ return parent.getChildrenTransactions();
234
+ }),
235
+ B('Transaction.addDerivedTransactions', T.OBJECT_TYPE_TRANSACTION, (c) => {
236
+ const parent = transaction(c, bond(c));
237
+ Transaction.addDerivedTransactions(parent, security(c));
238
+ return parent.getChildrenTransactions();
239
+ }),
240
+ B('PriceService.validateCreateOrUpdate', T.OBJECT_TYPE_CREATE_PRICE_REQUEST,
241
+ (c) => lastRequest(PriceService, (s) => s.validateCreateOrUpdate(c(new PriceProto())))),
242
+ B('PriceService.createOrUpdate', T.OBJECT_TYPE_CREATE_PRICE_REQUEST,
243
+ (c) => lastRequest(PriceService, (s) => s.createOrUpdate(c(new PriceProto())), () => new CreatePriceResponseProto())),
244
+ B('PriceService.search', T.OBJECT_TYPE_QUERY_PRICE_REQUEST, () => lastRequest(PriceService, (s) => s.search(asOf, filter()))),
245
+ B('PriceService.searchPrice', T.OBJECT_TYPE_QUERY_PRICE_REQUEST, () => lastRequest(PriceService, (s) => s.searchPrice(asOf, filter()))),
246
+ B('PriceService.searchPriceAsOfNow', T.OBJECT_TYPE_QUERY_PRICE_REQUEST, () => lastRequest(PriceService, (s) => s.searchPriceAsOfNow(filter()))),
247
+ B('PriceService.searchBySecurityId', T.OBJECT_TYPE_QUERY_PRICE_REQUEST,
248
+ () => lastRequest(PriceService, (s) => s.searchBySecurityId(UUID.random().toString(), asOf))),
249
+ B('PriceService.searchWithSecurities', T.OBJECT_TYPE_QUERY_PRICE_REQUEST,
250
+ () => lastRequest(PriceService, (s) => s.searchWithSecurities(asOf, filter(), capturingResolver().resolver))),
251
+ B('SecurityService.validateCreateSecurity', T.OBJECT_TYPE_CREATE_SECURITY_REQUEST,
252
+ (c) => lastRequest(SecurityService, (s) => s.validateCreateSecurity(c(new SecurityProto())))),
253
+ B('SecurityService.createSecurity', T.OBJECT_TYPE_CREATE_SECURITY_REQUEST,
254
+ (c) => lastRequest(SecurityService, (s) => s.createSecurity(c(new SecurityProto())), () => new CreateSecurityResponseProto())),
255
+ B('SecurityService.searchSecurity', T.OBJECT_TYPE_QUERY_SECURITY_REQUEST, () => lastRequest(SecurityService, (s) => s.searchSecurity(asOf, filter()))),
256
+ B('SecurityService.searchSecurityAsOfNow', T.OBJECT_TYPE_QUERY_SECURITY_REQUEST,
257
+ () => lastRequest(SecurityService, (s) => s.searchSecurityAsOfNow(filter()))),
258
+ B('TransactionService.validateCreateTransaction', T.OBJECT_TYPE_CREATE_TRANSACTION_REQUEST,
259
+ (c) => lastRequest(TransactionService, (s) => s.validateCreateTransaction({ proto: c(new TransactionProto()) } as Transaction))),
260
+ B('TransactionService.createTransaction', T.OBJECT_TYPE_CREATE_TRANSACTION_REQUEST,
261
+ (c) => lastRequest(TransactionService, (s) => s.createTransaction({ proto: c(new TransactionProto()) } as Transaction),
262
+ () => new CreateTransactionResponseProto())),
263
+ B('TransactionService.searchTransaction', T.OBJECT_TYPE_QUERY_TRANSACTION_REQUEST,
264
+ () => lastRequest(TransactionService, (s) => s.searchTransaction(asOf, filter()))),
265
+ B('TransactionService.searchWithSecurityAndPortfolio', T.OBJECT_TYPE_QUERY_TRANSACTION_REQUEST,
266
+ () => lastRequest(TransactionService, (s) => s.searchWithSecurityAndPortfolio(asOf, filter(), 100, capturingResolver().resolver))),
267
+ B('PortfolioService.validateCreatePortfolio', T.OBJECT_TYPE_CREATE_PORTFOLIO_REQUEST,
268
+ (c) => lastRequest(PortfolioService, (s) => s.validateCreatePortfolio(c(new PortfolioProto())))),
269
+ B('PortfolioService.createPortfolio', T.OBJECT_TYPE_CREATE_PORTFOLIO_REQUEST,
270
+ (c) => lastRequest(PortfolioService, (s) => s.createPortfolio(c(new PortfolioProto())), () => new CreatePortfolioResponseProto())),
271
+ B('PortfolioService.searchPortfolio', T.OBJECT_TYPE_QUERY_PORTFOLIO_REQUEST,
272
+ () => lastRequest(PortfolioService, (s) => s.searchPortfolio(asOf, filter()))),
273
+ B('PositionService.validateRequest', T.OBJECT_TYPE_QUERY_POSITION_REQUEST,
274
+ () => lastRequest(PositionService, (s) => s.validateRequest(QueryPositionRequest.from([], [])))),
275
+ B('PositionService.search', T.OBJECT_TYPE_QUERY_POSITION_REQUEST,
276
+ () => lastRequest(PositionService, (s) => s.search(QueryPositionRequest.from([], [])))),
277
+ B('LinkResolver.getSecurity', T.OBJECT_TYPE_QUERY_SECURITY_REQUEST, () => resolverRequest((r) => r.getSecurity(linkUuid()))),
278
+ B('LinkResolver.getPortfolio', T.OBJECT_TYPE_QUERY_PORTFOLIO_REQUEST, () => resolverRequest((r) => r.getPortfolio(linkUuid()))),
279
+ B('LinkResolver.getTransaction', T.OBJECT_TYPE_QUERY_TRANSACTION_REQUEST, () => resolverRequest((r) => r.getTransaction(linkUuid()))),
280
+ B('LinkResolver.resolveSecurities', T.OBJECT_TYPE_QUERY_SECURITY_REQUEST,
281
+ (c) => resolverRequest((r) => r.resolveSecurities([linkedPrice(c)]))),
282
+ B('LinkResolver.resolvePortfolios', T.OBJECT_TYPE_QUERY_PORTFOLIO_REQUEST,
283
+ (c) => resolverRequest((r) => r.resolvePortfolios([linkedTransaction(c)]))),
171
284
  ];
172
285
 
173
- function transaction(): Transaction {
174
- return new Transaction({
175
- tradeDate: new Date(2024, 0, 15),
176
- settlementDate: new Date(2024, 0, 17),
177
- asOfDate: new Date(2024, 0, 15, 10, 30, 0),
178
- price: new Decimal('100.50'),
179
- security: security(),
180
- transactionType: new TransactionType(TransactionTypeProto.BUY),
181
- portfolio: portfolio(),
182
- quantity: new Decimal('10'),
286
+ /** The messages a builder returned: wrappers are read through `proto`, arrays item by item. */
287
+ function messagesOf(built: unknown): jspb.Message[] {
288
+ return (Array.isArray(built) ? built : [built]).map((item) => {
289
+ const message = item instanceof jspb.Message ? item : (item as { proto?: unknown } | undefined)?.proto;
290
+ if (!(message instanceof jspb.Message)) throw new Error(`not a message or a wrapper of one: ${String(item)}`);
291
+ return message;
183
292
  });
184
293
  }
185
294
 
186
- test.each(LIBRARY_WRITERS)('%s stamps the type and schema version', async (_name, build, type) => {
187
- const message = await build();
295
+ // jspb.Message's own getters (getExtension, getJsPbMessageId, ...) are not fields.
296
+ const JSPB_GETTERS = new Set(Object.getOwnPropertyNames(jspb.Message.prototype));
188
297
 
189
- expect(objectTypes.readType(message)).toBe(type);
190
- expect(objectTypes.readVersion(message)).toEqual(objectTypes.SCHEMA_VERSION);
298
+ /** The zero-argument field getters of a generated message. */
299
+ function fieldGetters(message: jspb.Message): string[] {
300
+ const names: string[] = [];
301
+ for (let proto = Object.getPrototypeOf(message); proto && proto !== jspb.Message.prototype; proto = Object.getPrototypeOf(proto)) {
302
+ for (const name of Object.getOwnPropertyNames(proto)) {
303
+ const getter = proto[name];
304
+ if (name.startsWith('get') && !JSPB_GETTERS.has(name) && typeof getter === 'function' && getter.length === 0) names.push(name);
305
+ }
306
+ }
307
+ return names;
308
+ }
309
+
310
+ interface Walk {
311
+ /** Paths of messages with object_type that lack their own type or SCHEMA_VERSION. */
312
+ untyped: string[];
313
+ /** How many messages with object_type were checked. */
314
+ typed: number;
315
+ }
316
+
317
+ /**
318
+ * Visits `root` and every nested message (singular and repeated fields; no
319
+ * message with object_type has a map field of messages today). `callers`
320
+ * holds the protos the caller passed in: they are passed through as is, so
321
+ * they and what they hold are not checked here.
322
+ */
323
+ function untypedMessages(root: jspb.Message, callers: ReadonlySet<object> = new Set()): Walk {
324
+ const walk: Walk = { untyped: [], typed: 0 };
325
+ (function visit(value: unknown, at: string): void {
326
+ if (Array.isArray(value)) {
327
+ value.forEach((item, i) => visit(item, `${at}[${i}]`));
328
+ return;
329
+ }
330
+ if (!(value instanceof jspb.Message) || callers.has(value)) return;
331
+ const own = objectTypes.ownType(value);
332
+ if (own !== undefined) {
333
+ walk.typed++;
334
+ const message = value as unknown as ObjectTyped;
335
+ if (objectTypes.readType(message) !== own || String(objectTypes.readVersion(message)) !== String(objectTypes.SCHEMA_VERSION)) {
336
+ walk.untyped.push(at);
337
+ }
338
+ }
339
+ for (const getter of fieldGetters(value)) {
340
+ visit((value as unknown as Record<string, () => unknown>)[getter](), `${at}.${getter}`);
341
+ }
342
+ })(root, '$');
343
+ return walk;
344
+ }
345
+
346
+ test.each(BUILDERS.map((row) => [row.name, row] as const))('%s stamps every message it builds', async (_name, row) => {
347
+ const callers = new Set<object>();
348
+ const messages = messagesOf(await row.build((proto) => { callers.add(proto); return proto; }));
349
+
350
+ expect(messages.length).toBeGreaterThan(0);
351
+ for (const message of messages) {
352
+ expect(objectTypes.readType(message as unknown as ObjectTyped)).toBe(row.type);
353
+ const walk = untypedMessages(message, callers);
354
+ expect(walk.untyped).toEqual([]);
355
+ expect(walk.typed).toBeGreaterThan(0);
356
+ }
357
+ // Callers' protos are not re-stamped (LM-322's pass-through ruling).
358
+ for (const proto of callers) {
359
+ expect(objectTypes.readType(proto as ObjectTyped)).toBe(ObjectTypeProto.OBJECT_TYPE_UNKNOWN);
360
+ expect(objectTypes.readVersion(proto as ObjectTyped)).toBeUndefined();
361
+ }
191
362
  expect(objectTypes.SCHEMA_VERSION).toEqual([0, 0, 1]);
192
363
  });
364
+
365
+ test("callers' protos pass through as the same unstamped object", async () => {
366
+ const securityProto = new SecurityProto().setUuid(UUID.random().toUUIDProto());
367
+ const price = Price.create(new Decimal(1), new Security(securityProto), ZonedDateTime.now());
368
+ expect(price.proto.getSecurity()).toBe(securityProto);
369
+
370
+ const callers: jspb.Message[] = [];
371
+ const txn = transaction((proto) => { callers.push(proto); return proto; });
372
+ const [txnSecurity, txnPortfolio] = callers;
373
+ expect(txn.proto.getSecurity()).toBe(txnSecurity);
374
+ expect(txn.proto.getPortfolio()).toBe(txnPortfolio);
375
+
376
+ const input = new SecurityProto();
377
+ const request = await lastRequest(SecurityService, (s) => s.validateCreateSecurity(input)) as unknown as CreateSecurityRequestProto;
378
+ expect(request.getSecurityInput()).toBe(input);
379
+
380
+ for (const proto of [securityProto, txnSecurity, txnPortfolio, input] as unknown as ObjectTyped[]) {
381
+ expect(objectTypes.readType(proto)).toBe(ObjectTypeProto.OBJECT_TYPE_UNKNOWN);
382
+ expect(objectTypes.readVersion(proto)).toBeUndefined();
383
+ }
384
+ });
385
+
386
+ test('Price.fromSimple builds its security link, so the link is stamped, not passed through', () => {
387
+ const callers = new Set<object>();
388
+ const row = BUILDERS.find((b) => b.name === 'Price.fromSimple')!;
389
+ const price = messagesOf(row.build((proto) => { callers.add(proto); return proto; }))[0] as PriceProto;
390
+ expect(callers.size).toBe(0);
391
+ const link = price.getSecurity()!;
392
+ expect(objectTypes.readType(link)).toBe(ObjectTypeProto.OBJECT_TYPE_SECURITY);
393
+ expect(objectTypes.readVersion(link)).toEqual(objectTypes.SCHEMA_VERSION);
394
+ });
395
+
396
+ // ---- The walker finds what it must (it cannot pass by not looking) ----
397
+
398
+ const stampedPrice = () => objectTypes.stampOwn(new PriceProto());
399
+ const stampedSecurity = () => objectTypes.stampOwn(new SecurityProto());
400
+
401
+ test('the walker reports an unstamped nested message', () => {
402
+ const walk = untypedMessages(stampedPrice().setSecurity(new SecurityProto()));
403
+ expect(walk.untyped).toEqual(['$.getSecurity']);
404
+ expect(walk.typed).toBe(2);
405
+ });
406
+
407
+ test('the walker reports an unstamped message in a repeated field', () => {
408
+ const security = stampedSecurity().setIdentifiersList([objectTypes.stampOwn(new IdentifierProto()), new IdentifierProto()]);
409
+ expect(untypedMessages(security).untyped).toEqual(['$.getIdentifiersList[1]']);
410
+ });
411
+
412
+ test('the walker reports a message stamped with another type', () => {
413
+ const security = objectTypes.stamp(new SecurityProto(), ObjectTypeProto.OBJECT_TYPE_PRICE);
414
+ expect(untypedMessages(stampedPrice().setSecurity(security)).untyped).toEqual(['$.getSecurity']);
415
+ });
416
+
417
+ test('the walker reports a message with its type but no version', () => {
418
+ const security = new SecurityProto().setObjectType(ObjectTypeProto.OBJECT_TYPE_SECURITY);
419
+ expect(untypedMessages(stampedPrice().setSecurity(security)).untyped).toEqual(['$.getSecurity']);
420
+ });
421
+
422
+ test("the walker skips callers' protos", () => {
423
+ const input = new SecurityProto();
424
+ expect(untypedMessages(stampedPrice().setSecurity(input), new Set([input]))).toEqual({ untyped: [], typed: 1 });
425
+ });
426
+
427
+ // ---- Every public builder is tested or excluded (metric line 2) ----
428
+
429
+ const WRAPPERS_DIR = path.resolve(__dirname, '..');
430
+
431
+ /** Every non-test, non-declaration .ts under node/wrappers/. The generated fintekkers/**_pb are not scanned. */
432
+ function wrapperSources(dir = WRAPPERS_DIR): string[] {
433
+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
434
+ const file = path.join(dir, entry.name);
435
+ if (entry.isDirectory()) return wrapperSources(file);
436
+ return /\.ts$/.test(entry.name) && !/\.(test|d)\.ts$/.test(entry.name) ? [file] : [];
437
+ });
438
+ }
439
+
440
+ /**
441
+ * Public callables under node/wrappers/ that build a message, keyed
442
+ * `Class.method`, `Class.constructor` or `module.fn`, each with the rule that
443
+ * flagged it. Builders are found by type, not by name:
444
+ * (a) the return type holds an ObjectTyped message (after Promise, array, union);
445
+ * (b) the return type holds a wrapper whose `proto` is one;
446
+ * (c) the body has `new X()` where X is (a) or (b);
447
+ * (d) the body calls a flagged callable, public or not, repeated to a fixpoint.
448
+ * The service sources are scanned; only their integration tests are excluded
449
+ * from `npm test` (jest.config.js).
450
+ */
451
+ function publicBuilders(): Map<string, string> {
452
+ const configFile = path.resolve(WRAPPERS_DIR, '../../tsconfig.json');
453
+ const { config } = ts.readConfigFile(configFile, ts.sys.readFile);
454
+ const { options } = ts.parseJsonConfigFileContent(config, ts.sys, path.dirname(configFile));
455
+ const sources = wrapperSources();
456
+ const program = ts.createProgram(sources, { ...options, noEmit: true });
457
+ const checker = program.getTypeChecker();
458
+
459
+ const isMessage = (t: ts.Type) => !!t.getProperty('getObjectType') && !!t.getProperty('setVersion');
460
+ const isWrapper = (t: ts.Type) => {
461
+ const proto = t.getProperty('proto');
462
+ return !!proto?.valueDeclaration && isMessage(checker.getTypeOfSymbolAtLocation(proto, proto.valueDeclaration));
463
+ };
464
+ // The (a) or (b) rule a type meets, or undefined.
465
+ const builtBy = (t: ts.Type): string | undefined => {
466
+ const parts = t.isUnion() ? t.types : [t];
467
+ for (const part of parts) {
468
+ const unwrap = checker.isArrayType(part) || part.getSymbol()?.getName() === 'Promise';
469
+ const inner = unwrap ? checker.getTypeArguments(part as ts.TypeReference)[0] : part;
470
+ if (inner !== part) {
471
+ const rule = builtBy(inner);
472
+ if (rule) return rule;
473
+ } else if (isMessage(part)) {
474
+ return `(a) returns ${checker.typeToString(part)}`;
475
+ } else if (isWrapper(part)) {
476
+ return `(b) returns ${checker.typeToString(part)}, which wraps a message`;
477
+ }
478
+ }
479
+ return undefined;
480
+ };
481
+
482
+ type Callable = { key: string; isPublic: boolean; returns: ts.Type; bodies: ts.Node[] };
483
+ const callables = new Map<ts.Node, Callable>();
484
+ const isPrivate = (m: ts.ClassElement) => (!!m.name && ts.isPrivateIdentifier(m.name))
485
+ || (ts.getCombinedModifierFlags(m as ts.Declaration) & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected)) !== 0;
486
+
487
+ for (const sourceFile of program.getSourceFiles().filter((f) => sources.includes(path.resolve(f.fileName)))) {
488
+ const moduleName = path.basename(sourceFile.fileName, '.ts');
489
+ const exported = new Set<ts.Node>();
490
+ const moduleSymbol = checker.getSymbolAtLocation(sourceFile);
491
+ for (let symbol of moduleSymbol ? checker.getExportsOfModule(moduleSymbol) : []) {
492
+ if (symbol.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol);
493
+ symbol.declarations?.forEach((d) => exported.add(d));
494
+ }
495
+ const signatureOf = (decl: ts.SignatureDeclaration) => checker.getReturnTypeOfSignature(checker.getSignatureFromDeclaration(decl)!);
496
+
497
+ sourceFile.forEachChild(function visit(node) {
498
+ if (ts.isFunctionDeclaration(node) && node.name && node.body) {
499
+ callables.set(node, { key: `${moduleName}.${node.name.text}`, isPublic: exported.has(node), returns: signatureOf(node), bodies: [node.body] });
500
+ } else if (ts.isVariableStatement(node)) {
501
+ for (const d of node.declarationList.declarations) {
502
+ if (ts.isIdentifier(d.name) && d.initializer && (ts.isArrowFunction(d.initializer) || ts.isFunctionExpression(d.initializer))) {
503
+ callables.set(d.initializer, { key: `${moduleName}.${d.name.text}`, isPublic: exported.has(d), returns: signatureOf(d.initializer), bodies: [d.initializer.body] });
504
+ }
505
+ }
506
+ } else if (ts.isClassDeclaration(node) && node.name) {
507
+ const className = node.name.text;
508
+ const isPublic = exported.has(node);
509
+ const ctor = node.members.find(ts.isConstructorDeclaration);
510
+ const fieldInits = node.members.flatMap((m) =>
511
+ ts.isPropertyDeclaration(m) && m.initializer && !(ts.getCombinedModifierFlags(m) & ts.ModifierFlags.Static) ? [m.initializer] : []);
512
+ callables.set(ctor ?? node, {
513
+ key: `${className}.constructor`, isPublic,
514
+ returns: checker.getDeclaredTypeOfSymbol(checker.getSymbolAtLocation(node.name)!),
515
+ bodies: [...(ctor?.body ? [ctor.body] : []), ...fieldInits],
516
+ });
517
+ for (const m of node.members) {
518
+ if ((ts.isMethodDeclaration(m) || ts.isGetAccessorDeclaration(m)) && m.body && ts.isIdentifier(m.name)) {
519
+ callables.set(m, { key: `${className}.${m.name.text}`, isPublic: isPublic && !isPrivate(m), returns: signatureOf(m), bodies: [m.body] });
520
+ }
521
+ }
522
+ }
523
+ });
524
+ }
525
+
526
+ const flagged = new Map<ts.Node, string>();
527
+ for (const [decl, c] of callables) {
528
+ const rule = builtBy(c.returns);
529
+ if (rule) flagged.set(decl, rule);
530
+ }
531
+ for (const [decl, c] of callables) {
532
+ if (flagged.has(decl)) continue;
533
+ c.bodies.forEach(function visit(node: ts.Node): void {
534
+ if (flagged.has(decl)) return;
535
+ if (ts.isNewExpression(node)) {
536
+ const rule = builtBy(checker.getTypeAtLocation(node));
537
+ if (rule) flagged.set(decl, `(c) ${node.getText().split('(')[0]}(): ${rule}`);
538
+ }
539
+ node.forEachChild(visit);
540
+ });
541
+ }
542
+ // (d): calls to a flagged callable. `new X()` of a flagged class is already (c).
543
+ for (let changed = true; changed;) {
544
+ changed = false;
545
+ for (const [decl, c] of callables) {
546
+ if (flagged.has(decl)) continue;
547
+ c.bodies.forEach(function visit(node: ts.Node): void {
548
+ if (flagged.has(decl)) return;
549
+ if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
550
+ const target = checker.getResolvedSignature(node)?.declaration;
551
+ if (target && flagged.has(target)) {
552
+ flagged.set(decl, `(d) calls ${callables.get(target)!.key}`);
553
+ changed = true;
554
+ }
555
+ }
556
+ node.forEachChild(visit);
557
+ });
558
+ }
559
+ }
560
+
561
+ const builders = new Map<string, string>();
562
+ for (const [decl, rule] of flagged) {
563
+ const c = callables.get(decl)!;
564
+ if (c.isPublic) builders.set(c.key, rule);
565
+ }
566
+ return builders;
567
+ }
568
+
569
+ const RETURNS_STORED = 'an accessor: returns or wraps a proto it holds; builds nothing';
570
+ const WRAPS_CALLER = "wraps the caller's proto (pass-through ruling)";
571
+ const SERIALIZES = "converts values to untyped messages (dates, UUIDs, decimals) or returns the caller's Identifier proto as is";
572
+ const DESERIALIZES = 'decodes protos the caller or server sent into wrappers; builds no message';
573
+
574
+ /** Public callables the scan flags that build no message to stamp, each with its reason. */
575
+ const EXCLUSIONS: Record<string, string> = {
576
+ 'object-types.stamp': 'the stamping helper itself (tested above)',
577
+ 'object-types.stampOwn': 'the stamping helper itself (tested above)',
578
+ 'price_rules.validate': 'local stand-in for null input; never returned or sent',
579
+ 'price_rules.requireValid': 'local stand-in for null input; never returned or sent',
580
+ 'Security.create': "picks the wrapper class for the caller's proto (pass-through ruling)",
581
+ 'Position.fromJSON': DESERIALIZES,
582
+ 'ProtoSerializationUtil.deserialize': DESERIALIZES,
583
+ 'serialization.util.unpack': DESERIALIZES,
584
+ 'LocalDate.toDate': DESERIALIZES,
585
+ 'ProtoSerializationUtil.serialize': SERIALIZES,
586
+ 'serialization.util.pack': SERIALIZES,
587
+ 'PositionFilter.addFilter': SERIALIZES,
588
+ 'PositionFilter.addEqualsFilter': SERIALIZES,
589
+ 'PositionFilter.addEqualsStringFilter': SERIALIZES,
590
+ 'PositionFilter.addObjectFilter': SERIALIZES,
591
+ 'Security.hydrate': 'swaps in the proto LinkResolver.getSecurity fetched (that request is tested)',
592
+ 'Portfolio.hydrate': 'swaps in the proto LinkResolver.getPortfolio fetched (that request is tested)',
593
+ 'Transaction.hydrate': 'swaps in the proto LinkResolver.getTransaction fetched (that request is tested)',
594
+ ...Object.fromEntries([
595
+ 'BondSecurity.constructor', 'FloatingRateNote.constructor', 'Identifier.constructor', 'IndexSecurity.constructor',
596
+ 'Issuance.constructor', 'MortgageBackedSecurity.constructor', 'Portfolio.constructor', 'Position.constructor',
597
+ 'Price.constructor', 'Security.constructor', 'TIPSBond.constructor',
598
+ ].map((key) => [key, WRAPS_CALLER])),
599
+ ...Object.fromEntries([
600
+ 'BondSecurity.getIssuances', 'Price.getSecurity',
601
+ 'Position.getField', 'Position.getFieldDisplay', 'Position.getFieldValue', 'Position.getMeasureValue', 'Position.toString',
602
+ 'Security.getField', 'Security.getIdentifierByType', 'Security.getIdentifiers', 'Security.toString',
603
+ 'Transaction.getCashTransaction', 'Transaction.getChildrenTransactions', 'Transaction.getField', 'Transaction.getIssuerName',
604
+ 'Transaction.getPortfolio', 'Transaction.getPrice', 'Transaction.getSecurity', 'Transaction.getStrategyAllocation',
605
+ 'Transaction.isCashSecurity', 'Transaction.toString',
606
+ ].map((key) => [key, RETURNS_STORED])),
607
+ };
608
+
609
+ describe('every public builder is tested or excluded (metric line 2)', () => {
610
+ let scanned: Map<string, string>;
611
+ beforeAll(() => { scanned = publicBuilders(); }, 60_000);
612
+
613
+ test('every public builder is tested or excluded', () => {
614
+ const covered = new Set([...BUILDERS.map((b) => b.name), ...Object.keys(EXCLUSIONS)]);
615
+ const missing = [...scanned].filter(([key]) => !covered.has(key)).map(([key, rule]) => `${key}: rule ${rule}`);
616
+ expect(missing).toEqual([]);
617
+ });
618
+
619
+ test('no stale rows', () => {
620
+ const stale = [...BUILDERS.map((b) => b.name), ...Object.keys(EXCLUSIONS)].filter((key) => !scanned.has(key));
621
+ expect(stale).toEqual([]);
622
+ });
623
+
624
+ test('every exclusion has a reason', () => {
625
+ const blank = Object.entries(EXCLUSIONS).filter(([, reason]) => reason.trim().length === 0).map(([key]) => key);
626
+ expect(blank).toEqual([]);
627
+ });
628
+
629
+ test('no builder is both tested and excluded, and none is listed twice', () => {
630
+ const names = BUILDERS.map((b) => b.name);
631
+ expect(names.filter((name, i) => names.indexOf(name) !== i)).toEqual([]);
632
+ expect(names.filter((name) => Object.prototype.hasOwnProperty.call(EXCLUSIONS, name))).toEqual([]);
633
+ });
634
+ });