UNPKG

@jackchuka/gql-ingest

Version:

A CLI tool for ingesting data from CSV files into a GraphQL API

608 lines (493 loc) 18.7 kB
import fs from "fs"; import path from "path"; import { DataMapper } from "./mapper"; import { GraphQLClientWrapper } from "./graphql-client"; import { MetricsCollector } from "./metrics"; import { readCsvFile, DataReaderFactory } from "./readers"; jest.mock("fs"); jest.mock("./readers", () => ({ ...jest.requireActual("./readers"), readCsvFile: jest.fn(), DataReaderFactory: { getReader: jest.fn().mockReturnValue({ readFile: jest.fn(), }), }, })); const mockFs = fs as jest.Mocked<typeof fs>; describe("DataMapper", () => { let mockClient: jest.Mocked<GraphQLClientWrapper>; let mockMetrics: jest.Mocked<MetricsCollector>; let dataMapper: DataMapper; const testBasePath = "/test/base/path"; beforeEach(() => { mockClient = { executeMutation: jest.fn(), setHeaders: jest.fn(), } as any; mockMetrics = { startEntityProcessing: jest.fn(), recordSuccess: jest.fn(), recordFailure: jest.fn(), finishEntityProcessing: jest.fn(), getMetrics: jest.fn(), } as any; dataMapper = new DataMapper(mockClient, testBasePath, mockMetrics); }); afterEach(() => { jest.clearAllMocks(); }); describe("discoverMappings", () => { it("should discover mapping files in alphabetical order", () => { const mockFiles = ["users.json", "items.json", "orders.json"]; mockFs.readdirSync.mockReturnValue(mockFiles as any); const consoleSpy = jest.spyOn(console, "log").mockImplementation(); const result = dataMapper.discoverMappings("configs/test"); expect(mockFs.readdirSync).toHaveBeenCalledWith( path.resolve(testBasePath, "configs/test", "mappings") ); expect(result).toEqual([ "configs/test/mappings/items.json", "configs/test/mappings/orders.json", "configs/test/mappings/users.json", ]); expect(consoleSpy).toHaveBeenCalledWith( "Discovered 3 mapping files: items.json, orders.json, users.json" ); consoleSpy.mockRestore(); }); it("should filter only JSON files", () => { const mockFiles = ["users.json", "items.txt", "orders.json", "readme.md"]; mockFs.readdirSync.mockReturnValue(mockFiles as any); const result = dataMapper.discoverMappings("configs/test"); expect(result).toEqual([ "configs/test/mappings/orders.json", "configs/test/mappings/users.json", ]); }); it("should handle directory read errors", () => { mockFs.readdirSync.mockImplementation(() => { throw new Error("Directory not found"); }); const consoleSpy = jest.spyOn(console, "error").mockImplementation(); const result = dataMapper.discoverMappings("configs/nonexistent"); expect(result).toEqual([]); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining("Error reading mappings directory"), expect.any(Error) ); consoleSpy.mockRestore(); }); }); describe("processEntity", () => { it("should process entity successfully", async () => { const mockConfig = { csvFile: "data/users.csv", graphqlFile: "graphql/users.graphql", mapping: { name: "user_name", email: "user_email", }, }; const mockCsvData = [ { user_name: "John", user_email: "john@example.com" }, { user_name: "Jane", user_email: "jane@example.com" }, ]; const mockMutation = "mutation CreateUser($name: String!, $email: String!) { createUser(input: { name: $name, email: $email }) { id } }"; mockFs.readFileSync .mockReturnValueOnce(JSON.stringify(mockConfig)) .mockReturnValueOnce(mockMutation); const { DataReaderFactory } = require("./readers"); DataReaderFactory.getReader().readFile.mockResolvedValue(mockCsvData); mockClient.executeMutation.mockResolvedValue({ createUser: { id: "123" }, }); const consoleSpy = jest.spyOn(console, "log").mockImplementation(); await dataMapper.processEntity("configs/test/mappings/users.json"); expect(mockFs.readFileSync).toHaveBeenCalledWith( path.resolve(testBasePath, "configs/test/mappings/users.json"), "utf8" ); expect(DataReaderFactory.getReader).toHaveBeenCalledWith( path.resolve(testBasePath, "configs/test", "data/users.csv"), undefined ); expect(mockFs.readFileSync).toHaveBeenCalledWith( path.resolve(testBasePath, "configs/test", "graphql/users.graphql"), "utf8" ); expect(mockClient.executeMutation).toHaveBeenCalledTimes(2); expect(mockClient.executeMutation).toHaveBeenCalledWith( mockMutation, { name: "John", email: "john@example.com", }, undefined ); expect(mockClient.executeMutation).toHaveBeenCalledWith( mockMutation, { name: "Jane", email: "jane@example.com", }, undefined ); consoleSpy.mockRestore(); }); it("should handle GraphQL execution errors gracefully", async () => { const mockConfig = { csvFile: "data/users.csv", graphqlFile: "graphql/users.graphql", mapping: { name: "user_name" }, }; const mockCsvData = [{ user_name: "John" }]; const mockMutation = "mutation CreateUser($name: String!) { createUser(input: { name: $name }) { id } }"; mockFs.readFileSync .mockReturnValueOnce(JSON.stringify(mockConfig)) .mockReturnValueOnce(mockMutation); const { DataReaderFactory } = require("./readers"); DataReaderFactory.getReader().readFile.mockResolvedValue(mockCsvData); mockClient.executeMutation.mockRejectedValue(new Error("GraphQL error")); const consoleSpy = jest.spyOn(console, "error").mockImplementation(); await dataMapper.processEntity("configs/test/mappings/users.json"); expect(consoleSpy).toHaveBeenCalledWith( "✗ Failed to create entity for row 1:", { user_name: "John" }, expect.any(Error) ); consoleSpy.mockRestore(); }); it("should map CSV columns to GraphQL variables correctly", async () => { const mockConfig = { csvFile: "data/products.csv", graphqlFile: "graphql/products.graphql", mapping: { name: "product_name", price: "product_price", sku: "product_sku", }, }; const mockCsvData = [ { product_name: "Widget", product_price: "19.99", product_sku: "W001", extra_column: "ignored", }, ]; const mockMutation = "mutation CreateProduct($name: String!, $price: String!, $sku: String!) { createProduct(input: { name: $name, price: $price, sku: $sku }) { id } }"; mockFs.readFileSync .mockReturnValueOnce(JSON.stringify(mockConfig)) .mockReturnValueOnce(mockMutation); const { DataReaderFactory } = require("./readers"); DataReaderFactory.getReader().readFile.mockResolvedValue(mockCsvData); mockClient.executeMutation.mockResolvedValue({ createProduct: { id: "456" }, }); await dataMapper.processEntity("configs/test/mappings/products.json"); expect(mockClient.executeMutation).toHaveBeenCalledWith( mockMutation, { name: "Widget", price: "19.99", sku: "W001", }, undefined ); }); it("should handle missing CSV columns gracefully", async () => { const mockConfig = { csvFile: "data/users.csv", graphqlFile: "graphql/users.graphql", mapping: { name: "user_name", email: "user_email", phone: "user_phone", }, }; const mockCsvData = [ { user_name: "John", user_email: "john@example.com" }, ]; const mockMutation = "mutation CreateUser($name: String!, $email: String, $phone: String) { createUser(input: { name: $name, email: $email, phone: $phone }) { id } }"; mockFs.readFileSync .mockReturnValueOnce(JSON.stringify(mockConfig)) .mockReturnValueOnce(mockMutation); const { DataReaderFactory } = require("./readers"); DataReaderFactory.getReader().readFile.mockResolvedValue(mockCsvData); mockClient.executeMutation.mockResolvedValue({ createUser: { id: "789" }, }); await dataMapper.processEntity("configs/test/mappings/users.json"); expect(mockClient.executeMutation).toHaveBeenCalledWith( mockMutation, { name: "John", email: "john@example.com", }, undefined ); }); it("should call metrics methods during successful processing", async () => { const mockConfig = { csvFile: "data/users.csv", graphqlFile: "graphql/users.graphql", mapping: { name: "user_name" }, }; const mockCsvData = [{ user_name: "John" }, { user_name: "Jane" }]; const mockMutation = "mutation CreateUser($name: String!) { createUser(input: { name: $name }) { id } }"; mockFs.readFileSync .mockReturnValueOnce(JSON.stringify(mockConfig)) .mockReturnValueOnce(mockMutation); const { DataReaderFactory } = require("./readers"); DataReaderFactory.getReader().readFile.mockResolvedValue(mockCsvData); mockClient.executeMutation.mockResolvedValue({ createUser: { id: "123" }, }); await dataMapper.processEntity("configs/test/mappings/users.json"); expect(mockMetrics.startEntityProcessing).toHaveBeenCalledWith("users"); expect(mockMetrics.recordSuccess).toHaveBeenCalledTimes(2); expect(mockMetrics.recordSuccess).toHaveBeenCalledWith("users"); expect(mockMetrics.finishEntityProcessing).toHaveBeenCalledWith("users"); expect(mockMetrics.recordFailure).not.toHaveBeenCalled(); }); it("should call metrics methods during failed processing", async () => { const mockConfig = { csvFile: "data/users.csv", graphqlFile: "graphql/users.graphql", mapping: { name: "user_name" }, }; const mockCsvData = [{ user_name: "John" }]; const mockMutation = "mutation CreateUser($name: String!) { createUser(input: { name: $name }) { id } }"; mockFs.readFileSync .mockReturnValueOnce(JSON.stringify(mockConfig)) .mockReturnValueOnce(mockMutation); const { DataReaderFactory } = require("./readers"); DataReaderFactory.getReader().readFile.mockResolvedValue(mockCsvData); mockClient.executeMutation.mockRejectedValue(new Error("GraphQL error")); const consoleSpy = jest.spyOn(console, "error").mockImplementation(); await dataMapper.processEntity("configs/test/mappings/users.json"); expect(mockMetrics.startEntityProcessing).toHaveBeenCalledWith("users"); expect(mockMetrics.recordFailure).toHaveBeenCalledTimes(1); expect(mockMetrics.recordFailure).toHaveBeenCalledWith("users"); expect(mockMetrics.finishEntityProcessing).toHaveBeenCalledWith("users"); expect(mockMetrics.recordSuccess).not.toHaveBeenCalled(); consoleSpy.mockRestore(); }); it("should expose metrics through getMetrics method", () => { const metrics = dataMapper.getMetrics(); expect(metrics).toBe(mockMetrics); }); it("should convert numeric types from CSV strings to proper GraphQL types", async () => { const mockConfig = { csvFile: "data/products.csv", graphqlFile: "graphql/products.graphql", mapping: { name: "product_name", price: "product_price", quantity: "product_quantity", active: "product_active", }, }; const mockCsvData = [ { product_name: "Widget", product_price: "19.99", product_quantity: "10", product_active: "true", }, ]; const mockMutation = ` mutation CreateProduct($name: String!, $price: Float!, $quantity: Int!, $active: Boolean!) { createProduct(input: { name: $name, price: $price, quantity: $quantity, active: $active }) { id } } `; mockFs.readFileSync .mockReturnValueOnce(JSON.stringify(mockConfig)) .mockReturnValueOnce(mockMutation); const { DataReaderFactory } = require("./readers"); DataReaderFactory.getReader().readFile.mockResolvedValue(mockCsvData); mockClient.executeMutation.mockResolvedValue({ createProduct: { id: "123" }, }); await dataMapper.processEntity("configs/test/mappings/products.json"); expect(mockClient.executeMutation).toHaveBeenCalledWith( mockMutation, { name: "Widget", price: 19.99, quantity: 10, active: true, }, undefined ); }); it("should handle invalid numeric conversions gracefully", async () => { const mockConfig = { csvFile: "data/products.csv", graphqlFile: "graphql/products.graphql", mapping: { name: "product_name", price: "product_price", quantity: "product_quantity", }, }; const mockCsvData = [ { product_name: "Widget", product_price: "invalid_price", product_quantity: "invalid_quantity", }, ]; const mockMutation = ` mutation CreateProduct($name: String!, $price: Float!, $quantity: Int!) { createProduct(input: { name: $name, price: $price, quantity: $quantity }) { id } } `; mockFs.readFileSync .mockReturnValueOnce(JSON.stringify(mockConfig)) .mockReturnValueOnce(mockMutation); const { DataReaderFactory } = require("./readers"); DataReaderFactory.getReader().readFile.mockResolvedValue(mockCsvData); mockClient.executeMutation.mockResolvedValue({ createProduct: { id: "123" }, }); const consoleSpy = jest.spyOn(console, "warn").mockImplementation(); await dataMapper.processEntity("configs/test/mappings/products.json"); expect(mockClient.executeMutation).toHaveBeenCalledWith( mockMutation, { name: "Widget", price: "invalid_price", quantity: "invalid_quantity", }, undefined ); expect(consoleSpy).toHaveBeenCalledWith( 'Warning: Cannot convert "invalid_price" to Float for variable $price. Expected a valid number. Using original value.' ); expect(consoleSpy).toHaveBeenCalledWith( 'Warning: Cannot convert "invalid_quantity" to Int for variable $quantity. Expected a valid integer. Using original value.' ); consoleSpy.mockRestore(); }); it("should handle edge cases in numeric conversion safely", async () => { const mockConfig = { csvFile: "data/products.csv", graphqlFile: "graphql/products.graphql", mapping: { int_field: "int_value", float_field: "float_value", }, }; const mockCsvData = [ { int_value: "1.5", // Float in Int field - should remain string float_value: "Infinity", // Invalid float - should remain string }, { int_value: "not_a_number", // Invalid int - should remain string float_value: "1.2.3", // Invalid number format - should remain string }, ]; const mockMutation = ` mutation CreateProduct($int_field: Int!, $float_field: Float!) { createProduct(input: { int_field: $int_field, float_field: $float_field }) { id } } `; mockFs.readFileSync .mockReturnValueOnce(JSON.stringify(mockConfig)) .mockReturnValueOnce(mockMutation); const { DataReaderFactory } = require("./readers"); DataReaderFactory.getReader().readFile.mockResolvedValue(mockCsvData); mockClient.executeMutation.mockResolvedValue({ createProduct: { id: "123" }, }); const consoleSpy = jest.spyOn(console, "warn").mockImplementation(); await dataMapper.processEntity("configs/test/mappings/products.json"); // Should keep invalid values as strings expect(mockClient.executeMutation).toHaveBeenCalledWith( mockMutation, { int_field: "1.5", float_field: "Infinity", }, undefined ); expect(mockClient.executeMutation).toHaveBeenCalledWith( mockMutation, { int_field: "not_a_number", float_field: "1.2.3", }, undefined ); consoleSpy.mockRestore(); }); it("should keep unknown scalar types as strings", async () => { const mockConfig = { csvFile: "data/products.csv", graphqlFile: "graphql/products.graphql", mapping: { name: "product_name", custom_field: "custom_value", }, }; const mockCsvData = [ { product_name: "Widget", custom_value: "123", }, ]; const mockMutation = ` mutation CreateProduct($name: String!, $custom_field: CustomScalar!) { createProduct(input: { name: $name, custom_field: $custom_field }) { id } } `; mockFs.readFileSync .mockReturnValueOnce(JSON.stringify(mockConfig)) .mockReturnValueOnce(mockMutation); const { DataReaderFactory } = require("./readers"); DataReaderFactory.getReader().readFile.mockResolvedValue(mockCsvData); mockClient.executeMutation.mockResolvedValue({ createProduct: { id: "123" }, }); // Create verbose mapper to test the logging const verboseMapper = new DataMapper( mockClient, testBasePath, mockMetrics, true ); const consoleSpy = jest.spyOn(console, "log").mockImplementation(); await verboseMapper.processEntity("configs/test/mappings/products.json"); // Should keep custom scalar as string expect(mockClient.executeMutation).toHaveBeenCalledWith( mockMutation, { name: "Widget", custom_field: "123", }, undefined ); expect(consoleSpy).toHaveBeenCalledWith( 'Unknown GraphQL type "CustomScalar" for variable $custom_field. Keeping value as string.' ); consoleSpy.mockRestore(); }); }); });