diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts index fcd10ef..f16b354 100644 --- a/lib/src/commands/pull.test.ts +++ b/lib/src/commands/pull.test.ts @@ -28,6 +28,7 @@ const createMockTextItem = (overrides: Partial = {}) => ({ notes: "", tags: [], variableIds: [], + pluralForm: null, projectId: "project-1", variantId: null, ...overrides, @@ -41,6 +42,7 @@ const createMockComponent = (overrides: Partial = {}) => ({ notes: "", tags: [], variableIds: [], + pluralForm: null, folderId: null, variantId: null, ...overrides, diff --git a/lib/src/formatters/json.test.ts b/lib/src/formatters/json.test.ts new file mode 100644 index 0000000..4e0cf30 --- /dev/null +++ b/lib/src/formatters/json.test.ts @@ -0,0 +1,586 @@ +import { Output } from "../outputs"; +import { ProjectConfigYAML } from "../services/projectConfig"; +import { CommandMetaFlags, TextItem, Component } from "../http/types"; +import { fetchTextItems } from "../http/textItems"; +import { fetchComponents } from "../http/components"; +import fetchVariables, { Variable } from "../http/variables"; +import JSONFormatter from "./json"; +import { getFrameworkProcessor } from "./frameworks/json"; +import JSONOutputFile from "./shared/fileTypes/JSONOutputFile"; + +jest.mock("../http/textItems"); +jest.mock("../http/components"); +jest.mock("../http/variables"); +jest.mock("../utils/appContext", () => ({ + __esModule: true, + default: { + outDir: "/mock/app/context/outDir", + }, +})); + +jest.mock("./frameworks/json"); + +const mockGetFrameworkProcessor = getFrameworkProcessor as jest.MockedFunction< + typeof getFrameworkProcessor +>; + +const mockFetchTextItems = fetchTextItems as jest.MockedFunction< + typeof fetchTextItems +>; +const mockFetchComponents = fetchComponents as jest.MockedFunction< + typeof fetchComponents +>; +const mockFetchVariables = fetchVariables as jest.MockedFunction< + typeof fetchVariables +>; + +// fake test class to expose private methods +// @ts-ignore +class TestJSONFormatter extends JSONFormatter { + public async fetchAPIData() { + return super.fetchAPIData(); + } + + public transformAPIData( + data: Parameters[0] + ) { + return super.transformAPIData(data); + } + + // Expose private methods for testing + public transformAPITextEntity( + textEntity: TextItem | Component, + variablesById: Record + ) { + return super["transformAPITextEntity"](textEntity, variablesById); + } + + public async fetchTextItems() { + return super["fetchTextItems"](); + } + + public async fetchComponents() { + return super["fetchComponents"](); + } + + public async fetchVariables() { + return super["fetchVariables"](); + } + + public getOutputFiles() { + // @ts-ignore + return this.outputFiles; + } + + public getVariablesOutputFile() { + // @ts-ignore + return this.variablesOutputFile; + } +} + +describe("JSONFormatter", () => { + // @ts-ignore + const createMockOutput = (overrides: Partial = {}): Output => ({ + format: "json", + outDir: "/test/output", + ...overrides, + }); + + const createMockProjectConfig = ( + overrides: Partial = {} + ): ProjectConfigYAML => ({ + projects: [], + variants: [], + components: { + folders: [], + }, + outputs: [ + { + format: "json", + }, + ], + ...overrides, + }); + + const createMockMeta = (): CommandMetaFlags => ({}); + + const createMockTextItem = (overrides: Partial = {}): TextItem => ({ + id: "text-1", + text: "Plain text content", + richText: "

Rich HTML content

", + status: "FINAL", + notes: "", + tags: [], + variableIds: [], + projectId: "project-1", + variantId: null, + pluralForm: null, + ...overrides, + }); + + const createMockComponent = ( + overrides: Partial = {} + ): Component => ({ + id: "component-1", + text: "Plain text content", + richText: "

Rich HTML content

", + status: "FINAL", + notes: "", + tags: [], + variableIds: [], + folderId: null, + variantId: null, + pluralForm: null, + ...overrides, + }); + + const createMockVariable = (overrides: Partial = {}): Variable => { + const base = { + id: "var-1", + name: "Variable 1", + type: "string" as const, + data: { + example: "variable value", + fallback: undefined, + }, + }; + return { ...base, ...overrides } as Variable; + }; + + const createMockBaseData = () => { + const mockTextItems = [ + createMockTextItem({ + id: "text-1", + projectId: "project1", + variantId: null, + }), + createMockTextItem({ + id: "text-2", + projectId: "project1", + variantId: null, + }), + ]; + const mockComponents = [ + createMockComponent({ + id: "comp-1", + variantId: null, + }), + ]; + const mockVariables: Variable[] = [ + createMockVariable({ + id: "var-1", + name: "Variable 1", + }), + createMockVariable({ + id: "var-2", + name: "Variable 2", + type: "number" as const, + data: { + example: 42, + }, + }), + ]; + + return { + mockTextItems, + mockComponents, + mockVariables, + }; + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("fetchAPIData", () => { + it("should fetch text items, components, and variables and combine them", async () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + variants: [{ id: "base" }], + components: { + folders: [], + }, + }); + const output = createMockOutput(); + // @ts-ignore + const formatter = new TestJSONFormatter( + output, + projectConfig, + createMockMeta() + ); + + const { mockTextItems, mockComponents, mockVariables } = + createMockBaseData(); + + mockFetchTextItems.mockResolvedValue(mockTextItems); + mockFetchComponents.mockResolvedValue(mockComponents); + mockFetchVariables.mockResolvedValue(mockVariables); + + const fetchTextItemsSpy = jest.spyOn(formatter, "fetchTextItems"); + const fetchComponentsSpy = jest.spyOn(formatter, "fetchComponents"); + const fetchVariablesSpy = jest.spyOn(formatter, "fetchVariables"); + + const result = await formatter.fetchAPIData(); + + expect(fetchTextItemsSpy).toHaveBeenCalled(); + expect(fetchComponentsSpy).toHaveBeenCalled(); + expect(fetchVariablesSpy).toHaveBeenCalled(); + expect(result).toEqual({ + textItems: mockTextItems, + components: mockComponents, + variablesById: { + "var-1": mockVariables[0], + "var-2": mockVariables[1], + }, + }); + }); + }); + + describe("transformAPIData", () => { + it("should invoke transformAPITextEntity for each text item and component", () => { + const projectConfig = createMockProjectConfig(); + const output = createMockOutput(); + // @ts-ignore + const formatter = new TestJSONFormatter( + output, + projectConfig, + createMockMeta() + ); + + const { mockTextItems, mockComponents, mockVariables } = + createMockBaseData(); + + const transformAPITextEntitySpy = jest.spyOn( + formatter, + "transformAPITextEntity" + ); + + formatter.transformAPIData({ + textItems: mockTextItems, + components: mockComponents, + variablesById: mockVariables.reduce((acc, variable) => { + acc[variable.id] = variable; + return acc; + }, {} as Record), + }); + + const mockVariablesById = mockVariables.reduce( + (acc, variable) => ({ ...acc, [variable.id]: variable }), + {} as Record + ); + + expect(transformAPITextEntitySpy).toHaveBeenCalledTimes( + mockTextItems.length + mockComponents.length + ); + mockTextItems.forEach((mockTextItem) => { + expect(transformAPITextEntitySpy).toHaveBeenCalledWith( + mockTextItem, + mockVariablesById + ); + }); + mockComponents.forEach((mockComponent) => { + expect(transformAPITextEntitySpy).toHaveBeenCalledWith( + mockComponent, + mockVariablesById + ); + }); + }); + + it("should call getFrameworkProcessor.process if framework is enabled", () => { + const projectConfig = createMockProjectConfig(); + const output = createMockOutput({ framework: "i18next" }); + // @ts-ignore + const formatter = new TestJSONFormatter( + output, + projectConfig, + createMockMeta() + ); + + mockGetFrameworkProcessor.mockReturnValue({ + process: jest.fn().mockReturnValue(["fakeDriverFile"]), + } as any); + + formatter.transformAPIData({ + textItems: [], + components: [], + variablesById: {}, + }); + + expect(mockGetFrameworkProcessor).toHaveBeenCalledWith(output); + expect(mockGetFrameworkProcessor(output).process).toHaveBeenCalledWith( + formatter.getOutputFiles() + ); + }); + }); + + describe("transformAPITextEntity", () => { + let projectConfig: ProjectConfigYAML; + let output: Output; + let formatter: TestJSONFormatter; + + beforeEach(() => { + projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + variants: [{ id: "base" }], + components: { + folders: [], + }, + }); + output = createMockOutput(); + formatter = new TestJSONFormatter( + output, + projectConfig, + createMockMeta() + ); + }); + + it("should write to output file named {projectId}___{variantId}.json if textItem entity", () => { + const mockFrenchItem = createMockTextItem({ variantId: "french" }); + const mockBaseItem = createMockTextItem(); + formatter.transformAPITextEntity(mockFrenchItem, {}); + formatter.transformAPITextEntity(mockBaseItem, {}); + + const baseVariantFile = formatter.getOutputFiles()["project-1___base"]; + const frenchVariantFile = + formatter.getOutputFiles()["project-1___french"]; + expect(baseVariantFile).toBeInstanceOf(JSONOutputFile); + expect(baseVariantFile.content[mockBaseItem.id]).toEqual( + mockBaseItem.text + ); + expect(frenchVariantFile).toBeInstanceOf(JSONOutputFile); + expect(frenchVariantFile.content[mockFrenchItem.id]).toEqual( + mockFrenchItem.text + ); + }); + + it("should write to output file named components___{variantId}.json if component entity", () => { + const mockSpanishComponent = createMockComponent({ + variantId: "spanish", + }); + const mockBaseComponent = createMockComponent(); + formatter.transformAPITextEntity(mockSpanishComponent, {}); + formatter.transformAPITextEntity(mockBaseComponent, {}); + + const baseVariantFile = formatter.getOutputFiles()["components___base"]; + const spanishVariantFile = + formatter.getOutputFiles()["components___spanish"]; + expect(baseVariantFile).toBeInstanceOf(JSONOutputFile); + expect(baseVariantFile.content[mockBaseComponent.id]).toEqual( + mockBaseComponent.text + ); + expect(spanishVariantFile).toBeInstanceOf(JSONOutputFile); + expect(spanishVariantFile.content[mockSpanishComponent.id]).toEqual( + mockSpanishComponent.text + ); + }); + + it("should write richText to file if output.richText is 'html'", () => { + output = createMockOutput({ richText: "html" }); + formatter = new TestJSONFormatter( + output, + projectConfig, + createMockMeta() + ); + const mockTextItem = createMockTextItem(); + formatter.transformAPITextEntity(mockTextItem, {}); + expect( + formatter.getOutputFiles()["project-1___base"].content[mockTextItem.id] + ).toEqual(mockTextItem.richText); + }); + + it("should write richText to file if projectConfig.richText is 'html'", () => { + projectConfig = createMockProjectConfig({ richText: "html" }); + formatter = new TestJSONFormatter( + output, + projectConfig, + createMockMeta() + ); + const mockTextItem = createMockTextItem(); + formatter.transformAPITextEntity(mockTextItem, {}); + expect( + formatter.getOutputFiles()["project-1___base"].content[mockTextItem.id] + ).toEqual(mockTextItem.richText); + }); + + it("should not write richText to file if projectConfig.richText 'html' is overwritten by output", () => { + projectConfig = createMockProjectConfig({ richText: "html" }); + output = createMockOutput({ richText: false }); + formatter = new TestJSONFormatter( + output, + projectConfig, + createMockMeta() + ); + const mockTextItem = createMockTextItem(); + formatter.transformAPITextEntity(mockTextItem, {}); + expect( + formatter.getOutputFiles()["project-1___base"].content[mockTextItem.id] + ).toEqual(mockTextItem.text); + }); + + it("should write developer ID as-is from API response", () => { + projectConfig = createMockProjectConfig({ richText: "html" }); + output = createMockOutput({ richText: false }); + formatter = new TestJSONFormatter( + output, + projectConfig, + createMockMeta() + ); + const mockTextItem = createMockTextItem({ + id: "the-one-ring_one", + pluralForm: "one", + text: "The {{count}} ring to rule them all", + }); + formatter.transformAPITextEntity(mockTextItem, {}); + const fileContent = + formatter.getOutputFiles()["project-1___base"].content; + expect(fileContent).toHaveProperty(mockTextItem.id); + expect(fileContent[mockTextItem.id]).toEqual(mockTextItem.text); + }); + + it("should write each variableId to variables.json file", () => { + const mockVariables = { + FullName: { + id: "FullName", + name: "FullName", + type: "string" as const, + data: { + example: "Frodo Baggins", + fallback: "User", + }, + }, + }; + formatter.transformAPITextEntity( + createMockComponent({ + text: "{{FullName}}'s Account", + variableIds: ["FullName"], + }), + mockVariables + ); + + expect(formatter.getVariablesOutputFile().content["FullName"]).toEqual( + mockVariables.FullName.data + ); + }); + }); + + describe("fetchTextItems", () => { + let projectConfig: ProjectConfigYAML; + let output: Output; + let formatter: TestJSONFormatter; + + beforeEach(() => { + projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + variants: [{ id: "base" }], + statuses: ["FINAL"], + components: { + folders: [], + }, + }); + output = createMockOutput(); + formatter = new TestJSONFormatter( + output, + projectConfig, + createMockMeta() + ); + }); + + it("should return empty array if projects not configured", async () => { + projectConfig = createMockProjectConfig({ + projects: undefined, + components: { folders: [] }, + }); + formatter = new TestJSONFormatter( + output, + projectConfig, + createMockMeta() + ); + const result = await formatter.fetchTextItems(); + expect(mockFetchTextItems).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it("should invoke fetchTextItems http method with correct params", async () => { + await formatter.fetchTextItems(); + const expectedFilters = { + projects: projectConfig.projects, + variants: projectConfig.variants, + statuses: projectConfig.statuses, + }; + expect(mockFetchTextItems).toHaveBeenCalledWith( + { + filter: JSON.stringify(expectedFilters), + }, + // @ts-ignore + formatter.meta + ); + }); + }); + + describe("fetchComponents", () => { + let projectConfig: ProjectConfigYAML; + let output: Output; + let formatter: TestJSONFormatter; + + beforeEach(() => { + projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + variants: [{ id: "base" }], + statuses: ["FINAL"], + components: { + folders: [], + }, + }); + output = createMockOutput(); + formatter = new TestJSONFormatter( + output, + projectConfig, + createMockMeta() + ); + }); + + it("should return empty array if components not configured", async () => { + projectConfig = createMockProjectConfig({ + components: undefined, + projects: [], + }); + formatter = new TestJSONFormatter( + output, + projectConfig, + createMockMeta() + ); + const result = await formatter.fetchComponents(); + expect(mockFetchComponents).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it("should invoke fetchComponents http method with correct params", async () => { + await formatter.fetchComponents(); + const expectedFilters = { + folders: projectConfig.components?.folders, + variants: projectConfig.variants, + statuses: projectConfig.statuses, + }; + expect(mockFetchComponents).toHaveBeenCalledWith( + { + filter: JSON.stringify(expectedFilters), + }, + // @ts-ignore + formatter.meta + ); + }); + }); + + describe("fetchVariables", () => { + it("should invoke fetchVariables with formatter meta", async () => { + const formatter = new TestJSONFormatter( + {} as Output, + {} as ProjectConfigYAML, + createMockMeta() + ); + await formatter.fetchVariables(); + // @ts-ignore + expect(mockFetchVariables).toHaveBeenCalledWith(formatter.meta); + }); + }); +}); diff --git a/lib/src/http/components.test.ts b/lib/src/http/components.test.ts index ee41bb9..d7fe390 100644 --- a/lib/src/http/components.test.ts +++ b/lib/src/http/components.test.ts @@ -45,6 +45,7 @@ describe("fetchComponents", () => { richText: "

Rich HTML text

", status: "FINAL", notes: "Test note", + pluralForm: null, tags: ["tag1"], variableIds: ["var1"], folderId: null, @@ -70,9 +71,10 @@ describe("fetchComponents", () => { const mockResponse = { data: [ { - id: "text1", + id: "text1_one", text: "Plain text only", status: "FINAL", + pluralForm: "one", notes: "", tags: [], variableIds: [], diff --git a/lib/src/http/textItems.test.ts b/lib/src/http/textItems.test.ts index b1c2fb4..9d113cd 100644 --- a/lib/src/http/textItems.test.ts +++ b/lib/src/http/textItems.test.ts @@ -45,6 +45,7 @@ describe("fetchTextItems", () => { richText: "

Rich HTML text

", status: "FINAL", notes: "Test note", + pluralForm: null, tags: ["tag1"], variableIds: ["var1"], projectId: "project1", @@ -76,6 +77,7 @@ describe("fetchTextItems", () => { status: "FINAL", notes: "", tags: [], + pluralForm: null, variableIds: [], projectId: "project1", variantId: null, diff --git a/lib/src/http/types.ts b/lib/src/http/types.ts index b82429a..89d0457 100644 --- a/lib/src/http/types.ts +++ b/lib/src/http/types.ts @@ -23,6 +23,16 @@ export interface PullQueryParams { export const ZTextStatus = z.enum(["NONE", "WIP", "REVIEW", "FINAL"]); export type ITextStatus = z.infer; +export const ZTextPluralType = z.enum([ + "zero", + "one", + "two", + "few", + "many", + "other", +]); +export type ITextPluralType = z.infer; + const ZBaseTextEntity = z.object({ id: z.string(), text: z.string(), @@ -30,6 +40,7 @@ const ZBaseTextEntity = z.object({ status: z.string(), notes: z.string(), tags: z.array(z.string()), + pluralForm: ZTextPluralType.nullable(), variableIds: z.array(z.string()), variantId: z.string().nullable(), }); diff --git a/package.json b/package.json index f6b065b..530c165 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dittowords/cli", - "version": "5.3.0", + "version": "5.3.1", "description": "Command Line Interface for Ditto (dittowords.com).", "license": "MIT", "main": "bin/ditto.js", @@ -8,7 +8,9 @@ "prepublishOnly": "ENV=production node esbuild.mjs && sentry-cli sourcemaps inject ./bin && npx sentry-cli sourcemaps upload ./bin --release=\"$(cat package.json | jq -r '.version')\"", "prepare": "husky install", "start": "node esbuild.mjs && node --enable-source-maps --require dotenv/config bin/ditto.js", - "sync": "node esbuild.mjs && node --enable-source-maps --require dotenv/config bin/ditto.js pull" + "sync": "node esbuild.mjs && node --enable-source-maps --require dotenv/config bin/ditto.js pull", + "test": "jest", + "test:watch": "jest --watchAll" }, "repository": { "type": "git",