GitNexus/gitnexus/test/integration/spring-destinations-lbug.test.ts

239 lines
12 KiB
TypeScript

import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import { executeQuery } from '../../src/core/lbug/lbug-adapter.js';
import { PARSE_CACHE_VERSION, type ParseCache } from '../../src/storage/parse-cache.js';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'spring-destination-app');
/**
* The database half of the destination keying rule, which no in-process test
* can see.
*
* Two things only show up here:
*
* 1. The DISK-BACKED ParsedFile path. A `parseCache` with a `storagePath` — so,
* every real run of the CLI — flushes worker ParsedFiles to disk and hands
* the parse phase back an EMPTY `parsedFiles`. A phase that iterated it
* found nothing in production while every direct-pipeline test passed. This
* suite runs the pipeline the way the CLI does, so that asymmetry cannot
* come back silently.
*
* 2. NULL versus the empty string. The in-memory rule is that an unresolved
* destination has no `address` PROPERTY, but the CSV column has to hold
* something, and an empty string is a value that another empty string
* matches. If those loaded as `''` rather than NULL, two services that each
* merely wrote a placeholder would join on it after the round trip — the
* exact false connection the id rule prevents, reintroduced one layer down.
*/
withTestLbugDB(
'spring-destinations',
() => {
describe('Destination round trip through LadybugDB', () => {
it('persists every destination the pipeline resolved', async () => {
const rows = await executeQuery(
'MATCH (d:Destination) RETURN d.address AS address, d.broker AS broker, d.resolution AS resolution ORDER BY d.id',
);
expect(rows.length).toBe(25);
const resolved = rows
.filter((row) => row.address !== null && row.address !== undefined)
.map((row) => row.address as string)
.sort();
// `audit.v1` is deliberately NOT here: it is a `${key:default}`, and a
// default the configuration can override is not an identity.
//
// `orders.queue` and `orders.v1` appear TWICE each, and that is the
// point rather than a duplicate. Each is named by two brokers — a
// Rabbit listener and a JMS publish, a Kafka pair and an unrelated
// Rabbit listener — and identity is `(broker, address)`, so each
// spelling is two rows that both keep the join key. Address alone is
// therefore not unique in this table; `(address, broker)` is.
expect(resolved).toEqual([
'kotlin.arrayof.v1',
'kotlin.constant.v1',
'orders-out-0',
'orders.created',
'orders.jms',
'orders.queue',
'orders.queue',
'orders.v1',
'orders.v1',
'returns.v1',
'shipments.v1',
]);
});
it('stores an unresolved address as NULL, not as the empty string', async () => {
const [row] = await executeQuery(
"MATCH (d:Destination) WHERE d.address IS NULL RETURN count(d) AS nulls, count(CASE WHEN d.resolution = 'unresolved-config-key' THEN 1 END) AS placeholders",
);
expect(row?.nulls).toBe(14);
expect(row?.placeholders).toBe(5);
});
it('stores the refusal breakdown, which is what this feature is measured on', async () => {
const rows = await executeQuery(
'MATCH (d:Destination) WHERE d.address IS NULL RETURN d.resolution AS reason, count(d) AS n ORDER BY reason',
);
// Every value in this column comes from the resolver's own closed
// refusal set. The phase contributes none of its own: it withdraws no
// address, so it has no verdict to record here.
expect(Object.fromEntries(rows.map((row) => [row.reason, Number(row.n)]))).toEqual({
'overridable-config-default': 4,
'spel-expression': 2,
'unescaped-interpolation': 3,
'unresolved-config-key': 5,
});
});
it('keeps a `${key:default}` distinguishable from a bare `${key}` after the round trip', async () => {
const rows = await executeQuery(
"MATCH (d:Destination) WHERE d.configDefault = 'events' RETURN d.configKey AS key ORDER BY key",
);
// Two DIFFERENT keys that share a fallback. Substituting the default
// merged them into one `Destination:events` node.
expect(rows.map((row) => row.key)).toEqual([
'app.messaging.archive-topic',
'app.messaging.report-topic',
]);
});
it('gives a resolved destination NULL lines, not line -1', async () => {
const [row] = await executeQuery(
'MATCH (d:Destination) WHERE d.address IS NOT NULL RETURN count(d) AS total, count(d.startLine) AS withLine',
);
// A resolved destination has no location at all — the same fact
// `filePath` records — so the two columns must agree.
expect(Number(row?.total)).toBe(11);
expect(Number(row?.withLine)).toBe(0);
});
it('produces no false join between two unresolved destinations', async () => {
// The assertion the whole feature rests on, stated in the language a
// cross-repository pass would actually use. `a.address = b.address` is
// never true when both are NULL, so an unresolved destination cannot
// match any other row — which is the property being asserted.
const [row] = await executeQuery(
'MATCH (a:Destination), (b:Destination) WHERE a.address = b.address AND a.broker = b.broker AND a.id <> b.id RETURN count(*) AS falseJoins',
);
expect(row?.falseJoins).toBe(0);
// Stated the other way round, because the query above would also pass
// if the table were empty: every row that shares an ADDRESS with
// another differs from it by BROKER, and there are exactly the four
// such rows this fixture writes on purpose (`orders.queue` and
// `orders.v1`, two brokers each). Both halves are needed — the first
// says the identity is not overloaded, the second says the identity is
// still the thing that separates a real pair from a coincidence.
const shared = await executeQuery(
'MATCH (a:Destination), (b:Destination) WHERE a.address = b.address AND a.id <> b.id RETURN a.address AS address, a.broker AS mine, b.broker AS theirs ORDER BY address, mine',
);
expect(shared).toHaveLength(4);
for (const pair of shared) expect(pair.mine).not.toBe(pair.theirs);
expect(shared.map((pair) => pair.address)).toEqual([
'orders.queue',
'orders.queue',
'orders.v1',
'orders.v1',
]);
});
it('joins a publisher and a subscriber on the resolved address', async () => {
const rows = await executeQuery(
"MATCH (m)-[r:CodeRelation]->(d:Destination) WHERE d.address = 'orders.v1' AND d.broker = 'kafka' RETURN r.type AS type, m.name AS name ORDER BY name",
);
const types = new Set(rows.map((row) => row.type as string));
expect(types).toEqual(new Set(['CONSUMES_FROM', 'PUBLISHES_TO']));
const names = rows.map((row) => row.name as string);
expect(names).toContain('publishLiteral');
expect(names).toContain('consumeLiteral');
});
it('KEEPS that pair joined when a stranger names the same word on another broker', async () => {
// THE regression, after the round trip. `UnrelatedRabbitConsumer`
// listens on a Rabbit queue it happened to call `orders.v1`. While the
// address alone keyed the node, that one file withdrew the address from
// all three sites, re-keyed every one of them by source location, and
// the Kafka pair above stopped being connected — a report asking "who
// reads what this service publishes" answered nothing.
const rows = await executeQuery(
"MATCH (d:Destination) WHERE d.address = 'orders.v1' RETURN d.broker AS broker, d.id AS id, d.filePath AS filePath ORDER BY broker",
);
expect(rows.map((row) => row.broker)).toEqual(['kafka', 'rabbit']);
expect(new Set(rows.map((row) => row.id)).size).toBe(2);
// Both connect, so neither is stamped with a file. The stranger's node
// is as much a first-class destination as the pair's.
for (const row of rows) expect(row.filePath).toBeNull();
// The pair's node carries both directions from two different files; the
// stranger's carries one subscription and no publish, so the two-hop
// walk between the JMS/Kafka publisher and this listener finds nothing.
const stranger = await executeQuery(
"MATCH (m)-[r:CodeRelation]->(d:Destination) WHERE d.address = 'orders.v1' AND d.broker = 'rabbit' RETURN r.type AS type, m.filePath AS filePath",
);
expect(stranger.map((row) => row.type)).toEqual(['CONSUMES_FROM']);
expect(String(stranger[0]?.filePath)).toContain('UnrelatedRabbitConsumer.java');
});
it('keeps two brokers on one address as two rows that BOTH keep the address', async () => {
// `@RabbitListener(queues = "orders.queue")` and
// `jmsTemplate.convertAndSend("orders.queue", payload)` name one string
// over two brokers. They are two rows because the broker is in the id,
// not because the address was taken away from either of them — the
// earlier rule withdrew it from both and this query returned two NULLs.
const rows = await executeQuery(
"MATCH (d:Destination) WHERE d.name = 'orders.queue' RETURN d.address AS address, d.broker AS broker, d.resolution AS resolution ORDER BY d.broker",
);
expect(rows).toEqual([
{ address: 'orders.queue', broker: 'jms', resolution: 'literal' },
{ address: 'orders.queue', broker: 'rabbit', resolution: 'literal' },
]);
// Both sides still have their own edge — the publish and the
// subscription are real facts — and they land on two different nodes.
const edges = await executeQuery(
"MATCH (m)-[r:CodeRelation]->(d:Destination) WHERE d.address = 'orders.queue' RETURN r.type AS type, d.id AS destination ORDER BY type",
);
expect(edges.map((row) => row.type)).toEqual(['CONSUMES_FROM', 'PUBLISHES_TO']);
expect(new Set(edges.map((row) => row.destination)).size).toBe(2);
});
it('links an unresolved placeholder to its configuration keys', async () => {
const rows = await executeQuery(
"MATCH (d:Destination)-[r:CodeRelation]->(p:Property) WHERE r.type = 'USES' RETURN p.name AS key",
);
expect(rows.length).toBeGreaterThan(0);
// A `${key:default}` links to its key's Property nodes too — a default
// changes nothing about where the real value comes from — so both keys
// that are actually declared in the fixture's YAML appear. A key
// declared nowhere links to nothing, which is normal: it may come from
// an environment variable or a config server.
expect(new Set(rows.map((row) => row.key))).toEqual(
new Set(['app.messaging.shared-topic', 'app.messaging.audit-topic']),
);
});
});
},
{
beforeFTS: async (dbPath) => {
const storageDir = path.dirname(dbPath);
// A cold cache WITH a storagePath: this is what makes the parse phase use
// the disk-backed ParsedFile store, which is the production shape.
const cache: ParseCache = {
version: PARSE_CACHE_VERSION,
entries: new Map(),
usedKeys: new Set(),
storagePath: path.join(storageDir, 'parse-cache'),
onDiskKeys: new Set(),
};
const result = await runPipelineFromRepo(FIXTURE, () => {}, {
parseCache: cache,
workerPoolSize: 1,
});
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.loadGraphToLbug(result.graph, FIXTURE, storageDir);
},
timeout: 180_000,
},
);