GitNexus/gitnexus/test/unit/spring-destinations.test.ts

1054 lines
43 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
hasUnescapedStringInterpolation,
isAddressShaped,
parseSpringStringLiteral,
resolveSpringDestination,
resolveSpringPlaceholders,
selectConsumerDestinationArguments,
selectProducerDestinationArguments,
splitSpringDestinationList,
type SpringDestinationCandidate,
} from '../../src/core/ingestion/frameworks/spring/destinations.js';
/**
* The pure half of Spring destination resolution: argument selection, the
* four-step cascade, and the refusal taxonomy.
*
* The NEGATIVE cases are pinned as hard as the positive ones on purpose. The
* recurring failure mode in this area is a suppression that also eats correct
* results, and the single most important assertion in the whole feature is that
* an UNRESOLVED address never yields something two services could join on — so
* every path that declines is asserted to decline for a NAMED reason, not
* merely to produce nothing.
*/
const candidate = (
rawText: string,
overrides: Partial<SpringDestinationCandidate> = {},
): SpringDestinationCandidate => ({
role: 'consumer',
source: 'KafkaListener',
broker: 'kafka',
argIndex: 0,
elementIndex: 0,
rawText,
...overrides,
});
describe('splitSpringDestinationList', () => {
it('splits the Java brace form', () => {
expect(splitSpringDestinationList('{"orders", "shipments"}')).toEqual([
'"orders"',
'"shipments"',
]);
});
it('splits the Kotlin bracket form', () => {
expect(splitSpringDestinationList('["orders", "shipments"]')).toEqual([
'"orders"',
'"shipments"',
]);
});
it('splits the Kotlin arrayOf form', () => {
expect(splitSpringDestinationList('arrayOf("orders", "shipments")')).toEqual([
'"orders"',
'"shipments"',
]);
});
it('leaves a scalar argument as one element', () => {
expect(splitSpringDestinationList('"orders"')).toEqual(['"orders"']);
expect(splitSpringDestinationList('Topics.ORDERS')).toEqual(['Topics.ORDERS']);
});
it('does not split on a comma inside a string literal', () => {
expect(splitSpringDestinationList('{"a,b", "c"}')).toEqual(['"a,b"', '"c"']);
});
it('does not split on a comma inside a nested call', () => {
expect(splitSpringDestinationList('{join("a", "b"), "c"}')).toEqual(['join("a", "b")', '"c"']);
});
it('reports an empty list as empty, which is different from one element', () => {
expect(splitSpringDestinationList('{}')).toEqual([]);
expect(splitSpringDestinationList('[]')).toEqual([]);
expect(splitSpringDestinationList('arrayOf()')).toEqual([]);
});
});
describe('parseSpringStringLiteral', () => {
it('unquotes the ordinary form', () => {
expect(parseSpringStringLiteral('"orders.v1"')).toBe('orders.v1');
});
it('unquotes a Kotlin raw string', () => {
expect(parseSpringStringLiteral('"""orders.v1"""')).toBe('orders.v1');
});
it('undoes the Kotlin dollar escape so a placeholder is still recognizable', () => {
// Kotlin requires `\$` or the compiler reads `${...}` as a string template.
// Without this the placeholder would be misfiled as a literal address.
expect(parseSpringStringLiteral('"\\${app.orders.topic}"')).toBe('${app.orders.topic}');
});
it('returns null for anything that is not a single literal', () => {
expect(parseSpringStringLiteral('Topics.ORDERS')).toBeNull();
expect(parseSpringStringLiteral('"a" + "b"')).toBeNull();
expect(parseSpringStringLiteral('{"a"}')).toBeNull();
});
it('does not fold a concatenation of two raw strings into one literal', () => {
// A greedy triple-quote body swallowed the operator and produced the single
// address `a""" + """b`, which no source ever wrote.
expect(parseSpringStringLiteral('"""a""" + """b"""')).toBeNull();
expect(parseSpringStringLiteral('"""a"""+"""b"""')).toBeNull();
});
});
describe('hasUnescapedStringInterpolation', () => {
it('sees the two Kotlin template forms', () => {
expect(hasUnescapedStringInterpolation('"orders-$env"')).toBe(true);
expect(hasUnescapedStringInterpolation('"orders-${env}"')).toBe(true);
});
it('does not see an ESCAPED dollar, which is how a Spring placeholder is written', () => {
expect(hasUnescapedStringInterpolation('"\\${app.topic}"')).toBe(false);
});
it('treats every dollar in a raw string as an interpolation', () => {
// Kotlin raw strings have no backslash escapes at all.
expect(hasUnescapedStringInterpolation('"""orders-$env"""')).toBe(true);
expect(hasUnescapedStringInterpolation('"""orders"""')).toBe(false);
});
it('ignores a dollar that starts no template', () => {
expect(hasUnescapedStringInterpolation('"price-$"')).toBe(false);
expect(hasUnescapedStringInterpolation('"cost-$9"')).toBe(false);
});
});
describe('resolveSpringPlaceholders', () => {
it('passes a value with no placeholder through as plain', () => {
expect(resolveSpringPlaceholders('orders')).toEqual({ plain: true });
});
it('reports a default without substituting it', () => {
// The default is written in the source, so it is READ — but configuration
// can override it and this graph never sees configuration values, so it is
// provenance, not an address and not an identity.
expect(resolveSpringPlaceholders('${app.orders.topic:orders}')).toEqual({
plain: false,
key: 'app.orders.topic',
defaultValue: 'orders',
});
});
it('splits on the first colon only', () => {
expect(resolveSpringPlaceholders('${app.topic:a:b}')).toEqual({
plain: false,
key: 'app.topic',
defaultValue: 'a:b',
});
});
it('reads the first placeholder of a larger value', () => {
expect(resolveSpringPlaceholders('prefix-${env:dev}-orders')).toEqual({
plain: false,
key: 'env',
defaultValue: 'dev',
});
});
it('reports the key, and no default, when there is none', () => {
expect(resolveSpringPlaceholders('${app.orders.topic}')).toEqual({
plain: false,
key: 'app.orders.topic',
});
});
it('reports a nested default as written, without expanding it', () => {
expect(resolveSpringPlaceholders('${a:${b}}')).toEqual({
plain: false,
key: 'a',
defaultValue: '${b}',
});
});
it('reports an empty key for a placeholder that names none', () => {
expect(resolveSpringPlaceholders('${}')).toEqual({ plain: false, key: '' });
});
it('refuses an unterminated placeholder rather than treating it as text', () => {
expect(resolveSpringPlaceholders('${app.topic').key).toBe('app.topic');
});
});
describe('the resolution cascade', () => {
it('step 1 resolves a literal', () => {
expect(resolveSpringDestination(candidate('"orders"'))).toEqual({
kind: 'resolved',
address: 'orders',
via: 'literal',
});
});
it('step 2 resolves a constant through the supplied resolver', () => {
const resolution = resolveSpringDestination(candidate('Topics.ORDERS'), {
constant: (name) => (name === 'Topics.ORDERS' ? 'orders.v1' : null),
});
expect(resolution).toEqual({ kind: 'resolved', address: 'orders.v1', via: 'constant' });
});
it('step 2 refuses, by name, when the constant cannot be folded', () => {
expect(resolveSpringDestination(candidate('Topics.ORDERS'), { constant: () => null })).toEqual({
kind: 'unresolved',
reason: 'unresolved-constant',
});
});
it('step 3 does NOT resolve a placeholder default, and keeps both key and default', () => {
// A default holds only while the key is not overridden, and configuration
// VALUES are deliberately absent from this graph, so the code cannot know
// whether it holds. Keying on it merged every service that copy-pasted the
// same fallback.
expect(resolveSpringDestination(candidate('"${app.orders.topic:orders}"'))).toEqual({
kind: 'unresolved',
reason: 'overridable-config-default',
configKey: 'app.orders.topic',
configDefault: 'orders',
});
});
it('does not merge two DIFFERENT keys that share a default', () => {
// The reproduction: `${a.topic:events}` in one service and
// `${b.topic:events}` in another collapsed onto one `Destination:events`
// and reported a producer/consumer pair between two unrelated services.
const a = resolveSpringDestination(candidate('"${a.topic:events}"'));
const b = resolveSpringDestination(candidate('"${b.topic:events}"'));
expect(a).not.toHaveProperty('address');
expect(b).not.toHaveProperty('address');
expect(a).toMatchObject({ configKey: 'a.topic', configDefault: 'events' });
expect(b).toMatchObject({ configKey: 'b.topic', configDefault: 'events' });
});
it('step 3 does NOT resolve a placeholder without a default, and records the key', () => {
expect(resolveSpringDestination(candidate('"${app.orders.topic}"'))).toEqual({
kind: 'unresolved',
reason: 'unresolved-config-key',
configKey: 'app.orders.topic',
});
});
it('runs step 3 on the value a constant folded to', () => {
// `static final String TOPIC = "${app.orders.topic}"` is an ordinary way to
// write a placeholder. Stopping after step 2 would publish the placeholder
// text as a resolved address — a shared identity for unrelated services.
const resolution = resolveSpringDestination(candidate('Topics.ORDERS'), {
constant: () => '${app.orders.topic}',
});
expect(resolution).toEqual({
kind: 'unresolved',
reason: 'unresolved-config-key',
configKey: 'app.orders.topic',
});
});
it('never returns the placeholder text as an address', () => {
const resolution = resolveSpringDestination(candidate('"${app.topic}"'));
expect(resolution.kind).toBe('unresolved');
expect(resolution).not.toHaveProperty('address');
});
it('refuses an expression that is neither a literal nor a constant reference', () => {
expect(resolveSpringDestination(candidate('"a" + suffix()'))).toEqual({
kind: 'unresolved',
reason: 'not-a-literal-or-constant',
});
});
it('refuses an empty literal, and says which kind of empty it was', () => {
// An empty address addresses nothing, and letting it through would give
// every such site one shared `''` identity.
expect(resolveSpringDestination(candidate('""'))).toEqual({
kind: 'unresolved',
reason: 'empty-literal-address',
});
expect(resolveSpringDestination(candidate('" "'))).toEqual({
kind: 'unresolved',
reason: 'empty-literal-address',
});
expect(resolveSpringDestination(candidate('Topics.EMPTY'), { constant: () => '' })).toEqual({
kind: 'unresolved',
reason: 'empty-constant-address',
});
});
it('files `${key:}` as an overridable default, keeping the key it used to drop', () => {
// It used to land in `empty-address`, which threw the key away and merged
// the case with three unrelated causes.
expect(resolveSpringDestination(candidate('"${app.topic:}"'))).toEqual({
kind: 'unresolved',
reason: 'overridable-config-default',
configKey: 'app.topic',
configDefault: '',
});
});
it('refuses `${}` without ever reporting an empty configuration key', () => {
const resolution = resolveSpringDestination(candidate('"${}"'));
expect(resolution).toEqual({ kind: 'unresolved', reason: 'empty-config-key' });
expect(resolution).not.toHaveProperty('configKey');
});
it('keeps an address exactly as written, whitespace included', () => {
// The emptiness test trims; the address does not. `" orders "` gets its own
// node rather than joining `orders` — a missing connection, which is the
// direction this module errs in everywhere.
expect(resolveSpringDestination(candidate('" orders "'))).toEqual({
kind: 'resolved',
address: ' orders ',
via: 'literal',
});
});
// ── SpEL: a runtime bean expression is not an address ────────────────────
it('refuses a SpEL expression instead of filing it as a literal address', () => {
// Reproduced before the fix: two unrelated services that each wrote
// `@KafkaListener(topics = "#{@kafkaProps.ordersTopic}")` produced ONE node
// with `address = "#{@kafkaProps.ordersTopic}"` and a CONSUMES_FROM edge
// from each.
for (const spel of [
'"#{@kafkaProps.ordersTopic}"',
'"#{environment[\'app.topic\']}"',
'"#{T(Topics).ORDERS}"',
'"orders-#{@env.suffix}"',
]) {
const resolution = resolveSpringDestination(candidate(spel));
expect(resolution).toEqual({ kind: 'unresolved', reason: 'spel-expression' });
expect(resolution).not.toHaveProperty('address');
}
});
it('calls a SpEL expression that wraps a placeholder SpEL, not a config key', () => {
// `"#{'${app.topics}'.split(',')}"` was caught only by accident, because it
// happens to contain `${`. The diagnosis has to name what it really is.
expect(resolveSpringDestination(candidate("\"#{'${app.topics}'.split(',')}\""))).toEqual({
kind: 'unresolved',
reason: 'spel-expression',
});
});
// ── String templates in a language that interpolates ─────────────────────
it('refuses an unescaped interpolation where literals interpolate', () => {
for (const template of ['"orders-$env"', '"orders-${env}"', '"""orders-$env"""']) {
const resolution = resolveSpringDestination(candidate(template), {
interpolatesStringLiterals: true,
});
expect(resolution).toEqual({ kind: 'unresolved', reason: 'unescaped-interpolation' });
expect(resolution).not.toHaveProperty('address');
expect(resolution).not.toHaveProperty('configKey');
}
});
it('still reads the ESCAPED form as a Spring placeholder where literals interpolate', () => {
expect(
resolveSpringDestination(candidate('"\\${app.topic}"'), {
interpolatesStringLiterals: true,
}),
).toEqual({ kind: 'unresolved', reason: 'unresolved-config-key', configKey: 'app.topic' });
});
it('leaves a non-interpolating language exactly as it was', () => {
// In Java `$` is an ordinary character and `${...}` is a Spring placeholder.
expect(resolveSpringDestination(candidate('"orders-$env"'))).toEqual({
kind: 'resolved',
address: 'orders-$env',
via: 'literal',
});
expect(resolveSpringDestination(candidate('"${app.topic}"'))).toEqual({
kind: 'unresolved',
reason: 'unresolved-config-key',
configKey: 'app.topic',
});
});
it('does not invent a configKey from a Kotlin `${var}` template', () => {
// The old reading produced `configKey: "env"` and the phase then emitted a
// USES edge to any Property named `env` — provenance with no source.
const resolution = resolveSpringDestination(candidate('"orders-${env}"'), {
interpolatesStringLiterals: true,
});
expect(resolution).not.toHaveProperty('configKey');
});
it('refuses an interpolating constant rather than folding it to an address', () => {
// A LIVE path, not a guard for a hypothetical future provider: Kotlin both
// interpolates and supplies a constant fold (`languages/kotlin.ts` declares
// `extractModuleConstants` and `foldRoutePathOperands`), and the pipeline
// test `folds a KOTLIN constant, not only a Java one` shows the fold
// reaching this cascade end to end.
expect(
resolveSpringDestination(candidate('Topics.TEMPLATE'), {
constant: () => 'orders-$env',
interpolatesStringLiterals: true,
}),
).toEqual({ kind: 'unresolved', reason: 'unescaped-interpolation' });
});
it('misfiles a folded placeholder as interpolation — the cost of the lost escape', () => {
// By the time a folded value reaches this branch its escapes are gone, so
// `"\${app.topic}"` (a Spring placeholder, in Kotlin) and `"${app.topic}"`
// (a runtime template) are the same string and the guard fires first. The
// reason is then `unescaped-interpolation` where `unresolved-config-key`
// would have been more precise.
//
// Pinned rather than left implicit because the trade is the point: BOTH
// readings are unresolved, so the error can only ever be a misfiled reason
// in the refusal breakdown, never a false address. The precise diagnosis
// needs the fold to report whether the declaration was escaped, which the
// shared constant shape does not carry.
const resolution = resolveSpringDestination(candidate('Topics.CONFIGURED'), {
constant: () => '${app.topic}',
interpolatesStringLiterals: true,
});
expect(resolution).toEqual({ kind: 'unresolved', reason: 'unescaped-interpolation' });
expect(resolution).not.toHaveProperty('address');
});
it('leaves step 4 unimplemented, and consults it only when supplied', () => {
// The seam is deliberately not wired in production; this pins its contract.
expect(resolveSpringDestination(candidate('"${app.topic}"')).kind).toBe('unresolved');
expect(
resolveSpringDestination(candidate('"${app.topic}"'), {
specification: () => 'orders.from.spec',
}),
).toEqual({ kind: 'resolved', address: 'orders.from.spec', via: 'specification' });
});
});
describe('consumer argument selection', () => {
it('reads @KafkaListener(topics = …)', () => {
const selection = selectConsumerDestinationArguments(
'org.springframework.kafka.annotation.KafkaListener',
[{ name: 'topics', text: '"orders"' }],
);
expect(selection?.candidates).toHaveLength(1);
expect(selection?.candidates[0]).toMatchObject({ broker: 'kafka', argName: 'topics' });
expect(selection?.refusals).toEqual([]);
});
it('reads @RabbitListener(queues = …) and @JmsListener(destination = …)', () => {
expect(
selectConsumerDestinationArguments('RabbitListener', [{ name: 'queues', text: '"orders"' }])
?.candidates[0],
).toMatchObject({ broker: 'rabbit' });
expect(
selectConsumerDestinationArguments('JmsListener', [{ name: 'destination', text: '"orders"' }])
?.candidates[0],
).toMatchObject({ broker: 'jms' });
});
it('reads @ServiceActivator(inputChannel = …)', () => {
expect(
selectConsumerDestinationArguments('ServiceActivator', [
{ name: 'inputChannel', text: '"orders"' },
])?.candidates[0],
).toMatchObject({ broker: 'integration', argName: 'inputChannel' });
});
it('reads a positional argument only where `value` really is the destination', () => {
expect(
selectConsumerDestinationArguments('SqsListener', [{ text: '"orders"' }])?.candidates,
).toHaveLength(1);
expect(
selectConsumerDestinationArguments('StreamListener', [{ text: '"orders"' }])?.candidates,
).toHaveLength(1);
// @KafkaListener declares no `value` alias for its topics, so a positional
// argument there is a different element and must not be guessed at.
const kafka = selectConsumerDestinationArguments('KafkaListener', [{ text: '"orders"' }]);
expect(kafka?.candidates).toEqual([]);
expect(kafka?.refusals.map((r) => r.reason)).toEqual(['no-destination-argument']);
});
it('yields one candidate per element for an array, not a group', () => {
const selection = selectConsumerDestinationArguments('KafkaListener', [
{ name: 'topics', text: '{"orders", "shipments"}' },
]);
expect(selection?.candidates.map((c) => c.rawText)).toEqual(['"orders"', '"shipments"']);
expect(selection?.candidates.map((c) => c.elementIndex)).toEqual([0, 1]);
});
it('refuses topicPattern as a pattern, not an address', () => {
const selection = selectConsumerDestinationArguments('KafkaListener', [
{ name: 'topicPattern', text: '"orders.*"' },
]);
expect(selection?.candidates).toEqual([]);
expect(selection?.refusals.map((r) => r.reason)).toEqual(['topic-pattern']);
});
it('refuses argument shapes it will not read, by name', () => {
expect(
selectConsumerDestinationArguments('RabbitListener', [
{ name: 'bindings', text: '@QueueBinding(value = @Queue("orders"))' },
])?.refusals.map((r) => r.reason),
).toEqual(['unsupported-annotation-argument']);
expect(
selectConsumerDestinationArguments('KafkaListener', [
{ name: 'topicPartitions', text: '@TopicPartition(topic = "orders", partitions = "0")' },
])?.refusals.map((r) => r.reason),
).toEqual(['unsupported-annotation-argument']);
});
it('refuses an empty destination list rather than passing silently', () => {
const selection = selectConsumerDestinationArguments('KafkaListener', [
{ name: 'topics', text: '{}' },
]);
expect(selection?.candidates).toEqual([]);
expect(selection?.refusals.map((r) => r.reason)).toEqual(['empty-destination-list']);
});
it('keeps unread arguments and an empty argument list apart', () => {
// Absent = the capture never read the list. Empty = a list WAS read and it
// was empty, which is a fact about the source. Filing a source-level gap
// under a tooling gap corrupts the reason breakdown this feature is
// measured on, and the producer side of this module already distinguishes
// them.
expect(
selectConsumerDestinationArguments('KafkaListener', undefined)?.refusals.map((r) => r.reason),
).toEqual(['annotation-arguments-unavailable']);
expect(
selectConsumerDestinationArguments('KafkaListener', [])?.refusals.map((r) => r.reason),
).toEqual(['annotation-arguments-empty']);
});
it('records a listener whose arguments name no destination', () => {
const selection = selectConsumerDestinationArguments('KafkaListener', [
{ name: 'groupId', text: '"orders-group"' },
]);
expect(selection?.refusals.map((r) => r.reason)).toEqual(['no-destination-argument']);
});
it('excludes WebSocket/STOMP mappings entirely', () => {
// @MessageMapping and @SubscribeMapping are session-scoped application
// routes, not broker addresses. Modelling them as destinations would put a
// STOMP path in the same namespace as a Kafka topic.
expect(
selectConsumerDestinationArguments('MessageMapping', [{ text: '"/topic/prices"' }]),
).toBeNull();
expect(
selectConsumerDestinationArguments('SubscribeMapping', [{ text: '"/topic/prices"' }]),
).toBeNull();
});
it('records a repeated-listener container as a refusal, not as nothing', () => {
// Capture does not descend into the nested annotations, so a repository
// using these really does lose those destinations. Returning an empty
// selection made that indistinguishable from a repository with no
// listeners, and was observationally identical to returning `null` — the
// caller skips both — so the stated reason for the branch did not hold.
for (const [container, broker] of [
['KafkaListeners', 'kafka'],
['RabbitListeners', 'rabbit'],
['JmsListeners', 'jms'],
['PulsarListeners', 'pulsar'],
] as const) {
const selection = selectConsumerDestinationArguments(container, [
{ text: '{@Listener(topics = "a")}' },
]);
expect(selection?.candidates).toEqual([]);
expect(selection?.refusals).toHaveLength(1);
expect(selection?.refusals[0]).toMatchObject({
reason: 'repeated-listener-container',
broker,
source: container,
});
}
});
});
describe('producer argument selection', () => {
const args = (...texts: string[]) => texts.map((text) => ({ text }));
it('takes argument 0 for kafka send', () => {
const selection = selectProducerDestinationArguments({
template: 'kafka',
methodName: 'send',
args: args('"orders"', 'payload'),
});
expect(selection.candidates).toHaveLength(1);
expect(selection.candidates[0]).toMatchObject({ broker: 'kafka', argIndex: 0 });
});
it('takes argument 0 for a stream-bridge binding and reports the broker as stream', () => {
expect(
selectProducerDestinationArguments({
template: 'stream-bridge',
methodName: 'send',
args: args('"orders-out-0"', 'payload'),
}).candidates[0],
).toMatchObject({ broker: 'stream', argIndex: 0 });
});
it('takes argument 0 for jms convertAndSend', () => {
expect(
selectProducerDestinationArguments({
template: 'jms',
methodName: 'convertAndSend',
args: args('"orders"', 'payload'),
}).candidates[0],
).toMatchObject({ broker: 'jms', argIndex: 0 });
});
it('refuses a single-argument send: the destination is inside an object', () => {
// send(ProducerRecord) / send(Message<?>) / convertAndSend(Object).
for (const template of ['kafka', 'jms', 'stream-bridge'] as const) {
const selection = selectProducerDestinationArguments({
template,
methodName:
template === 'kafka' || template === 'stream-bridge' ? 'send' : 'convertAndSend',
args: args('record'),
});
expect(selection.candidates).toEqual([]);
expect(selection.refusals.map((r) => r.reason)).toEqual(['producer-arity-unrecognized']);
}
});
it('refuses a two-argument jms convertAndSend whose slot 0 is not confidently an address', () => {
// `convertAndSend(message, postProcessor)` has the same arity as
// `convertAndSend(destination, message)`. Shape, not arity, is what tells
// them apart, so a payload in slot 0 must refuse rather than be published.
const selection = selectProducerDestinationArguments({
template: 'jms',
methodName: 'convertAndSend',
args: args('message', 'postProcessor'),
});
expect(selection.candidates).toEqual([]);
expect(selection.refusals.map((r) => r.reason)).toEqual([
'producer-argument-not-address-shaped',
]);
});
it('relaxes the jms gate at three arguments, where slot 0 is always the destination', () => {
const selection = selectProducerDestinationArguments({
template: 'jms',
methodName: 'convertAndSend',
args: args('destination', 'message', 'postProcessor'),
});
expect(selection.candidates).toHaveLength(1);
expect(selection.candidates[0]).toMatchObject({ argIndex: 0, rawText: 'destination' });
});
it('records a Kotlin trailing-lambda call as arguments-unavailable', () => {
const selection = selectProducerDestinationArguments({
template: 'kafka',
methodName: 'send',
});
expect(selection.refusals.map((r) => r.reason)).toEqual(['producer-arguments-unavailable']);
});
describe('rabbit convertAndSend arity rules', () => {
it('refuses one argument: the default exchange has no address in the source', () => {
const selection = selectProducerDestinationArguments({
template: 'rabbit',
methodName: 'convertAndSend',
args: args('payload'),
});
expect(selection.candidates).toEqual([]);
expect(selection.refusals.map((r) => r.reason)).toEqual(['rabbit-default-exchange']);
});
it('reads two arguments as routingKey + message', () => {
const selection = selectProducerDestinationArguments({
template: 'rabbit',
methodName: 'convertAndSend',
args: args('"orders.created"', 'payload'),
});
expect(selection.candidates).toHaveLength(1);
expect(selection.candidates[0]).toMatchObject({ argIndex: 0, rawText: '"orders.created"' });
expect(selection.candidates[0]?.exchange).toBeUndefined();
});
it('reads three arguments as exchange + routingKey + message', () => {
// Slot 1 is a CONSTANT, which is why this one is readable at all: a
// literal there would be indistinguishable from a String payload under
// the competing `(routingKey, message, correlationData)` overload, and
// is refused instead (see the ambiguity test below).
const selection = selectProducerDestinationArguments({
template: 'rabbit',
methodName: 'convertAndSend',
args: args('"orders.exchange"', 'Topics.ORDERS_KEY', 'payload'),
});
expect(selection.candidates).toHaveLength(1);
// The routing key is the address; the exchange rides along as provenance.
expect(selection.candidates[0]).toMatchObject({
argIndex: 1,
rawText: 'Topics.ORDERS_KEY',
exchange: 'orders.exchange',
});
});
it('REFUSES at three arguments when slot 1 is not confidently an address', () => {
// `convertAndSend(routingKey, message, postProcessor)` and
// `convertAndSend(routingKey, message, correlationData)` are three
// arguments with no exchange, and they are indistinguishable from
// `convertAndSend(exchange, routingKey, message)` by arity. The former
// code fell back to slot 0 here, which is what published an EXCHANGE as
// an address; there is no fallback now.
const selection = selectProducerDestinationArguments({
template: 'rabbit',
methodName: 'convertAndSend',
args: args('"orders.created"', 'payload', 'postProcessor'),
});
expect(selection.candidates).toEqual([]);
expect(selection.refusals.map((r) => r.reason)).toEqual([
'producer-argument-not-address-shaped',
]);
// The refusal names the slot that failed, not an arbitrary one.
expect(selection.refusals[0]).toMatchObject({ argIndex: 1 });
});
it('never publishes the EXCHANGE as an address when the routing key is a variable', () => {
// The reproduction, in the three spellings it was reproduced in. Each of
// these is an ordinary Spring AMQP call whose routing key happens to be a
// variable; the last two even fold to a real exchange name, so the graph
// got a plausible-looking wrong address that can join a
// @RabbitListener(queues = "orders").
for (const exchange of ['"orders.exchange"', 'Exchanges.ORDERS', 'ORDERS_EXCHANGE']) {
const selection = selectProducerDestinationArguments({
template: 'rabbit',
methodName: 'convertAndSend',
args: args(exchange, 'routingKey', 'event'),
});
expect(selection.candidates).toEqual([]);
expect(selection.refusals.map((r) => r.reason)).toEqual([
'producer-argument-not-address-shaped',
]);
}
});
it('accepts a screaming-snake constant as confident evidence at three arguments', () => {
const selection = selectProducerDestinationArguments({
template: 'rabbit',
methodName: 'convertAndSend',
args: args('ORDERS_EXCHANGE', 'ORDERS_ROUTING_KEY', 'payload'),
});
expect(selection.candidates[0]).toMatchObject({
argIndex: 1,
rawText: 'ORDERS_ROUTING_KEY',
exchange: 'ORDERS_EXCHANGE',
});
});
it('gates four arguments exactly like three', () => {
// Arity does not fix the overload here either:
// `(routingKey, message, postProcessor, correlationData)` also reaches
// four, so the permissive gate that used to apply at this arity let a
// payload through as an address.
const permissiveOnly = selectProducerDestinationArguments({
template: 'rabbit',
methodName: 'convertAndSend',
args: args('"orders.exchange"', 'routingKey', 'payload', 'postProcessor'),
});
expect(permissiveOnly.candidates).toEqual([]);
expect(permissiveOnly.refusals.map((r) => r.reason)).toEqual([
'producer-argument-not-address-shaped',
]);
const confident = selectProducerDestinationArguments({
template: 'rabbit',
methodName: 'convertAndSend',
args: args('"orders.exchange"', 'ORDERS_ROUTING_KEY', 'payload', 'postProcessor'),
});
expect(confident.candidates[0]).toMatchObject({
argIndex: 1,
rawText: 'ORDERS_ROUTING_KEY',
exchange: 'orders.exchange',
});
// ...and the ambiguity rule reaches four arguments too, because
// `(routingKey, message, postProcessor, correlationData)` also has four.
const ambiguous = selectProducerDestinationArguments({
template: 'rabbit',
methodName: 'convertAndSend',
args: args('"orders.exchange"', '"body"', 'payload', 'postProcessor'),
});
expect(ambiguous.candidates).toEqual([]);
expect(ambiguous.refusals.map((r) => r.reason)).toEqual(['ambiguous-producer-overload']);
});
it('applies the same rule to the five-argument overload', () => {
const selection = selectProducerDestinationArguments({
template: 'rabbit',
methodName: 'convertAndSend',
args: args('"x"', '"orders.created"', 'payload', 'postProcessor', 'correlation'),
});
expect(selection.candidates[0]).toMatchObject({ argIndex: 1, exchange: 'x' });
});
it('reads two arguments as routingKey + message only on confident evidence', () => {
const selection = selectProducerDestinationArguments({
template: 'rabbit',
methodName: 'convertAndSend',
args: args('message', 'postProcessor'),
});
expect(selection.candidates).toEqual([]);
expect(selection.refusals.map((r) => r.reason)).toEqual([
'producer-argument-not-address-shaped',
]);
});
it('REFUSES the one ambiguity it cannot settle, rather than choosing', () => {
// `convertAndSend("orders.rk", "body", correlationData)` fits two real
// overloads that disagree about which slot is the address:
//
// (exchange, routingKey, message) → the address is `"body"`
// (routingKey, message, correlationData) → the address is `"orders.rk"`
//
// Both are spelled (String, String, ref). This used to take the exchange
// reading, which made the String PAYLOAD the address — and a
// `@RabbitListener(queues = "body")` anywhere in the repository then
// joined a publisher that has never written to it. A refusal is counted
// and recoverable; that edge enters a report as a fact.
const selection = selectProducerDestinationArguments({
template: 'rabbit',
methodName: 'convertAndSend',
args: args('"orders.rk"', '"body"', 'correlationData'),
});
expect(selection.candidates).toEqual([]);
expect(selection.refusals.map((r) => r.reason)).toEqual(['ambiguous-producer-overload']);
// The refusal names the slot that could not be read, not an arbitrary one.
expect(selection.refusals[0]).toMatchObject({ argIndex: 1, rawText: '"body"' });
});
// ── The refusal must not eat the cases that ARE decidable ──────────────
//
// The recurring way a suppression goes wrong here is by taking correct
// results down with the wrong one, and the loss is silent: a destination
// that stops being emitted looks exactly like a repository that never had
// one. Each case below is a reading the syntax really does settle.
it('still resolves a CONSTANT routing key at the ambiguous arities', () => {
// The spelling is the evidence — the same premise `isConfidentAddressShape`
// is built on. `ORDERS_ROUTING_KEY` and `Topics.ORDERS_KEY` are how a
// configured NAME is written; a payload computed at the call site is not.
for (const key of ['ORDERS_ROUTING_KEY', 'Topics.ORDERS_KEY']) {
for (const trailing of [['payload'], ['payload', 'postProcessor']]) {
const selection = selectProducerDestinationArguments({
template: 'rabbit',
methodName: 'convertAndSend',
args: args('"orders.exchange"', key, ...trailing),
});
expect(selection.refusals).toEqual([]);
expect(selection.candidates[0]).toMatchObject({
argIndex: 1,
rawText: key,
exchange: 'orders.exchange',
});
}
}
});
it('still resolves a string-literal routing key at FIVE arguments', () => {
// `(exchange, routingKey, message, postProcessor, correlationData)` is
// the only five-argument overload there is, so nothing competes for slot
// 1 and the literal is not ambiguous. A refusal written on the shape of
// the argument alone, without the arity, would have cut this.
const selection = selectProducerDestinationArguments({
template: 'rabbit',
methodName: 'convertAndSend',
args: args('"x"', '"orders.created"', 'payload', 'postProcessor', 'correlation'),
});
expect(selection.refusals).toEqual([]);
expect(selection.candidates[0]).toMatchObject({
argIndex: 1,
rawText: '"orders.created"',
exchange: 'x',
});
});
it('still resolves a string-literal routing key at TWO arguments', () => {
// `(routingKey, message)` competes only with `(message, postProcessor)`
// and `(message, correlationData)`, and slot 0 is the routing key in the
// only one of the three that carries an address at all.
const selection = selectProducerDestinationArguments({
template: 'rabbit',
methodName: 'convertAndSend',
args: args('"orders.created"', 'payload'),
});
expect(selection.refusals).toEqual([]);
expect(selection.candidates[0]).toMatchObject({ argIndex: 0 });
});
it('still resolves a string-literal routing key passed BY NAME', () => {
// A name settles which slot is which outright, so the ambiguity the
// refusal exists for does not arise. Refusing on the literal alone would
// have thrown away the one form that states the answer explicitly.
const selection = selectProducerDestinationArguments({
template: 'rabbit',
methodName: 'convertAndSend',
args: [
{ text: '"orders.exchange"' },
{ name: 'routingKey', text: '"orders.created"' },
{ text: 'payload' },
],
});
expect(selection.refusals).toEqual([]);
expect(selection.candidates[0]).toMatchObject({
argName: 'routingKey',
rawText: '"orders.created"',
});
});
it('does not apply the ambiguity rule to the other templates', () => {
// Only Rabbit has an overload family in which slot 1 can be either the
// routing key or the message. Kafka, JMS and StreamBridge put the
// destination first in every multi-argument overload they have, so a
// literal in slot 1 there is simply the payload and says nothing.
for (const template of ['kafka', 'jms', 'stream-bridge'] as const) {
const selection = selectProducerDestinationArguments({
template,
methodName:
template === 'kafka' || template === 'stream-bridge' ? 'send' : 'convertAndSend',
args: args('"orders.v1"', '"body"', 'extra'),
});
expect(selection.refusals).toEqual([]);
expect(selection.candidates[0]).toMatchObject({ argIndex: 0, rawText: '"orders.v1"' });
}
});
});
describe('named arguments', () => {
it('selects by NAME, not by position', () => {
// Kotlin argument lists need not be in parameter order.
// `kafkaTemplate.send(data = "payload", topic = "orders")` selected
// args[0] and published the PAYLOAD as the address.
const selection = selectProducerDestinationArguments({
template: 'kafka',
methodName: 'send',
args: [
{ name: 'data', text: '"payload"' },
{ name: 'topic', text: '"orders"' },
],
});
expect(selection.candidates).toHaveLength(1);
expect(selection.candidates[0]).toMatchObject({
argIndex: 1,
argName: 'topic',
rawText: '"orders"',
});
});
it('reads a rabbit exchange passed by name as provenance, not as the address', () => {
const selection = selectProducerDestinationArguments({
template: 'rabbit',
methodName: 'convertAndSend',
args: [
{ name: 'message', text: 'payload' },
{ name: 'exchange', text: '"orders.exchange"' },
{ name: 'routingKey', text: '"orders.created"' },
],
});
expect(selection.candidates[0]).toMatchObject({
rawText: '"orders.created"',
exchange: 'orders.exchange',
});
});
it('knows the destination parameter of the other templates', () => {
expect(
selectProducerDestinationArguments({
template: 'jms',
methodName: 'convertAndSend',
args: [
{ name: 'message', text: 'payload' },
{ name: 'destinationName', text: '"orders.jms"' },
],
}).candidates[0],
).toMatchObject({ rawText: '"orders.jms"' });
expect(
selectProducerDestinationArguments({
template: 'stream-bridge',
methodName: 'send',
args: [
{ name: 'data', text: 'payload' },
{ name: 'bindingName', text: '"orders-out-0"' },
],
}).candidates[0],
).toMatchObject({ rawText: '"orders-out-0"' });
});
it('still reads a partially named list that puts the destination first', () => {
// `send("orders", data = payload)` is legal — positional arguments
// precede named ones — and slot 0 really is the topic. Refusing every
// list that contains any name at all would have cost this.
const selection = selectProducerDestinationArguments({
template: 'kafka',
methodName: 'send',
args: [{ text: '"orders"' }, { name: 'data', text: 'payload' }],
});
expect(selection.candidates).toHaveLength(1);
expect(selection.candidates[0]).toMatchObject({ argIndex: 0, rawText: '"orders"' });
});
it('refuses when the POSITIONAL slot is named after something else', () => {
// The residual of the rule above: no name matched a destination
// parameter, so the positional reading ran — and landed on an argument
// whose own name contradicts it.
const selection = selectProducerDestinationArguments({
template: 'kafka',
methodName: 'send',
args: [
{ name: 'data', text: '"payload"' },
{ name: 'partition', text: '0' },
],
});
expect(selection.candidates).toEqual([]);
expect(selection.refusals.map((r) => r.reason)).toEqual([
'producer-named-argument-unrecognized',
]);
});
});
it('keeps the permissive gate where arity already fixes the slot', () => {
// kafka `send` puts the topic first in every multi-argument overload, so a
// bare identifier is passed to the cascade rather than refused on shape —
// and the cascade then refuses it by the name of what it actually tried.
const selection = selectProducerDestinationArguments({
template: 'kafka',
methodName: 'send',
args: args('topic', 'payload'),
});
expect(selection.candidates).toHaveLength(1);
const [only] = selection.candidates;
expect(
resolveSpringDestination(only as SpringDestinationCandidate, { constant: () => null }),
).toEqual({ kind: 'unresolved', reason: 'unresolved-constant' });
});
});
describe('isAddressShaped', () => {
it('accepts literals and constant references', () => {
expect(isAddressShaped('"orders"')).toBe(true);
expect(isAddressShaped('ORDERS')).toBe(true);
expect(isAddressShaped('Topics.ORDERS')).toBe(true);
expect(isAddressShaped('com.example.Topics.ORDERS')).toBe(true);
});
it('rejects payload-shaped expressions', () => {
expect(isAddressShaped('new ProducerRecord<>("a", b)')).toBe(false);
expect(isAddressShaped('buildMessage()')).toBe(false);
expect(isAddressShaped('"a" + b')).toBe(false);
});
it('accepts a bare identifier, which a resolver may still refuse', () => {
// The shape gate says "this could be an address", never "this resolves".
expect(isAddressShaped('topic')).toBe(true);
expect(resolveSpringDestination(candidate('topic'), { constant: () => null }).kind).toBe(
'unresolved',
);
});
});