diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts new file mode 100644 index 0000000..99703d6 --- /dev/null +++ b/lib/src/commands/pull.test.ts @@ -0,0 +1,362 @@ +import { pull } from "./pull"; +import httpClient from "../http/client"; +import { TextItem } from "../http/textItems"; +import appContext from "../utils/appContext"; +import * as path from "path"; +import * as fs from "fs"; +import * as os from "os"; + +jest.mock("../http/client"); + +const mockHttpClient = httpClient as jest.Mocked; + +// Test data factories +const createMockTextItem = (overrides: Partial = {}) => ({ + id: "text-1", + text: "Plain text content", + richText: "

Rich HTML content

", + status: "active", + notes: "", + tags: [], + variableIds: [], + projectId: "project-1", + variantId: null, + ...overrides, +}); + +const createMockVariable = (overrides: any = {}) => ({ + id: "var-1", + name: "Variable 1", + type: "string", + data: { + example: "variable value", + fallback: undefined, + }, + ...overrides, +}); + +// Helper functions +const setupMocks = (textItems: TextItem[] = [], variables: any[] = []) => { + mockHttpClient.get.mockImplementation((url: string) => { + if (url.includes("/v2/textItems")) { + return Promise.resolve({ data: textItems }); + } + if (url.includes("/v2/variables")) { + return Promise.resolve({ data: variables }); + } + return Promise.resolve({ data: [] }); + }); +}; + +const parseJsonFile = (filepath: string) => { + const content = fs.readFileSync(filepath, "utf-8"); + return JSON.parse(content); +}; + +const assertFileContainsText = ( + filepath: string, + textId: string, + expectedText: string +) => { + const content = parseJsonFile(filepath); + expect(content[textId]).toBe(expectedText); +}; + +const assertFilesCreated = (outputDir: string, expectedFiles: string[]) => { + const actualFiles = fs.readdirSync(outputDir).toSorted(); + expect(actualFiles).toEqual(expectedFiles.toSorted()); +}; + +describe("pull command - end-to-end tests", () => { + // Create a temporary directory for tests + let testDir: string; + let outputDir: string; + + // Reset appContext before each test + beforeEach(() => { + jest.clearAllMocks(); + + // Create a fresh temp directory for each test + testDir = fs.mkdtempSync(path.join(os.tmpdir(), "ditto-test-")); + outputDir = path.join(testDir, "output"); + + // Reset appContext to a clean state + appContext.setProjectConfig({ + projects: [], + outputs: [ + { + format: "json", + outDir: outputDir, + }, + ], + }); + }); + + // Clean up temp directory after each test + afterEach(() => { + if (testDir && fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + describe("Rich Text Feature", () => { + it("should use rich text when configured at base level", async () => { + // Only create output directory since we're mocking HTTP and setting appContext directly + fs.mkdirSync(outputDir, { recursive: true }); + + const mockTextItem = createMockTextItem(); + setupMocks([mockTextItem], []); + + // Set up appContext - this is what actually drives the test + appContext.setProjectConfig({ + projects: [{ id: "project-1" }], + richText: "html", + outputs: [{ format: "json", outDir: outputDir }], + }); + + await pull(); + + // Verify rich text content was written + assertFileContainsText( + path.join(outputDir, "project-1___base.json"), + "text-1", + "

Rich HTML content

" + ); + }); + + it("should use plain text when richText is disabled at output level", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + const mockTextItem = createMockTextItem(); + setupMocks([mockTextItem], []); + + appContext.setProjectConfig({ + projects: [{ id: "project-1" }], + richText: "html", + outputs: [{ format: "json", outDir: outputDir, richText: false }], + }); + + await pull(); + + // Verify plain text content was written despite base config + assertFileContainsText( + path.join(outputDir, "project-1___base.json"), + "text-1", + "Plain text content" + ); + }); + + it("should use rich text when enabled only at output level", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + const mockTextItem = createMockTextItem(); + setupMocks([mockTextItem], []); + + appContext.setProjectConfig({ + projects: [{ id: "project-1" }], + outputs: [{ format: "json", outDir: outputDir, richText: "html" }], + }); + + await pull(); + + // Verify rich text content was written + assertFileContainsText( + path.join(outputDir, "project-1___base.json"), + "text-1", + "

