From 7148d5a734576394ad99c85353373a45c31df1e2 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Sirois Date: Mon, 16 Mar 2026 19:52:37 +0400 Subject: [PATCH 01/13] test: add tests for preprocessEncodedJson Co-Authored-By: Claude Opus 4.6 --- src/sql/json.test.ts | 55 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/sql/json.test.ts diff --git a/src/sql/json.test.ts b/src/sql/json.test.ts new file mode 100644 index 0000000..c98915c --- /dev/null +++ b/src/sql/json.test.ts @@ -0,0 +1,55 @@ +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}'); +}); From 03da3281e6affedd086512affd3c5a0a29cb5149 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Sirois Date: Mon, 16 Mar 2026 19:52:58 +0400 Subject: [PATCH 02/13] test: add tests for error classes serialization Co-Authored-By: Claude Opus 4.6 --- src/sync/errors.test.ts | 62 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/sync/errors.test.ts diff --git a/src/sync/errors.test.ts b/src/sync/errors.test.ts new file mode 100644 index 0000000..f4bbfc2 --- /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 name", () => { + const error = new ExtensionNotInstalledError("pg_stat_statements"); + expect(error.toJSON()).toEqual({ + kind: "error", + type: "extension_not_installed", + extensionName: "extension pg_stat_statements is not installed", + }); + expect(error.extension).toBe("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); +}); From 6a4341139ad67ca381f7f46c25a703cd7f19f50e Mon Sep 17 00:00:00 2001 From: Jean-Philippe Sirois Date: Mon, 16 Mar 2026 19:53:24 +0400 Subject: [PATCH 03/13] test: add tests for SchemaDiffer diff tracking Co-Authored-By: Claude Opus 4.6 --- src/sync/schema_differ.test.ts | 114 +++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 src/sync/schema_differ.test.ts diff --git a/src/sync/schema_differ.test.ts b/src/sync/schema_differ.test.ts new file mode 100644 index 0000000..4e33de8 --- /dev/null +++ b/src/sync/schema_differ.test.ts @@ -0,0 +1,114 @@ +import { test, expect } from "vitest"; +import { SchemaDiffer, type FullSchema } from "./schema_differ.ts"; +import { Connectable } from "./connectable.ts"; + +function makeConnectable(): Connectable { + return new Connectable("postgres://test:test@localhost:5432/test"); +} + +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 schema = makeSchema(); + + const result = differ.put(conn, schema); + 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 JSON patch ops when schema changes", () => { + 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(Array.isArray(result)).toBe(true); + expect(result!.length).toBeGreaterThan(0); +}); + +test("put tracks schemas per connectable independently", () => { + const differ = new SchemaDiffer(); + const conn1 = makeConnectable(); + const conn2 = new Connectable("postgres://test:test@localhost:5433/other"); + + differ.put(conn1, makeSchema()); + differ.put(conn2, makeSchema()); + + // Change only conn1's schema + 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 via oid-based identity", () => { + 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!.some((op) => op.path.startsWith("/indexes"))).toBe(true); +}); From 1d369cecca5766b75d3b042e8fc36b2ccfc1a78d Mon Sep 17 00:00:00 2001 From: Jean-Philippe Sirois Date: Mon, 16 Mar 2026 19:54:00 +0400 Subject: [PATCH 04/13] test: add tests for sanitizePostgresUrl Co-Authored-By: Claude Opus 4.6 --- src/sanitize.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/sanitize.test.ts 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); +}); From f29c447e487fad3072262d2e971c184a4242a2bd Mon Sep 17 00:00:00 2001 From: Jean-Philippe Sirois Date: Mon, 16 Mar 2026 19:54:24 +0400 Subject: [PATCH 05/13] test: add tests for RecentQuery static helper methods Co-Authored-By: Claude Opus 4.6 --- src/sql/recent-query.test.ts | 77 ++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 src/sql/recent-query.test.ts diff --git a/src/sql/recent-query.test.ts b/src/sql/recent-query.test.ts new file mode 100644 index 0000000..e13241e --- /dev/null +++ b/src/sql/recent-query.test.ts @@ -0,0 +1,77 @@ +import { test, expect } from "vitest"; +import { RecentQuery, 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, + }; +} + +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); +}); + +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("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); +}); + +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); +}); From 88c6c530dcd4f588c34dd9dcbf6c8024d1ec8a7b Mon Sep 17 00:00:00 2001 From: Jean-Philippe Sirois Date: Mon, 16 Mar 2026 19:54:48 +0400 Subject: [PATCH 06/13] test: add tests for fetchAnalyzerConfig Co-Authored-By: Claude Opus 4.6 --- src/config.test.ts | 61 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/config.test.ts diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..15cb6fe --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,61 @@ +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), + ); +}); From fd64fe06027c5ed5f3ad9e5d45cc543d45f277ad Mon Sep 17 00:00:00 2001 From: Jean-Philippe Sirois Date: Mon, 16 Mar 2026 19:55:30 +0400 Subject: [PATCH 07/13] test: add tests for DependencyAnalyzer edge cases Co-Authored-By: Claude Opus 4.6 --- src/sync/dependency-tree.test.ts | 234 +++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 src/sync/dependency-tree.test.ts diff --git a/src/sync/dependency-tree.test.ts b/src/sync/dependency-tree.test.ts new file mode 100644 index 0000000..2470ad2 --- /dev/null +++ b/src/sync/dependency-tree.test.ts @@ -0,0 +1,234 @@ +import { test, expect } from "vitest"; +import { + DependencyAnalyzer, + type DatabaseConnector, + type Hash, + type Dependency, +} from "./dependency-tree.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); +}); From bcf5d86a05b1be5ffc0128495fc131f785e550c1 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Sirois Date: Mon, 16 Mar 2026 20:19:56 +0400 Subject: [PATCH 08/13] test: add coverage for \r, \t preservation and edge cases in json Co-Authored-By: Claude Opus 4.6 --- src/sql/json.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/sql/json.test.ts b/src/sql/json.test.ts index c98915c..b7295f7 100644 --- a/src/sql/json.test.ts +++ b/src/sql/json.test.ts @@ -53,3 +53,23 @@ test("handles mixed leading whitespace and escaped newlines", () => { 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(); +}); From 85d60a5bf42f862ffccf659a3c661b4ff557d3db Mon Sep 17 00:00:00 2001 From: Jean-Philippe Sirois Date: Mon, 16 Mar 2026 20:20:40 +0400 Subject: [PATCH 09/13] test: strengthen SchemaDiffer assertions and fix Connectable usage Use Connectable.fromString() instead of private constructor. Assert specific op types (add/remove/replace) instead of just checking that diffs exist. Add coverage for removals, modifications, and constraint changes. Co-Authored-By: Claude Opus 4.6 --- src/sync/schema_differ.test.ts | 111 +++++++++++++++++++++++++++++---- 1 file changed, 100 insertions(+), 11 deletions(-) diff --git a/src/sync/schema_differ.test.ts b/src/sync/schema_differ.test.ts index 4e33de8..6c188d8 100644 --- a/src/sync/schema_differ.test.ts +++ b/src/sync/schema_differ.test.ts @@ -2,8 +2,8 @@ import { test, expect } from "vitest"; import { SchemaDiffer, type FullSchema } from "./schema_differ.ts"; import { Connectable } from "./connectable.ts"; -function makeConnectable(): Connectable { - return new Connectable("postgres://test:test@localhost:5432/test"); +function makeConnectable(url = "postgres://test:test@localhost:5432/test"): Connectable { + return Connectable.fromString(url); } function makeSchema(overrides?: Partial): FullSchema { @@ -23,9 +23,8 @@ function makeSchema(overrides?: Partial): FullSchema { test("put returns undefined on first call (no previous schema to diff)", () => { const differ = new SchemaDiffer(); const conn = makeConnectable(); - const schema = makeSchema(); - const result = differ.put(conn, schema); + const result = differ.put(conn, makeSchema()); expect(result).toBeUndefined(); }); @@ -39,7 +38,7 @@ test("put returns undefined when schema has not changed", () => { expect(result).toBeUndefined(); }); -test("put returns JSON patch ops when schema changes", () => { +test("put returns add op when a table is added", () => { const differ = new SchemaDiffer(); const conn = makeConnectable(); @@ -59,19 +58,80 @@ test("put returns JSON patch ops when schema changes", () => { const result = differ.put(conn, updated); expect(result).toBeDefined(); - expect(Array.isArray(result)).toBe(true); - expect(result!.length).toBeGreaterThan(0); + 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 = new Connectable("postgres://test:test@localhost:5433/other"); + const conn2 = makeConnectable("postgres://test:test@otherhost:5432/other"); differ.put(conn1, makeSchema()); differ.put(conn2, makeSchema()); - // Change only conn1's schema const updated = makeSchema({ extensions: [ { extensionName: "pg_trgm", version: "1.0", schemaName: "public" as any }, @@ -85,7 +145,7 @@ test("put tracks schemas per connectable independently", () => { expect(result2).toBeUndefined(); }); -test("put detects index additions via oid-based identity", () => { +test("put detects index additions with correct path", () => { const differ = new SchemaDiffer(); const conn = makeConnectable(); @@ -110,5 +170,34 @@ test("put detects index additions via oid-based identity", () => { const result = differ.put(conn, withIndex); expect(result).toBeDefined(); - expect(result!.some((op) => op.path.startsWith("/indexes"))).toBe(true); + 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" }), + ); }); From 49f88018ed0d7bcb99bb0a7fed6d6ad2badc711a Mon Sep 17 00:00:00 2001 From: Jean-Philippe Sirois Date: Mon, 16 Mar 2026 20:23:55 +0400 Subject: [PATCH 10/13] test: add DependencyAnalyzer coverage for multi-level chains, dedup, maxRows, and error paths Co-Authored-By: Claude Opus 4.6 --- src/sync/dependency-tree.test.ts | 189 +++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) diff --git a/src/sync/dependency-tree.test.ts b/src/sync/dependency-tree.test.ts index 2470ad2..5ed62ab 100644 --- a/src/sync/dependency-tree.test.ts +++ b/src/sync/dependency-tree.test.ts @@ -5,6 +5,7 @@ import { type Hash, type Dependency, } from "./dependency-tree.ts"; +import { MaxTableIterationsReached } from "./errors.ts"; type TestRow = { data: Record; table: string }; @@ -232,3 +233,191 @@ test("findAllDependencies skips null FK values without error", async () => { // 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); +}); From 36c4e119f6874326d36a24917f0f90692df035f0 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Sirois Date: Mon, 16 Mar 2026 20:25:29 +0400 Subject: [PATCH 11/13] test: add RecentQuery constructor, withOptimization, and analyze coverage Cover constructor derived booleans (isTargetlessSelectQuery only true for SELECTs), data field copying, withOptimization mutation, analyze integration, and edge cases for static helpers. Co-Authored-By: Claude Opus 4.6 --- src/sql/recent-query.test.ts | 124 ++++++++++++++++++++++++++++++++++- 1 file changed, 121 insertions(+), 3 deletions(-) diff --git a/src/sql/recent-query.test.ts b/src/sql/recent-query.test.ts index e13241e..96748e3 100644 --- a/src/sql/recent-query.test.ts +++ b/src/sql/recent-query.test.ts @@ -1,5 +1,9 @@ import { test, expect } from "vitest"; -import { RecentQuery, type RawRecentQuery } from "./recent-query.ts"; +import { + RecentQuery, + QueryHash, + type RawRecentQuery, +} from "./recent-query.ts"; import type { TableReference } from "@query-doctor/core"; function makeRawQuery(overrides?: Partial): RawRecentQuery { @@ -15,6 +19,10 @@ function makeRawQuery(overrides?: Partial): RawRecentQuery { }; } +const testHash = QueryHash.parse("test-hash"); + +// --- isSelectQuery --- + test("isSelectQuery returns true for SELECT statements", () => { expect(RecentQuery.isSelectQuery(makeRawQuery())).toBe(true); expect( @@ -24,13 +32,22 @@ test("isSelectQuery returns true for SELECT statements", () => { test("isSelectQuery returns false for non-SELECT statements", () => { expect( - RecentQuery.isSelectQuery(makeRawQuery({ query: "INSERT INTO users VALUES (1)" })), + RecentQuery.isSelectQuery( + makeRawQuery({ query: "INSERT INTO users VALUES (1)" }), + ), ).toBe(false); expect( - RecentQuery.isSelectQuery(makeRawQuery({ query: "UPDATE users SET name = 'x'" })), + 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); @@ -55,6 +72,20 @@ test("isSystemQuery returns false for user tables", () => { 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( @@ -67,6 +98,8 @@ 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); }); @@ -75,3 +108,88 @@ 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(); +}); From 57e04e58af6a0142d21ddc225c8f2b1c57fcab0b Mon Sep 17 00:00:00 2001 From: Jean-Philippe Sirois Date: Mon, 16 Mar 2026 20:26:11 +0400 Subject: [PATCH 12/13] test: add config tests for partial response and lastSeenQueries handling Co-Authored-By: Claude Opus 4.6 --- src/config.test.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/config.test.ts b/src/config.test.ts index 15cb6fe..9abac13 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -59,3 +59,36 @@ test("encodes repo name in URL", async () => { 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"]); +}); From 68f5c2e329bca106190257c2164dd926a30923c8 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Sirois Date: Tue, 17 Mar 2026 15:50:37 +0100 Subject: [PATCH 13/13] fix: update errors tests for ExtensionNotInstalledError string[] API Co-Authored-By: Claude Opus 4.6 --- src/sync/errors.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/sync/errors.test.ts b/src/sync/errors.test.ts index f4bbfc2..aecaedf 100644 --- a/src/sync/errors.test.ts +++ b/src/sync/errors.test.ts @@ -21,18 +21,18 @@ test("PostgresError.toResponse returns 500 with JSON body", async () => { expect(await response.json()).toEqual(error.toJSON()); }); -test("ExtensionNotInstalledError serializes with extension name", () => { - const error = new ExtensionNotInstalledError("pg_stat_statements"); +test("ExtensionNotInstalledError serializes with extension names", () => { + const error = new ExtensionNotInstalledError(["pg_stat_statements"]); expect(error.toJSON()).toEqual({ kind: "error", type: "extension_not_installed", - extensionName: "extension pg_stat_statements is not installed", + extensionName: "none of the following extensions are installed: pg_stat_statements", }); - expect(error.extension).toBe("pg_stat_statements"); + expect(error.extensionNames).toEqual(["pg_stat_statements"]); }); test("ExtensionNotInstalledError.toResponse returns 400", async () => { - const error = new ExtensionNotInstalledError("pg_stat_statements"); + const error = new ExtensionNotInstalledError(["pg_stat_statements"]); const response = error.toResponse(); expect(response.status).toBe(400); expect(await response.json()).toEqual(error.toJSON()); @@ -57,6 +57,6 @@ test("MaxTableIterationsReached.toResponse returns 500", async () => { test("all error classes are instances of Error", () => { expect(new PostgresError("x")).toBeInstanceOf(Error); - expect(new ExtensionNotInstalledError("x")).toBeInstanceOf(Error); + expect(new ExtensionNotInstalledError(["x"])).toBeInstanceOf(Error); expect(new MaxTableIterationsReached(1)).toBeInstanceOf(Error); });