import { describe, it, expect, vi } from "vitest"; import { z } from "zod"; import type { WarehouseReader } from "@jitsu/warehouse-query"; import type { ReverseRunConfig } from "@jitsu/warehouse-query/src/runtime"; import { createGoogleDataManager, projectGoogleAudience, } from "@jitsu/destination-functions/src/functions/google-ads/audience/runtime"; import { prepareSource } from "./source-preflight"; import { defaultLimits } from "./persistence/types"; describe("source preflight", () => { const google = createGoogleDataManager(async () => "unused"); const validation = { stream: google.mirrorStream, projection: { rowType: google.stream.rowType, project: (row: any) => projectGoogleAudience("upsert", row) }, }; const config = { model: { primaryKey: ["id"] }, options: { mode: "mirror", mapping: { email: "email" } }, } as unknown as ReverseRunConfig; function source() { const stream = vi.fn(async function* () { yield { row: { id: "1", email: "One@example.com", ignored: "not staged" }, deleted: false }; yield { row: { id: "2", email: "Two@example.com" }, deleted: false }; }); const close = vi.fn(async () => {}); return { stream, close } as unknown as WarehouseReader; } it("reuses the exact validated rows without querying again, drops unmapped data and closes the reader", async () => { const reader = source(); const signal = new AbortController().signal; const staged = await prepareSource(config, validation, reader, signal, defaultLimits); try { expect(staged.count).toBe(2); const rows: Record[] = []; for await (const row of staged.rows(signal)) rows.push(row.row); expect(rows).toEqual([ { id: "1", email: "One@example.com" }, { id: "2", email: "Two@example.com" }, ]); expect(reader.stream).toHaveBeenCalledTimes(1); expect(reader.close).toHaveBeenCalledTimes(1); } finally { await staged.close(); } }); it("rejects snapshot budget overflow before provisioning", async () => { const reader = source(); await expect( prepareSource(config, validation, reader, new AbortController().signal, { ...defaultLimits, snapshotEntries: 1 }) ).rejects.toThrow("No audience changes were submitted"); expect(reader.close).toHaveBeenCalledTimes(1); }); it("accepts composition-only mappings and stages their source columns for replay", async () => { const reader = source(); const signal = new AbortController().signal; const composed = { ...config, options: { ...config.options, mapping: {}, objectMappings: { contact: { email: "email" } } }, }; // An object-only projection exercises the shared contract without requiring a new provider stream. const project = vi.fn((row: { contact: { email: string } }) => projectGoogleAudience("upsert", google.stream.rowType.parse(row.contact)) ); const staged = await prepareSource( composed, { ...validation, projection: { rowType: z.object({ contact: z.object({ email: z.string().email() }) }), project }, }, reader, signal, defaultLimits ); try { expect(staged.count).toBe(2); expect(project).toHaveBeenCalledTimes(2); expect(project).toHaveBeenNthCalledWith(1, { contact: { email: "One@example.com" } }); expect(project).toHaveBeenNthCalledWith(2, { contact: { email: "Two@example.com" } }); const rows: Record[] = []; for await (const row of staged.rows(signal)) rows.push(row.row); expect(rows).toEqual([ { id: "1", email: "One@example.com" }, { id: "2", email: "Two@example.com" }, ]); expect(reader.stream).toHaveBeenCalledTimes(1); expect(reader.close).toHaveBeenCalledTimes(1); } finally { await staged.close(); } }); const emptyObjectMappings: ReverseRunConfig["options"]["objectMappings"][] = [undefined, {}, { contact: {} }]; it.each(emptyObjectMappings)("rejects empty mappings/compositions before reading rows (%#)", async objectMappings => { const reader = source(); await expect( prepareSource( { ...config, options: { ...config.options, mapping: {}, objectMappings } }, validation, reader, new AbortController().signal, defaultLimits ) ).rejects.toThrow("read 0 rows, saved 0"); expect(reader.stream).not.toHaveBeenCalled(); expect(reader.close).toHaveBeenCalledTimes(1); }); });