Rich HTML content

" + ); + }); + }); + + describe("Filter Feature", () => { + it("should filter projects when configured at base level", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + appContext.setProjectConfig({ + projects: [{ id: "project-1" }, { id: "project-2" }], + outputs: [ + { + format: "json", + outDir: outputDir, + }, + ], + }); + + await pull(); + + // Verify correct API call with filtered params + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/textItems", { + params: { + filter: '{"projects":[{"id":"project-1"},{"id":"project-2"}]}', + }, + }); + }); + + it("should filter variants at base level", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + appContext.setProjectConfig({ + projects: [{ id: "project-1" }], + variants: [{ id: "variant-a" }, { id: "variant-b" }], + outputs: [ + { + format: "json", + outDir: outputDir, + }, + ], + }); + + await pull(); + + // Verify correct API call with filtered params + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/textItems", { + params: { + filter: + '{"projects":[{"id":"project-1"}],"variants":[{"id":"variant-a"},{"id":"variant-b"}]}', + }, + }); + }); + + it("should filter projects at output level", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + appContext.setProjectConfig({ + projects: [{ id: "project-1" }, { id: "project-2" }], + outputs: [ + { + format: "json", + outDir: outputDir, + projects: [{ id: "project-1" }], + }, + ], + }); + + await pull(); + + // Verify correct API call with filtered params + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/textItems", { + params: { + filter: '{"projects":[{"id":"project-1"}]}', + }, + }); + }); + + it("should filter variants at output level", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + appContext.setProjectConfig({ + projects: [{ id: "project-1" }], + variants: [{ id: "variant-a" }, { id: "variant-b" }], + outputs: [ + { + format: "json", + outDir: outputDir, + variants: [{ id: "variant-a" }], + }, + ], + }); + + await pull(); + + // Verify correct API call with filtered params + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/textItems", { + params: { + filter: + '{"projects":[{"id":"project-1"}],"variants":[{"id":"variant-a"}]}', + }, + }); + }); + + it("supports the default filter behavior", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + appContext.setProjectConfig({ + outputs: [ + { + format: "json", + outDir: outputDir, + }, + ], + }); + + await pull(); + + // Verify correct API call with filtered params + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/textItems", { + params: { + filter: "{}", + }, + }); + }); + }); + + describe("Output files", () => { + it("should create output files for each project and variant returned from the API", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + // project-1 and project-2 each have at least one base text item + const baseTextItems = [ + createMockTextItem({ + projectId: "project-1", + variantId: null, + id: "text-1", + }), + createMockTextItem({ + projectId: "project-1", + variantId: null, + id: "text-2", + }), + createMockTextItem({ + projectId: "project-2", + variantId: null, + id: "text-3", + }), + ]; + + // project-1 and project-2 each have a variant-a text item + const variantATextItems = [ + createMockTextItem({ + projectId: "project-1", + variantId: "variant-a", + id: "text-4", + }), + createMockTextItem({ + projectId: "project-2", + variantId: "variant-a", + id: "text-5", + }), + ]; + + // Only project-1 has variant-b, so only project-1 should get a variant-b file + const variantBTextItems = [ + createMockTextItem({ + projectId: "project-1", + variantId: "variant-b", + id: "text-6", + }), + createMockTextItem({ + projectId: "project-1", + variantId: "variant-b", + id: "text-7", + }), + ]; + + setupMocks( + [...baseTextItems, ...variantATextItems, ...variantBTextItems], + [] + ); + + await pull(); + + // Verify a file was created for each project and variant present in the (mocked) API response + assertFilesCreated(outputDir, [ + "project-1___base.json", + "project-1___variant-a.json", + "project-1___variant-b.json", + "project-2___base.json", + "project-2___variant-a.json", + "variables.json", + ]); + }); + }); +}); diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index 912b341..e4145f8 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -1,9 +1,8 @@ -import fetchText, { PullFilters, TextItemsResponse } from "../http/textItems"; -import fetchVariables, { Variable, VariablesResponse } from "../http/variables"; +import fetchText, { TextItemsResponse, PullFilters, PullQueryParams } from "../http/textItems"; +import fetchVariables, { Variable } from "../http/variables"; import BaseFormatter from "./shared/base"; import OutputFile from "./shared/fileTypes/OutputFile"; import JSONOutputFile from "./shared/fileTypes/JSONOutputFile"; -import appContext from "../utils/appContext"; import { applyMixins } from "./shared"; import { getFrameworkProcessor } from "./frameworks/json"; @@ -16,8 +15,8 @@ export default class JSONFormatter extends applyMixins( BaseFormatter) { protected async fetchAPIData() { - const filters = this.generatePullFilter(); - const textItems = await fetchText(filters); + const queryParams = this.generateQueryParams(); + const textItems = await fetchText(queryParams); const variables = await fetchVariables(); const variablesById = variables.reduce((acc, variable) => { @@ -51,8 +50,15 @@ export default class JSONFormatter extends applyMixins( metadata: { variantId: textItem.variantId || "base" }, }); - - outputJsonFiles[fileName].content[textItem.id] = textItem.text; + // Use richText if available and configured, otherwise use text + const outputRichTextEnabled = this.output.richText === "html" + const baseRichTextEnabledAndNotOveridden = this.projectConfig.richText === "html" && this.output.richText !== false + const richTextConfigured = outputRichTextEnabled || baseRichTextEnabledAndNotOveridden + const textValue = richTextConfigured && textItem.richText + ? textItem.richText + : textItem.text; + + outputJsonFiles[fileName].content[textItem.id] = textValue; for (const variableId of textItem.variableIds) { const variable = data.variablesById[variableId]; variablesOutputFile.content[variableId] = variable.data; @@ -87,4 +93,25 @@ export default class JSONFormatter extends applyMixins( return filters; } + + /** + * Returns the query parameters for the fetchText API request + */ + private generateQueryParams() { + const filter = this.generatePullFilter(); + + let params: PullQueryParams = { + filter: JSON.stringify(filter), + }; + + if (this.projectConfig.richText) { + params.richText = this.projectConfig.richText; + } + + if (this.output.richText) { + params.richText = this.output.richText; + } + + return params; + } } diff --git a/lib/src/http/textItems.test.ts b/lib/src/http/textItems.test.ts new file mode 100644 index 0000000..aad3b53 --- /dev/null +++ b/lib/src/http/textItems.test.ts @@ -0,0 +1,89 @@ +import fetchText from "./textItems"; +import httpClient from "./client"; + +jest.mock("./client"); + +describe("fetchText", () => { + const mockHttpClient = httpClient as jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("richText parameter", () => { + it("should parse response with richText field correctly", async () => { + const mockResponse = { + data: [ + { + id: "text1", + text: "Plain text", + richText: "

Rich HTML text

", + status: "active", + notes: "Test note", + tags: ["tag1"], + variableIds: ["var1"], + projectId: "project1", + variantId: "variant1", + }, + ], + }; + + mockHttpClient.get.mockResolvedValue(mockResponse); + + const result = await fetchText({ + richText: "html", + }); + + expect(result).toEqual([ + { + id: "text1", + text: "Plain text", + richText: "

Rich HTML text

", + status: "active", + notes: "Test note", + tags: ["tag1"], + variableIds: ["var1"], + projectId: "project1", + variantId: "variant1", + }, + ]); + }); + + it("should handle response without richText field", async () => { + const mockResponse = { + data: [ + { + id: "text1", + text: "Plain text only", + status: "active", + notes: "", + tags: [], + variableIds: [], + projectId: "project1", + variantId: null, + }, + ], + }; + + mockHttpClient.get.mockResolvedValue(mockResponse); + + const result = await fetchText({ + richText: "html", + }); + + expect(result).toEqual([ + { + id: "text1", + text: "Plain text only", + richText: undefined, + status: "active", + notes: "", + tags: [], + variableIds: [], + projectId: "project1", + variantId: null, + }, + ]); + }); + }); +}); \ No newline at end of file diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index 18e86f9..173fc5b 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -7,28 +7,35 @@ export interface PullFilters { variants?: { id: string }[]; } -const TextItemsResponse = z.array( - z.object({ - id: z.string(), - text: z.string(), - status: z.string(), - notes: z.string(), - tags: z.array(z.string()), - variableIds: z.array(z.string()), - projectId: z.string(), - variantId: z.string().nullable(), - }) -); +export interface PullQueryParams { + filter: string; // Stringified PullFilters + richText?: "html"; +} + +const ZTextItem = z.object({ + id: z.string(), + text: z.string(), + richText: z.string().optional(), + status: z.string(), + notes: z.string(), + tags: z.array(z.string()), + variableIds: z.array(z.string()), + projectId: z.string(), + variantId: z.string().nullable(), +}); + +/** + * Represents a single text item, as returned from the /v2/textItems endpoint + */ +export type TextItem = z.infer; + +const TextItemsResponse = z.array(ZTextItem); export type TextItemsResponse = z.infer; -export default async function fetchText(filters?: PullFilters) { +export default async function fetchText(params: PullQueryParams) { try { - const response = await httpClient.get("/v2/textItems", { - params: { - filter: JSON.stringify(filters), - }, - }); + const response = await httpClient.get("/v2/textItems", { params }); return TextItemsResponse.parse(response.data); } catch (e: unknown) { diff --git a/lib/src/outputs/shared.ts b/lib/src/outputs/shared.ts index 2ba0b05..c044d5a 100644 --- a/lib/src/outputs/shared.ts +++ b/lib/src/outputs/shared.ts @@ -8,4 +8,5 @@ export const ZBaseOutputFilters = z.object({ projects: z.array(z.object({ id: z.string() })).optional(), variants: z.array(z.object({ id: z.string() })).optional(), outDir: z.string().optional(), + richText: z.union([z.literal("html"), z.literal(false)]).optional(), }); diff --git a/lib/src/services/apiToken/collectToken.test.ts b/lib/src/services/apiToken/collectToken.test.ts index 4fa270c..54a5785 100644 --- a/lib/src/services/apiToken/collectToken.test.ts +++ b/lib/src/services/apiToken/collectToken.test.ts @@ -28,8 +28,6 @@ describe("collectToken", () => { const response = await collectToken(); expect(response).toBe(token); expect(logger.url).toHaveBeenCalled(); - expect(logger.bold).toHaveBeenCalled(); - expect(logger.info).toHaveBeenCalled(); expect(logger.writeLine).toHaveBeenCalled(); expect(promptForApiTokenSpy).toHaveBeenCalled(); }); diff --git a/lib/src/services/apiToken/collectToken.ts b/lib/src/services/apiToken/collectToken.ts index eb7b717..045951f 100644 --- a/lib/src/services/apiToken/collectToken.ts +++ b/lib/src/services/apiToken/collectToken.ts @@ -6,9 +6,8 @@ import promptForApiToken from "./promptForApiToken"; * @returns The collected token */ export default async function collectToken() { - const apiUrl = logger.url("https://app.dittowords.com/account/devtools"); - const breadcrumbs = logger.bold(logger.info("API Keys")); - const tokenDescription = `To get started, you'll need your Ditto API key. You can find this at: ${apiUrl} under "${breadcrumbs}".`; + const apiUrl = logger.url("https://app.dittowords.com/developers/api-keys"); + const tokenDescription = `To get started, you'll need your Ditto API key. You can find this at: ${apiUrl}.`; logger.writeLine(tokenDescription);