/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { buildSchema } from "graphql"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { makeNoopPrimeDeps } from "../../__tests__/helpers/prime-deps.js"; import { downloadSchemaSdl } from "../download-schema.js"; import { schemaCacheKeyForInstanceUrl, schemaDir } from "../introspect.js"; import { type PrimeDeps } from "../prime-schema.js"; import { clearSchemaCache } from "../walker.js"; const ORG = "download-sdl-org"; const ORG_URL = "https://download-sdl-org.my.salesforce.com"; // A schema with a recognizable field so we can assert the emitted SDL is the // one we primed, not an empty stub. const SCHEMA = buildSchema(`type Query { hello: String }`); describe("lib/download-schema", () => { let tmpRoot: string; beforeEach(() => { tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "graphiti-download-sdl-")); process.env.GRAPHITI_HOME = tmpRoot; }); afterEach(() => { delete process.env.GRAPHITI_HOME; // getSchema populates a module-level parsed-schema cache keyed by instance // URL; clear it so a shared ORG_URL can't leak a schema between tests. clearSchemaCache(); fs.rmSync(tmpRoot, { recursive: true, force: true }); }); function cacheFile(): string { return path.join(schemaDir(), `${schemaCacheKeyForInstanceUrl(ORG_URL)}.json`); } /** Wrap real deps with a download counter, mirroring connect.spec.ts. */ function counting(base: PrimeDeps): { deps: PrimeDeps; calls: () => number } { let calls = 0; return { deps: { getOrgAuth: base.getOrgAuth, downloadSchema: async (a) => { calls++; return base.downloadSchema(a); }, }, calls: () => calls, }; } it("primes a fresh (uncached) org, writes SDL to outPath, and reports downloaded", async () => { const { deps, calls } = counting(makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA)); const outPath = path.join(tmpRoot, "schema.graphql"); const result = await downloadSchemaSdl({ org: ORG, outPath, deps }); expect(calls()).toBe(1); // fresh org → one introspection expect(result.downloaded).toBe(true); expect(result.refreshed).toBe(false); expect(result.refreshedDueToAge).toBe(false); expect(result.instanceUrl).toBe(ORG_URL); expect(fs.existsSync(cacheFile())).toBe(true); // shared cache populated expect(result.outPath).toBe(path.resolve(outPath)); expect(fs.readFileSync(outPath, "utf-8")).toBe(result.sdl); expect(result.sdl).toMatch(/type Query/); expect(result.sdl).toMatch(/hello: String/); }); it("second call is a cache hit: no re-download, still returns and writes the SDL (wait once)", async () => { const base = makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA); await downloadSchemaSdl({ org: ORG, deps: base }); // prime once const { deps, calls } = counting(base); const outPath = path.join(tmpRoot, "schema.graphql"); const result = await downloadSchemaSdl({ org: ORG, outPath, deps }); expect(calls()).toBe(0); // cache hit — no second introspection expect(result.downloaded).toBe(false); expect(result.refreshed).toBe(false); expect(fs.readFileSync(outPath, "utf-8")).toBe(result.sdl); expect(result.sdl).toMatch(/type Query/); }); it("omitting outPath returns the SDL without writing an export file", async () => { const base = makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA); const exportPath = path.join(tmpRoot, "schema.graphql"); const result = await downloadSchemaSdl({ org: ORG, deps: base }); expect(result.outPath).toBeUndefined(); expect(result.sdl).toMatch(/type Query/); // With no outPath, downloadSchemaSdl writes no export. (getSchema still // maintains its own `.graphql` cache side-file under schemaDir, // but never the caller-named export path.) expect(fs.existsSync(exportPath)).toBe(false); }); it("maxAgeMs forces a refresh when the cached schema is older than the gate", async () => { const base = makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA); await downloadSchemaSdl({ org: ORG, deps: base }); // prime once (fresh) // Backdate the shared cache so it reads as ~20 min old. getSchemaMetadata // derives `downloadedAt` from the file mtime, so this is what the gate sees. const past = new Date(Date.now() - 20 * 60_000); fs.utimesSync(cacheFile(), past, past); const { deps, calls } = counting(base); const outPath = path.join(tmpRoot, "schema.graphql"); const result = await downloadSchemaSdl({ org: ORG, outPath, maxAgeMs: 10 * 60_000, deps, }); expect(calls()).toBe(1); // stale → forced re-download expect(result.downloaded).toBe(true); expect(result.refreshed).toBe(true); expect(result.refreshedDueToAge).toBe(true); expect(fs.readFileSync(outPath, "utf-8")).toBe(result.sdl); }); it("maxAgeMs leaves a fresh cache untouched — wait once is preserved", async () => { const base = makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA); await downloadSchemaSdl({ org: ORG, deps: base }); // prime once (just now) const { deps, calls } = counting(base); const result = await downloadSchemaSdl({ org: ORG, maxAgeMs: 10 * 60_000, deps }); expect(calls()).toBe(0); // young cache → no refresh expect(result.downloaded).toBe(false); expect(result.refreshed).toBe(false); expect(result.refreshedDueToAge).toBe(false); }); it("maxAgeMs on an uncached org lazily primes (no false 'stale') ", async () => { const { deps, calls } = counting(makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA)); // No prior cache: the gate finds no metadata and must not force a refresh; // the normal lazy prime downloads exactly once. const result = await downloadSchemaSdl({ org: ORG, maxAgeMs: 10 * 60_000, deps }); expect(calls()).toBe(1); expect(result.downloaded).toBe(true); expect(result.refreshed).toBe(false); expect(result.refreshedDueToAge).toBe(false); }); it("forceRefresh re-downloads even a fresh cache; refreshedDueToAge stays false", async () => { const base = makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA); await downloadSchemaSdl({ org: ORG, deps: base }); // prime once const { deps, calls } = counting(base); const result = await downloadSchemaSdl({ org: ORG, forceRefresh: true, deps }); expect(calls()).toBe(1); // explicit refresh re-introspects expect(result.downloaded).toBe(true); expect(result.refreshed).toBe(true); expect(result.refreshedDueToAge).toBe(false); // it was explicit, not the gate }); });