mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
239 lines
12 KiB
TypeScript
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,
|
|
},
|
|
);
|