diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..9abac13 --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,94 @@ +import { test, expect, vi, afterEach } from "vitest"; +import { fetchAnalyzerConfig, DEFAULT_CONFIG } from "./config.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +test("returns parsed config from successful response", async () => { + const config = { + minimumCost: 100, + regressionThreshold: 0.5, + ignoredQueryHashes: ["abc123"], + lastSeenQueries: ["hash1"], + }; + vi.spyOn(globalThis, "fetch").mockResolvedValue( + Response.json(config, { status: 200 }), + ); + + const result = await fetchAnalyzerConfig("https://api.example.com", "my/repo"); + expect(result).toEqual(config); +}); + +test("returns defaults when response is not ok", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("Not Found", { status: 404 }), + ); + + const result = await fetchAnalyzerConfig("https://api.example.com", "my/repo"); + expect(result).toEqual(DEFAULT_CONFIG); +}); + +test("returns defaults when fetch throws", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network error")); + + const result = await fetchAnalyzerConfig("https://api.example.com", "my/repo"); + expect(result).toEqual(DEFAULT_CONFIG); +}); + +test("constructs correct URL with trailing slash stripped", async () => { + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValue( + Response.json(DEFAULT_CONFIG, { status: 200 }), + ); + + await fetchAnalyzerConfig("https://api.example.com/", "org/repo"); + expect(mockFetch).toHaveBeenCalledWith( + "https://api.example.com/ci/repos/org%2Frepo/config", + expect.any(Object), + ); +}); + +test("encodes repo name in URL", async () => { + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValue( + Response.json(DEFAULT_CONFIG, { status: 200 }), + ); + + await fetchAnalyzerConfig("https://api.example.com", "org/repo with spaces"); + expect(mockFetch).toHaveBeenCalledWith( + "https://api.example.com/ci/repos/org%2Frepo%20with%20spaces/config", + expect.any(Object), + ); +}); + +test("passes through partial response with missing optional fields", async () => { + const partial = { + minimumCost: 50, + regressionThreshold: 0.1, + ignoredQueryHashes: [], + // lastSeenQueries intentionally omitted + }; + vi.spyOn(globalThis, "fetch").mockResolvedValue( + Response.json(partial, { status: 200 }), + ); + + const result = await fetchAnalyzerConfig("https://api.example.com", "my/repo"); + expect(result.minimumCost).toBe(50); + expect(result.regressionThreshold).toBe(0.1); + expect(result.ignoredQueryHashes).toEqual([]); + expect(result.lastSeenQueries).toBeUndefined(); +}); + +test("preserves lastSeenQueries when present in response", async () => { + const config = { + minimumCost: 0, + regressionThreshold: 0, + ignoredQueryHashes: [], + lastSeenQueries: ["q1", "q2"], + }; + vi.spyOn(globalThis, "fetch").mockResolvedValue( + Response.json(config, { status: 200 }), + ); + + const result = await fetchAnalyzerConfig("https://api.example.com", "my/repo"); + expect(result.lastSeenQueries).toEqual(["q1", "q2"]); +}); diff --git a/src/sanitize.test.ts b/src/sanitize.test.ts new file mode 100644 index 0000000..7b7e5d9 --- /dev/null +++ b/src/sanitize.test.ts @@ -0,0 +1,38 @@ +import { test, expect, vi } from "vitest"; + +test("returns original URL when HOSTED is false", async () => { + vi.stubEnv("HOSTED", "false"); + vi.resetModules(); + const { sanitizePostgresUrl } = await import("./sanitize.ts"); + const url = "postgres://user:pass@host:5432/db"; + expect(sanitizePostgresUrl(url)).toBe(url); +}); + +test("returns hashed URL when HOSTED is true", async () => { + vi.stubEnv("HOSTED", "true"); + vi.resetModules(); + const { sanitizePostgresUrl } = await import("./sanitize.ts"); + const url = "postgres://user:pass@host:5432/db"; + const result = sanitizePostgresUrl(url); + expect(result).toMatch(/^omitted__[a-f0-9]{8}$/); + expect(result).not.toContain("user"); + expect(result).not.toContain("pass"); + expect(result).not.toContain("host"); +}); + +test("same input produces same hash", async () => { + vi.stubEnv("HOSTED", "true"); + vi.resetModules(); + const { sanitizePostgresUrl } = await import("./sanitize.ts"); + const url = "postgres://user:pass@host:5432/db"; + expect(sanitizePostgresUrl(url)).toBe(sanitizePostgresUrl(url)); +}); + +test("different inputs produce different hashes", async () => { + vi.stubEnv("HOSTED", "true"); + vi.resetModules(); + const { sanitizePostgresUrl } = await import("./sanitize.ts"); + const a = sanitizePostgresUrl("postgres://a@host/db1"); + const b = sanitizePostgresUrl("postgres://b@host/db2"); + expect(a).not.toBe(b); +}); diff --git a/src/sql/json.test.ts b/src/sql/json.test.ts new file mode 100644 index 0000000..b7295f7 --- /dev/null +++ b/src/sql/json.test.ts @@ -0,0 +1,75 @@ +import { test, expect } from "vitest"; +import { preprocessEncodedJson } from "./json.ts"; + +test("returns parsed JSON object from clean input", () => { + const input = '{"key": "value"}'; + const result = preprocessEncodedJson(input); + expect(result).toBe('{"key": "value"}'); +}); + +test("skips leading whitespace before opening brace", () => { + const input = ' \t {"key": "value"}'; + const result = preprocessEncodedJson(input); + expect(result).toBe('{"key": "value"}'); +}); + +test("skips leading escaped newlines before opening brace", () => { + const input = '\\n\\n{"key": "value"}'; + const result = preprocessEncodedJson(input); + expect(result).toBe('{"key": "value"}'); +}); + +test("returns undefined for non-JSON input", () => { + expect(preprocessEncodedJson("hello world")).toBeUndefined(); +}); + +test("returns undefined for empty string", () => { + expect(preprocessEncodedJson("")).toBeUndefined(); +}); + +test("round-trips \\n: unescapes then re-escapes newlines", () => { + // \\n (literal backslash-n) → real newline → \\n (control char handler re-escapes) + const input = '{"key":\\n"value"}'; + const result = preprocessEncodedJson(input); + expect(result).toBe('{"key":\\n"value"}'); +}); + +test("strips control characters but preserves escaped \\n, \\r, \\t", () => { + // A real newline (from unescaping) should be preserved as \\n in the output + const input = '{"key": "val\\nue"}'; + const result = preprocessEncodedJson(input); + // \\n becomes real \n, then the control char replacement turns \n back to \\n + expect(result).toBe('{"key": "val\\nue"}'); +}); + +test("strips NUL and other low control characters", () => { + const input = '{"key": "val\x01\x02ue"}'; + const result = preprocessEncodedJson(input); + expect(result).toBe('{"key": "value"}'); +}); + +test("handles mixed leading whitespace and escaped newlines", () => { + const input = ' \\n \\n {"data": 1}'; + const result = preprocessEncodedJson(input); + expect(result).toBe('{"data": 1}'); +}); + +test("preserves \\r as escaped sequence after unescaping", () => { + const input = '{"key": "val\rue"}'; + const result = preprocessEncodedJson(input); + expect(result).toBe('{"key": "val\\rue"}'); +}); + +test("preserves \\t as escaped sequence after unescaping", () => { + const input = '{"key": "val\tue"}'; + const result = preprocessEncodedJson(input); + expect(result).toBe('{"key": "val\\tue"}'); +}); + +test("skips non-whitespace characters before opening brace", () => { + expect(preprocessEncodedJson('abc{"key": 1}')).toBe('{"key": 1}'); +}); + +test("returns undefined for whitespace-only input", () => { + expect(preprocessEncodedJson(" \\n\\n ")).toBeUndefined(); +}); diff --git a/src/sql/recent-query.test.ts b/src/sql/recent-query.test.ts new file mode 100644 index 0000000..96748e3 --- /dev/null +++ b/src/sql/recent-query.test.ts @@ -0,0 +1,195 @@ +import { test, expect } from "vitest"; +import { + RecentQuery, + QueryHash, + type RawRecentQuery, +} from "./recent-query.ts"; +import type { TableReference } from "@query-doctor/core"; + +function makeRawQuery(overrides?: Partial): RawRecentQuery { + return { + username: "test", + query: "SELECT * FROM users", + formattedQuery: "SELECT * FROM users", + meanTime: 1.5, + calls: "10", + rows: "100", + topLevel: true, + ...overrides, + }; +} + +const testHash = QueryHash.parse("test-hash"); + +// --- isSelectQuery --- + +test("isSelectQuery returns true for SELECT statements", () => { + expect(RecentQuery.isSelectQuery(makeRawQuery())).toBe(true); + expect( + RecentQuery.isSelectQuery(makeRawQuery({ query: "select 1" })), + ).toBe(true); +}); + +test("isSelectQuery returns false for non-SELECT statements", () => { + expect( + RecentQuery.isSelectQuery( + makeRawQuery({ query: "INSERT INTO users VALUES (1)" }), + ), + ).toBe(false); + expect( + RecentQuery.isSelectQuery( + makeRawQuery({ query: "UPDATE users SET name = 'x'" }), + ), + ).toBe(false); + expect( + RecentQuery.isSelectQuery(makeRawQuery({ query: "DELETE FROM users" })), + ).toBe(false); +}); + +// --- isSystemQuery --- + +test("isSystemQuery returns true for pg_ tables", () => { + const refs: TableReference[] = [{ table: "pg_class", schema: "pg_catalog" }]; + expect(RecentQuery.isSystemQuery(refs)).toBe(true); +}); + +test("isSystemQuery returns true for timescaledb internal tables", () => { + const refs: TableReference[] = [ + { table: "hypertable", schema: "_timescaledb_catalog" }, + ]; + expect(RecentQuery.isSystemQuery(refs)).toBe(true); +}); + +test("isSystemQuery returns true for timescaledb_information schema", () => { + const refs: TableReference[] = [ + { table: "chunks", schema: "timescaledb_information" }, + ]; + expect(RecentQuery.isSystemQuery(refs)).toBe(true); +}); + +test("isSystemQuery returns false for user tables", () => { + const refs: TableReference[] = [{ table: "users", schema: "public" }]; + expect(RecentQuery.isSystemQuery(refs)).toBe(false); +}); + +test("isSystemQuery returns false for empty array", () => { + expect(RecentQuery.isSystemQuery([])).toBe(false); +}); + +test("isSystemQuery returns true when any ref is a system table (mixed refs)", () => { + const refs: TableReference[] = [ + { table: "users", schema: "public" }, + { table: "pg_stat_activity", schema: "pg_catalog" }, + ]; + expect(RecentQuery.isSystemQuery(refs)).toBe(true); +}); + +// --- isIntrospection --- + +test("isIntrospection returns true when query has @qd_introspection marker", () => { + expect( + RecentQuery.isIntrospection( + makeRawQuery({ query: "SELECT 1 /* @qd_introspection */" }), + ), + ).toBe(true); +}); + +test("isIntrospection returns false for normal queries", () => { + expect(RecentQuery.isIntrospection(makeRawQuery())).toBe(false); +}); + +// --- isTargetlessSelectQuery --- + +test("isTargetlessSelectQuery returns true when no table references", () => { + expect(RecentQuery.isTargetlessSelectQuery([])).toBe(true); +}); + +test("isTargetlessSelectQuery returns false when table references exist", () => { + const refs: TableReference[] = [{ table: "users", schema: "public" }]; + expect(RecentQuery.isTargetlessSelectQuery(refs)).toBe(false); +}); + +// --- constructor --- + +test("constructor sets derived boolean properties correctly for a SELECT on user tables", () => { + const refs: TableReference[] = [{ table: "users", schema: "public" }]; + const rq = new RecentQuery(makeRawQuery(), refs, [], [], [], testHash, 1000); + expect(rq.isSelectQuery).toBe(true); + expect(rq.isSystemQuery).toBe(false); + expect(rq.isIntrospection).toBe(false); + expect(rq.isTargetlessSelectQuery).toBe(false); +}); + +test("constructor sets isTargetlessSelectQuery=true for SELECT with no table refs", () => { + const rq = new RecentQuery(makeRawQuery(), [], [], [], [], testHash, 1000); + expect(rq.isSelectQuery).toBe(true); + expect(rq.isTargetlessSelectQuery).toBe(true); +}); + +test("constructor sets isTargetlessSelectQuery=false for non-SELECT even with empty refs", () => { + const rq = new RecentQuery( + makeRawQuery({ query: "INSERT INTO t VALUES (1)" }), + [], + [], + [], + [], + testHash, + 1000, + ); + expect(rq.isSelectQuery).toBe(false); + expect(rq.isTargetlessSelectQuery).toBe(false); +}); + +test("constructor copies all data fields from RawRecentQuery", () => { + const data = makeRawQuery({ + username: "admin", + query: "SELECT 1", + formattedQuery: "SELECT\n 1", + meanTime: 42.5, + calls: "999", + rows: "0", + topLevel: false, + }); + const rq = new RecentQuery(data, [], [], [], [], testHash, 1000); + expect(rq.username).toBe("admin"); + expect(rq.query).toBe("SELECT 1"); + expect(rq.formattedQuery).toBe("SELECT\n 1"); + expect(rq.meanTime).toBe(42.5); + expect(rq.calls).toBe("999"); + expect(rq.rows).toBe("0"); + expect(rq.topLevel).toBe(false); + expect(rq.hash).toBe(testHash); + expect(rq.seenAt).toBe(1000); +}); + +// --- withOptimization --- + +test("withOptimization attaches optimization to the instance", () => { + const rq = new RecentQuery(makeRawQuery(), [], [], [], [], testHash, 1000); + const optimization = { plan: "mock plan" } as any; + const optimized = rq.withOptimization(optimization); + expect(optimized.optimization).toBe(optimization); + // Should be the same object (mutates in place) + expect(optimized).toBe(rq); +}); + +// --- analyze (integration) --- + +test("analyze produces a RecentQuery with formatted query and analysis", async () => { + const data = makeRawQuery({ query: "SELECT id FROM users WHERE id = $1" }); + const rq = await RecentQuery.analyze(data, testHash, 2000); + expect(rq).toBeInstanceOf(RecentQuery); + expect(rq.hash).toBe(testHash); + expect(rq.seenAt).toBe(2000); + // The formatted query should have uppercase keywords + expect(rq.formattedQuery).toMatch(/SELECT/); + // Table references should include 'users' + expect(rq.tableReferences.some((ref) => ref.table === "users")).toBe(true); +}); + +test("analyze throws on unparseable SQL", async () => { + const data = makeRawQuery({ query: "THIS IS NOT VALID SQL AT ALL !!!" }); + await expect( + RecentQuery.analyze(data, testHash, 3000), + ).rejects.toThrow(); +}); diff --git a/src/sync/dependency-tree.test.ts b/src/sync/dependency-tree.test.ts new file mode 100644 index 0000000..5ed62ab --- /dev/null +++ b/src/sync/dependency-tree.test.ts @@ -0,0 +1,423 @@ +import { test, expect } from "vitest"; +import { + DependencyAnalyzer, + type DatabaseConnector, + type Hash, + type Dependency, +} from "./dependency-tree.ts"; +import { MaxTableIterationsReached } from "./errors.ts"; + +type TestRow = { data: Record; table: string }; + +function makeConnector( + db: Record[]>, + deps: Dependency[], +): DatabaseConnector { + return { + async *cursor(table) { + for (const row of db[table] ?? []) { + yield { data: row, table }; + } + }, + dependencies() { + return Promise.resolve(deps); + }, + get(table, values) { + const found = (db[table] ?? []).find((row) => + Object.entries(values).every( + ([key, value]) => String(row[key]) === String(value), + ), + ); + return Promise.resolve(found ? { data: found, table } : undefined); + }, + hash(v) { + return JSON.stringify(v.data) as Hash; + }, + }; +} + +test("findAllDependencies returns empty when requiredRows is 0", async () => { + const connector = makeConnector( + { "public.users": [{ id: 1 }] }, + [ + { + sourceSchema: "public", + sourceTable: "users", + sourceColumn: null, + referencedSchema: null, + referencedTable: null, + referencedColumn: null, + }, + ], + ); + const da = new DependencyAnalyzer(connector, { + requiredRows: 0, + maxRows: 10, + seed: 0, + }); + const graph = await da.buildGraph( + await connector.dependencies({ excludedSchemas: [] }), + ); + const result = await da.findAllDependencies(graph); + expect(result.items).toEqual({}); + expect(result.notices).toEqual([]); +}); + +test("findAllDependencies produces too_few_rows notice when table has fewer rows than required", async () => { + const connector = makeConnector( + { "public.users": [{ id: 1 }] }, + [ + { + sourceSchema: "public", + sourceTable: "users", + sourceColumn: null, + referencedSchema: null, + referencedTable: null, + referencedColumn: null, + }, + ], + ); + const da = new DependencyAnalyzer(connector, { + requiredRows: 5, + maxRows: 100, + seed: 0, + }); + const graph = await da.buildGraph( + await connector.dependencies({ excludedSchemas: [] }), + ); + const result = await da.findAllDependencies(graph); + expect(result.items["public.users"]).toHaveLength(1); + expect(result.notices).toEqual([ + { + kind: "too_few_rows", + table: "public.users", + requested: 5, + found: 1, + }, + ]); +}); + +test("buildGraph creates entries for tables with no dependencies", async () => { + const connector = makeConnector( + { "public.standalone": [{ id: 1 }] }, + [ + { + sourceSchema: "public", + sourceTable: "standalone", + sourceColumn: null, + referencedSchema: null, + referencedTable: null, + referencedColumn: null, + }, + ], + ); + const da = new DependencyAnalyzer(connector, { + requiredRows: 1, + maxRows: 10, + seed: 0, + }); + const graph = await da.buildGraph( + await connector.dependencies({ excludedSchemas: [] }), + ); + expect(graph.has("public.standalone")).toBe(true); + expect(graph.get("public.standalone")).toEqual([]); +}); + +test("buildGraph links FK dependencies between tables", async () => { + const deps: Dependency[] = [ + { + sourceSchema: "public", + sourceTable: "posts", + sourceColumn: ["author_id"], + referencedSchema: "public", + referencedTable: "users", + referencedColumn: ["id"], + }, + ]; + const connector = makeConnector({}, deps); + const da = new DependencyAnalyzer(connector, { + requiredRows: 1, + maxRows: 10, + seed: 0, + }); + const graph = await da.buildGraph(deps); + const pointers = graph.get("public.posts"); + expect(pointers).toBeDefined(); + expect(pointers).toHaveLength(1); + expect(pointers![0]).toMatchObject({ + sourceColumn: ["author_id"], + referencedColumn: ["id"], + }); +}); + +test("findAllDependencies follows FK chains correctly", async () => { + const connector = makeConnector( + { + "public.orders": [{ id: 1, user_id: 10 }], + "public.users": [{ id: 10 }], + }, + [ + { + sourceSchema: "public", + sourceTable: "orders", + sourceColumn: ["user_id"], + referencedSchema: "public", + referencedTable: "users", + referencedColumn: ["id"], + }, + { + sourceSchema: "public", + sourceTable: "users", + sourceColumn: null, + referencedSchema: null, + referencedTable: null, + referencedColumn: null, + }, + ], + ); + const da = new DependencyAnalyzer(connector, { + requiredRows: 1, + maxRows: 10, + seed: 0, + }); + const graph = await da.buildGraph( + await connector.dependencies({ excludedSchemas: [] }), + ); + const result = await da.findAllDependencies(graph); + // The order's FK to user should pull in the user row + expect(result.items["public.users"]).toContainEqual({ id: 10 }); + expect(result.items["public.orders"]).toContainEqual({ + id: 1, + user_id: 10, + }); +}); + +test("findAllDependencies skips null FK values without error", async () => { + const connector = makeConnector( + { + "public.posts": [{ id: 1, author_id: null }], + "public.users": [{ id: 10 }], + }, + [ + { + sourceSchema: "public", + sourceTable: "posts", + sourceColumn: ["author_id"], + referencedSchema: "public", + referencedTable: "users", + referencedColumn: ["id"], + }, + { + sourceSchema: "public", + sourceTable: "users", + sourceColumn: null, + referencedSchema: null, + referencedTable: null, + referencedColumn: null, + }, + ], + ); + const da = new DependencyAnalyzer(connector, { + requiredRows: 1, + maxRows: 10, + seed: 0, + }); + const graph = await da.buildGraph( + await connector.dependencies({ excludedSchemas: [] }), + ); + const result = await da.findAllDependencies(graph); + expect(result.items["public.posts"]).toContainEqual({ + id: 1, + author_id: null, + }); + // User should still be pulled in via its own cursor, not the null FK + expect(result.items["public.users"]).toHaveLength(1); +}); + +test("findAllDependencies follows multi-level FK chains (A -> B -> C)", async () => { + const connector = makeConnector( + { + "public.comments": [{ id: 1, post_id: 10 }], + "public.posts": [{ id: 10, author_id: 100 }], + "public.users": [{ id: 100 }], + }, + [ + { + sourceSchema: "public", + sourceTable: "comments", + sourceColumn: ["post_id"], + referencedSchema: "public", + referencedTable: "posts", + referencedColumn: ["id"], + }, + { + sourceSchema: "public", + sourceTable: "posts", + sourceColumn: ["author_id"], + referencedSchema: "public", + referencedTable: "users", + referencedColumn: ["id"], + }, + { + sourceSchema: "public", + sourceTable: "users", + sourceColumn: null, + referencedSchema: null, + referencedTable: null, + referencedColumn: null, + }, + ], + ); + const da = new DependencyAnalyzer(connector, { + requiredRows: 1, + maxRows: 100, + seed: 0, + }); + const graph = await da.buildGraph( + await connector.dependencies({ excludedSchemas: [] }), + ); + const result = await da.findAllDependencies(graph); + expect(result.items["public.comments"]).toContainEqual({ id: 1, post_id: 10 }); + expect(result.items["public.posts"]).toContainEqual({ id: 10, author_id: 100 }); + expect(result.items["public.users"]).toContainEqual({ id: 100 }); +}); + +test("findAllDependencies deduplicates rows referenced by multiple FKs", async () => { + // Two posts by the same author — user row should appear only once + const connector = makeConnector( + { + "public.posts": [ + { id: 1, author_id: 10 }, + { id: 2, author_id: 10 }, + ], + "public.users": [{ id: 10 }], + }, + [ + { + sourceSchema: "public", + sourceTable: "posts", + sourceColumn: ["author_id"], + referencedSchema: "public", + referencedTable: "users", + referencedColumn: ["id"], + }, + { + sourceSchema: "public", + sourceTable: "users", + sourceColumn: null, + referencedSchema: null, + referencedTable: null, + referencedColumn: null, + }, + ], + ); + const da = new DependencyAnalyzer(connector, { + requiredRows: 2, + maxRows: 100, + seed: 0, + }); + const graph = await da.buildGraph( + await connector.dependencies({ excludedSchemas: [] }), + ); + const result = await da.findAllDependencies(graph); + expect(result.items["public.posts"]).toHaveLength(2); + // The user row should not be duplicated + expect(result.items["public.users"]).toHaveLength(1); +}); + +test("findAllDependencies produces incomplete_dependency_chain notice when maxRows exceeded", async () => { + // Dependencies ordered so categories is inserted into the graph first, + // making orders appear at a higher index. The backward inner loop processes + // orders first, so its FK chains add categories before categories' own cursor runs. + // After the first order's chain adds category 1, the second order's chain + // finds categories already at maxRows and emits the notice. + const deps: Dependency[] = [ + { + sourceSchema: "public", + sourceTable: "categories", + sourceColumn: null, + referencedSchema: null, + referencedTable: null, + referencedColumn: null, + }, + { + sourceSchema: "public", + sourceTable: "orders", + sourceColumn: ["cat_id"], + referencedSchema: "public", + referencedTable: "categories", + referencedColumn: ["id"], + }, + ]; + const connector = makeConnector( + { + "public.orders": [ + { id: 1, cat_id: 1 }, + { id: 2, cat_id: 2 }, + { id: 3, cat_id: 3 }, + ], + "public.categories": [{ id: 1 }, { id: 2 }, { id: 3 }], + }, + deps, + ); + const da = new DependencyAnalyzer(connector, { + requiredRows: 3, + maxRows: 1, + seed: 0, + }); + const graph = await da.buildGraph(deps); + const result = await da.findAllDependencies(graph); + expect( + result.notices.some((n) => n.kind === "incomplete_dependency_chain"), + ).toBe(true); +}); + +test("traverseDependencyChain throws when table is not in graph", async () => { + const connector = makeConnector( + { "public.users": [{ id: 1 }] }, + [], + ); + const da = new DependencyAnalyzer(connector, { + requiredRows: 1, + maxRows: 10, + seed: 0, + }); + const emptyGraph = new Map(); + await expect( + da.traverseDependencyChain( + emptyGraph, + "public.missing", + { data: { id: 1 }, table: "public.missing" }, + ), + ).rejects.toThrow("Table not declared in dependency graph"); +}); + +test("onStartAnalyze is called when provided", async () => { + let called = false; + const connector = makeConnector( + { "public.t": [{ id: 1 }] }, + [ + { + sourceSchema: "public", + sourceTable: "t", + sourceColumn: null, + referencedSchema: null, + referencedTable: null, + referencedColumn: null, + }, + ], + ); + connector.onStartAnalyze = async () => { + called = true; + }; + const da = new DependencyAnalyzer(connector, { + requiredRows: 1, + maxRows: 10, + seed: 0, + }); + const graph = await da.buildGraph( + await connector.dependencies({ excludedSchemas: [] }), + ); + await da.findAllDependencies(graph); + expect(called).toBe(true); +}); diff --git a/src/sync/errors.test.ts b/src/sync/errors.test.ts new file mode 100644 index 0000000..aecaedf --- /dev/null +++ b/src/sync/errors.test.ts @@ -0,0 +1,62 @@ +import { test, expect } from "vitest"; +import { + PostgresError, + ExtensionNotInstalledError, + MaxTableIterationsReached, +} from "./errors.ts"; + +test("PostgresError serializes to JSON with correct shape", () => { + const error = new PostgresError("connection failed"); + expect(error.toJSON()).toEqual({ + kind: "error", + type: "unexpected_error", + error: "connection failed", + }); +}); + +test("PostgresError.toResponse returns 500 with JSON body", async () => { + const error = new PostgresError("something broke"); + const response = error.toResponse(); + expect(response.status).toBe(500); + expect(await response.json()).toEqual(error.toJSON()); +}); + +test("ExtensionNotInstalledError serializes with extension names", () => { + const error = new ExtensionNotInstalledError(["pg_stat_statements"]); + expect(error.toJSON()).toEqual({ + kind: "error", + type: "extension_not_installed", + extensionName: "none of the following extensions are installed: pg_stat_statements", + }); + expect(error.extensionNames).toEqual(["pg_stat_statements"]); +}); + +test("ExtensionNotInstalledError.toResponse returns 400", async () => { + const error = new ExtensionNotInstalledError(["pg_stat_statements"]); + const response = error.toResponse(); + expect(response.status).toBe(400); + expect(await response.json()).toEqual(error.toJSON()); +}); + +test("MaxTableIterationsReached serializes with bug message", () => { + const error = new MaxTableIterationsReached(100); + expect(error.toJSON()).toEqual({ + kind: "error", + type: "max_table_iterations_reached", + error: "Max table iterations reached. This is a bug with the syncer", + }); + expect(error.maxIterations).toBe(100); +}); + +test("MaxTableIterationsReached.toResponse returns 500", async () => { + const error = new MaxTableIterationsReached(100); + const response = error.toResponse(); + expect(response.status).toBe(500); + expect(await response.json()).toEqual(error.toJSON()); +}); + +test("all error classes are instances of Error", () => { + expect(new PostgresError("x")).toBeInstanceOf(Error); + expect(new ExtensionNotInstalledError(["x"])).toBeInstanceOf(Error); + expect(new MaxTableIterationsReached(1)).toBeInstanceOf(Error); +}); diff --git a/src/sync/schema_differ.test.ts b/src/sync/schema_differ.test.ts new file mode 100644 index 0000000..6c188d8 --- /dev/null +++ b/src/sync/schema_differ.test.ts @@ -0,0 +1,203 @@ +import { test, expect } from "vitest"; +import { SchemaDiffer, type FullSchema } from "./schema_differ.ts"; +import { Connectable } from "./connectable.ts"; + +function makeConnectable(url = "postgres://test:test@localhost:5432/test"): Connectable { + return Connectable.fromString(url); +} + +function makeSchema(overrides?: Partial): FullSchema { + return { + indexes: [], + tables: [], + constraints: [], + functions: [], + extensions: [], + views: [], + types: [], + triggers: [], + ...overrides, + }; +} + +test("put returns undefined on first call (no previous schema to diff)", () => { + const differ = new SchemaDiffer(); + const conn = makeConnectable(); + + const result = differ.put(conn, makeSchema()); + expect(result).toBeUndefined(); +}); + +test("put returns undefined when schema has not changed", () => { + const differ = new SchemaDiffer(); + const conn = makeConnectable(); + const schema = makeSchema(); + + differ.put(conn, schema); + const result = differ.put(conn, schema); + expect(result).toBeUndefined(); +}); + +test("put returns add op when a table is added", () => { + const differ = new SchemaDiffer(); + const conn = makeConnectable(); + + differ.put(conn, makeSchema()); + + const updated = makeSchema({ + tables: [ + { + type: "table", + oid: 1, + schemaName: "public" as any, + tableName: "users" as any, + columns: [], + }, + ], + }); + + const result = differ.put(conn, updated); + expect(result).toBeDefined(); + expect(result!).toContainEqual( + expect.objectContaining({ op: "add", path: "/tables/0" }), + ); +}); + +test("put returns remove op when a table is removed", () => { + const differ = new SchemaDiffer(); + const conn = makeConnectable(); + + const withTable = makeSchema({ + tables: [ + { + type: "table", + oid: 1, + schemaName: "public" as any, + tableName: "users" as any, + columns: [], + }, + ], + }); + + differ.put(conn, withTable); + const result = differ.put(conn, makeSchema()); + + expect(result).toBeDefined(); + expect(result!).toContainEqual( + expect.objectContaining({ op: "remove", path: "/tables/0" }), + ); +}); + +test("put returns replace op when a table property changes", () => { + const differ = new SchemaDiffer(); + const conn = makeConnectable(); + + const original = makeSchema({ + tables: [ + { + type: "table", + oid: 1, + schemaName: "public" as any, + tableName: "users" as any, + columns: [], + }, + ], + }); + + differ.put(conn, original); + + const modified = makeSchema({ + tables: [ + { + type: "table", + oid: 1, + schemaName: "public" as any, + tableName: "users" as any, + tablespace: "fast_ssd", + columns: [], + }, + ], + }); + + const result = differ.put(conn, modified); + expect(result).toBeDefined(); + expect(result!.some((op) => op.op === "add" || op.op === "replace")).toBe(true); +}); + +test("put tracks schemas per connectable independently", () => { + const differ = new SchemaDiffer(); + const conn1 = makeConnectable(); + const conn2 = makeConnectable("postgres://test:test@otherhost:5432/other"); + + differ.put(conn1, makeSchema()); + differ.put(conn2, makeSchema()); + + const updated = makeSchema({ + extensions: [ + { extensionName: "pg_trgm", version: "1.0", schemaName: "public" as any }, + ], + }); + + const result1 = differ.put(conn1, updated); + const result2 = differ.put(conn2, makeSchema()); + + expect(result1).toBeDefined(); + expect(result2).toBeUndefined(); +}); + +test("put detects index additions with correct path", () => { + const differ = new SchemaDiffer(); + const conn = makeConnectable(); + + differ.put(conn, makeSchema()); + + const withIndex = makeSchema({ + indexes: [ + { + type: "index", + oid: 42, + schemaName: "public" as any, + tableName: "users" as any, + indexName: "users_pkey" as any, + indexType: "btree", + isUnique: true, + isPrimary: true, + isClustered: false, + keyColumns: [{ type: "indexColumn", name: "id" as any }], + }, + ], + }); + + const result = differ.put(conn, withIndex); + expect(result).toBeDefined(); + expect(result!).toContainEqual( + expect.objectContaining({ op: "add", path: "/indexes/0" }), + ); +}); + +test("put detects constraint changes via oid-based identity", () => { + const differ = new SchemaDiffer(); + const conn = makeConnectable(); + + differ.put(conn, makeSchema()); + + const withConstraint = makeSchema({ + constraints: [ + { + type: "constraint", + oid: 99, + schemaName: "public" as any, + tableName: "users" as any, + constraintName: "users_pkey" as any, + constraintType: "primary_key", + definition: "PRIMARY KEY (id)", + }, + ], + }); + + const result = differ.put(conn, withConstraint); + expect(result).toBeDefined(); + expect(result!).toContainEqual( + expect.objectContaining({ op: "add", path: "/constraints/0" }), + ); +});