diff --git a/.claude/agents/test-writer.md b/.claude/agents/test-writer.md index 268d501..a1e2ef3 100644 --- a/.claude/agents/test-writer.md +++ b/.claude/agents/test-writer.md @@ -23,6 +23,7 @@ Write thorough Jest unit tests that match the project's existing patterns. When **Test runner:** Jest 29 with `ts-jest`. Config in `jest.config.js`. **Where tests live:** + - Tests always go in a `tests/` subfolder **inside the same directory as the source file**. - `src/core/auth.ts` → `src/core/tests/auth.test.ts` - `src/lib/assets/asset-utils.ts` → `src/lib/assets/tests/asset-utils.test.ts` @@ -34,6 +35,7 @@ Write thorough Jest unit tests that match the project's existing patterns. When The `jest.config.js` `testMatch` is `**/src/**/tests/**/*.test.ts` — any `tests/` folder under `src/` is automatically picked up. **TypeScript path aliases** (pre-configured in `jest.config.js`): + - `core/*` → `src/core/*` - `lib/*` → `src/lib/*` - `types/*` → `src/types/*` @@ -45,14 +47,14 @@ The `jest.config.js` `testMatch` is `**/src/**/tests/**/*.test.ts` — any `test ### Standard test file scaffold ```typescript -import { ThingToTest } from '../module-name'; -import { resetState, setState, getState } from '../state'; +import { ThingToTest } from "../module-name"; +import { resetState, setState, getState } from "../state"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -65,15 +67,15 @@ Always suppress console output. Always call `resetState()` — the `state` objec ### When tests touch the filesystem ```typescript -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { setState } from '../state'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { setState } from "../state"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-test-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-test-")); }); afterAll(() => { @@ -94,16 +96,16 @@ Never write to `agility-files/` or the project root in tests. Always use `os.tmp `getApiClient()` throws unless `state.mgmtApiOptions` or `state.token` is set. To unblock a constructor or method that calls it without testing the API: ```typescript -setState({ token: 'test-token', targetGuid: 'test-guid-u' }); +setState({ token: "test-token", targetGuid: "test-guid-u" }); // This makes getApiClient() create a real (but unused) ApiClient from the SDK. // Safe — the SDK constructor just stores options, makes no network calls. ``` -For methods that actually *call* the API, mock `getApiClient`: +For methods that actually _call_ the API, mock `getApiClient`: ```typescript -jest.mock('../state', () => ({ - ...jest.requireActual('../state'), +jest.mock("../state", () => ({ + ...jest.requireActual("../state"), getApiClient: jest.fn().mockReturnValue({ contentMethods: { saveContentItem: jest.fn().mockResolvedValue({ contentID: 99 }) }, // add only the methods your test needs @@ -114,7 +116,7 @@ jest.mock('../state', () => ({ ### When testing `fileOperations` ```typescript -const ops = new fileOperations('my-guid', 'en-us'); +const ops = new fileOperations("my-guid", "en-us"); // instancePath = tmpDir/my-guid/en-us (because setState({ rootPath: tmpDir })) ``` @@ -147,6 +149,7 @@ const ops = new fileOperations('my-guid', 'en-us'); `state` is a single exported object. All functions share it. Always call `resetState()` in `beforeEach`. Key defaults after `resetState()`: + - `sourceGuid: []`, `targetGuid: []`, `locale: []` - `rootPath: 'agility-files'` - `token: null` @@ -159,6 +162,7 @@ Key defaults after `resetState()`: ### Auth URL routing (`src/core/auth.ts`) `determineBaseUrl(guid)` and `determineFetchUrl(guid)` route by GUID suffix: + - `*u` → US (`mgmt.aglty.io` / `api.aglty.io`) - `*c` → Canada, `*e` → EU, `*a` → AUS, `*d` → Dev, `*us2` → US2 - `state.local = true` → `https://localhost:5050` (management only, not fetch) @@ -171,6 +175,7 @@ Pure utility: `createBatches(items: T[], batchSize?: number): T[][]`. Default ### `fileOperations` (`src/core/fileOperations.ts`) Path layout (normal mode): + - `instancePath` = `rootPath/guid/locale` - `mappingsPath` = `rootPath/guid/mappings` - Central mapping path = `rootPath/mappings/sourceGuid-targetGuid/locale/type/mappings.json` @@ -197,7 +202,7 @@ Legacy mode (`state.legacyFolders = true`) flattens everything to `rootPath/`. - Group tests with `describe` blocks by method/behavior. Match the naming style in existing tests. - Use `it('does X when Y')` phrasing — describe behavior, not implementation. - Use `it.each` for table-driven cases (multiple valid/invalid inputs, multiple enum values, etc.). -- Never add comments that describe what the code does — only write comments when the *why* is non-obvious. +- Never add comments that describe what the code does — only write comments when the _why_ is non-obvious. - Don't import types you don't use. - Keep each test focused on one assertion or one closely related group. - After writing, always run `npm test` to confirm all tests pass before reporting done. diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml new file mode 100644 index 0000000..f0594fa --- /dev/null +++ b/.github/workflows/eslint.yml @@ -0,0 +1,28 @@ +name: ESLint + +on: + pull_request: + push: + branches: + - main + +jobs: + lint: + name: ESLint Check + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run ESLint + run: npm run lint diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 4985b9d..7cde0e4 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -48,4 +48,4 @@ jobs: npm publish --access public --tag "$PRERELEASE_TAG" else npm publish --access public - fi + fi diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..c3612cf --- /dev/null +++ b/.prettierignore @@ -0,0 +1,21 @@ +# Build output and dependencies +dist/ +node_modules/ +coverage/ + +# Sync data — never reformat customer data +agility-files/ + +# Lockfiles +package-lock.json + +# Generated logs +logs/ +*.log + +# OS / editor cruft +.DS_Store +.vscode-test/ + +# Backups +*.backup-* diff --git a/.prettierrc b/.prettierrc index 8c21236..a09f967 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,3 +1,9 @@ { - "printWidth": 120 - } \ No newline at end of file + "printWidth": 120, + "tabWidth": 2, + "semi": true, + "singleQuote": false, + "trailingComma": "es5", + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..d7df89c --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5cc2ca2 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,26 @@ +{ + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[jsonc]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[yaml]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[markdown]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "prettier.requireConfig": true, + "files.eol": "\n", + "files.insertFinalNewline": true, + "files.trimTrailingWhitespace": true +} diff --git a/README.md b/README.md index 531db36..3484f26 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ npm install -g @agility/cli **Note:** Authentication happens automatically when you run `pull` or `sync` commands. The `login` and `logout` commands are primarily used for troubleshooting authentication issues (see [Troubleshooting](#troubleshooting)). -**Required Permissions:** You must be an Org Admin, Instance Admin, or have Manager role to perform CLI operations. ---- +## **Required Permissions:** You must be an Org Admin, Instance Admin, or have Manager role to perform CLI operations. + ## Commands ### Pull Command @@ -27,36 +27,36 @@ agility pull [options] **Core Instance Options:** -| Option | Type | Default | Description | -| -------------- | ------- | ----------- | ------------------------------------------------------------------- | -| `--sourceGuid` | string | _(empty)_ | Source instance GUID (required for sync, can be from .env for pull) | -| `--locales` | string | _(empty)_ | Comma-separated list of locales to operate on. If not specified, locales are automatically pulled from the instance | +| Option | Type | Default | Description | +| -------------- | ------ | --------- | ------------------------------------------------------------------------------------------------------------------- | +| `--sourceGuid` | string | _(empty)_ | Source instance GUID (required for sync, can be from .env for pull) | +| `--locales` | string | _(empty)_ | Comma-separated list of locales to operate on. If not specified, locales are automatically pulled from the instance | **Content Selection Options:** -| Option | Type | Default | Description | -| ------------ | ------ | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | -| `--elements` | string | `Models,Galleries,Assets,Containers,Content,Templates,Pages` | Comma-separated list of elements to process | -| `--models` | string | _(empty)_ | Comma-separated list of model reference names to sync (only syncs the specified models) | +| Option | Type | Default | Description | +| ------------ | ------ | ------------------------------------------------------------ | --------------------------------------------------------------------------------------- | +| `--elements` | string | `Models,Galleries,Assets,Containers,Content,Templates,Pages` | Comma-separated list of elements to process | +| `--models` | string | _(empty)_ | Comma-separated list of model reference names to sync (only syncs the specified models) | **File System Options:** -| Option | Type | Default | Description | -| ------------ | ------- | --------------- | -------------------------------- | -| `--rootPath` | string | `agility-files` | Root directory for local files | +| Option | Type | Default | Description | +| ------------ | ------ | --------------- | ------------------------------ | +| `--rootPath` | string | `agility-files` | Root directory for local files | **Operation Control Options:** -| Option | Type | Default | Description | -| ---------- | ------- | ------- | --------------------------------------------------------------------------- | -| `--update` | boolean | `false` | Set to `true` to force updating source data | +| Option | Type | Default | Description | +| ---------- | ------- | ------- | ------------------------------------------- | +| `--update` | boolean | `false` | Set to `true` to force updating source data | **UI & Output Options:** -| Option | Type | Default | Description | -| ------------ | ------- | ------- | ---------------------------- | +| Option | Type | Default | Description | +| ------------ | ------- | ------- | --------------------------------------------------------------------- | | `--headless` | boolean | `false` | Disable logging and console/terminal output, log to file only (CI/CD) | -| `--verbose` | boolean | `true` | Detailed console output | +| `--verbose` | boolean | `true` | Detailed console output | #### Pull Examples @@ -73,7 +73,9 @@ agility pull --sourceGuid="abc123" --locales="en-us" --update=true # Pull from live environment agility pull --sourceGuid="abc123" --locales="en-us" ``` + --- + ### Sync Command Synchronize content between two Agility CMS instances with intelligent dependency resolution. @@ -86,41 +88,40 @@ agility sync [options] **Core Instance Options:** -| Option | Type | Default | Description | -| -------------- | ------- | ----------- | ------------------------------------------------------------------- | -| `--sourceGuid` | string | _(empty)_ | Source instance GUID (required for sync) | -| `--targetGuid` | string | _(empty)_ | Target instance GUID (required for sync) | -| `--locales` | string | _(empty)_ | Comma-separated list of locales to operate on. If not specified, locales are automatically pulled from the source instance. **Note:** For sync operations, if locales are not specified, the target instance must have all the same locales set up, or the sync will error. You can selectively sync only specific locales (e.g., `--locales="en-us"`) to avoid requiring all locales to be set up in the target instance | +| Option | Type | Default | Description | +| -------------- | ------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--sourceGuid` | string | _(empty)_ | Source instance GUID (required for sync) | +| `--targetGuid` | string | _(empty)_ | Target instance GUID (required for sync) | +| `--locales` | string | _(empty)_ | Comma-separated list of locales to operate on. If not specified, locales are automatically pulled from the source instance. **Note:** For sync operations, if locales are not specified, the target instance must have all the same locales set up, or the sync will error. You can selectively sync only specific locales (e.g., `--locales="en-us"`) to avoid requiring all locales to be set up in the target instance | **Content Selection Options:** -| Option | Type | Default | Description | -| ------------------ | ------ | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | -| `--elements` | string | `Models,Galleries,Assets,Containers,Content,Templates,Pages` | Comma-separated list of elements to process | -| `--models` | string | _(empty)_ | Comma-separated list of model reference names to sync (only syncs the specified models) | +| Option | Type | Default | Description | +| -------------------- | ------ | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--elements` | string | `Models,Galleries,Assets,Containers,Content,Templates,Pages` | Comma-separated list of elements to process | +| `--models` | string | _(empty)_ | Comma-separated list of model reference names to sync (only syncs the specified models) | | `--models-with-deps` | string | _(empty)_ | Comma-separated list of model reference names to sync with dependencies (includes content, assets, galleries, containers, and lists, but not pages) | **File System Options:** -| Option | Type | Default | Description | -| ------------ | ------- | --------------- | -------------------------------- | -| `--rootPath` | string | `agility-files` | Root directory for local files | +| Option | Type | Default | Description | +| ------------ | ------ | --------------- | ------------------------------ | +| `--rootPath` | string | `agility-files` | Root directory for local files | **Operation Control Options:** -| Option | Type | Default | Description | -| ---------------- | ------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `--update` | boolean | `false` | Download fresh data from source instance before operations, if left false, incremental sync is performed to only get changed data. | -| `--overwrite` | boolean | `false` | Force update existing items in target instance instead of creating new items with -1 IDs. Default: false (Warning: may cause duplicate items in lists, overwriting existing content) | -| `--autoPublish` | string | _(disabled)_ | Automatically publish synced items that were published in the source instance. Values: `content`, `pages`, `both`. If flag is provided without a value, defaults to `both`. Items that are only in staging (not published) in the source are skipped. | +| Option | Type | Default | Description | +| --------------- | ------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--update` | boolean | `false` | Download fresh data from source instance before operations, if left false, incremental sync is performed to only get changed data. | +| `--overwrite` | boolean | `false` | Force update existing items in target instance instead of creating new items with -1 IDs. Default: false (Warning: may cause duplicate items in lists, overwriting existing content) | +| `--autoPublish` | string | _(disabled)_ | Automatically publish synced items that were published in the source instance. Values: `content`, `pages`, `both`. If flag is provided without a value, defaults to `both`. Items that are only in staging (not published) in the source are skipped. | **UI & Output Options:** -| Option | Type | Default | Description | -| ------------ | ------- | ------- | ------------------------------------------------------------------------------ | -| `--headless` | boolean | `false` | Disable logging and console/terminal output, log to file only (CI/CD) | -| `--verbose` | boolean | `true` | Detailed console output | - +| Option | Type | Default | Description | +| ------------ | ------- | ------- | --------------------------------------------------------------------- | +| `--headless` | boolean | `false` | Disable logging and console/terminal output, log to file only (CI/CD) | +| `--verbose` | boolean | `true` | Detailed console output | #### Sync Examples @@ -152,7 +153,9 @@ agility sync --sourceGuid="abc123" --targetGuid="def456" --autoPublish=content # Sync and auto-publish only pages agility sync --sourceGuid="abc123" --targetGuid="def456" --autoPublish=pages ``` + --- + ## Advanced Topics ### Auto-Publish @@ -161,11 +164,11 @@ The `--autoPublish` flag lets you automatically publish synced content and/or pa #### Modes -| Value | Behavior | -| --------- | --------------------------------------------- | +| Value | Behavior | +| --------- | ------------------------------------------------------------------------------------ | | `both` | Publish both content items and pages (default when flag is provided without a value) | -| `content` | Publish only content items | -| `pages` | Publish only pages | +| `content` | Publish only content items | +| `pages` | Publish only pages | #### How It Works @@ -211,6 +214,7 @@ agility sync --sourceGuid="abc123" --targetGuid="def456" --models="BlogPost,Blog #### --models-with-deps The `--models-with-deps` parameter syncs the specified models **plus their dependencies**. It includes: + - Content items based on those models - Assets referenced by the content - Galleries referenced by the content @@ -291,10 +295,11 @@ agility-files/ ``` > **⚠️ CRITICAL WARNING: Mapping File Safety** -> +> > **If you lose your mappings, syncing again will result in duplicate content being created in the target instance.** The CLI uses mappings to identify existing content and avoid duplicates. Without mappings, it cannot determine what already exists and will create new items. -> +> > **Recommended Practices:** +> > - **Persist your mappings** through shared file storage or a repository (e.g., Git) when working on a team > - **Do not have multiple instances of the CLI syncing the same source→target instance pairs simultaneously** - this can cause mapping conflicts and duplicate content > - **Back up your `agility-files/mappings/` directory** before performing destructive operations @@ -329,21 +334,21 @@ agility-files/ For CI/CD pipelines and automation, you can configure the CLI using environment variables. Command line arguments always override environment variables when both are provided. -| Environment Variable | Command Argument | Description | -| ------------------------ | ----------------- | ----------------------------------------------- | -| `AGILITY_GUID` | `--sourceGuid` | Default source instance GUID | -| `AGILITY_TARGET_GUID` | `--targetGuid` | Default target instance GUID | -| `AGILITY_LOCALES` | `--locales` | Comma-separated list of locales to operate on | -| `AGILITY_WEBSITE` | `--channel` | Default channel name | -| `AGILITY_ELEMENTS` | `--elements` | Default elements to process | -| `AGILITY_MODELS` | `--models` | Default models to sync (comma-separated, models only) | -| `AGILITY_MODELS_WITH_DEPS` | `--models-with-deps` | Default models to sync with dependencies (comma-separated) | -| `AGILITY_ROOT_PATH` | `--rootPath` | Default root directory | -| `AGILITY_VERBOSE` | `--verbose` | Default verbose output setting | -| `AGILITY_HEADLESS` | `--headless` | Default headless mode setting | -| `AGILITY_UPDATE` | `--update` | Default fresh data setting (both pull and sync) | -| `AGILITY_OVERWRITE` | `--overwrite` | Default overwrite setting (sync only) | -| `AGILITY_TOKEN` | `--token` | Personal Access Token for headless/CI authentication (see below) | +| Environment Variable | Command Argument | Description | +| -------------------------- | -------------------- | ---------------------------------------------------------------- | +| `AGILITY_GUID` | `--sourceGuid` | Default source instance GUID | +| `AGILITY_TARGET_GUID` | `--targetGuid` | Default target instance GUID | +| `AGILITY_LOCALES` | `--locales` | Comma-separated list of locales to operate on | +| `AGILITY_WEBSITE` | `--channel` | Default channel name | +| `AGILITY_ELEMENTS` | `--elements` | Default elements to process | +| `AGILITY_MODELS` | `--models` | Default models to sync (comma-separated, models only) | +| `AGILITY_MODELS_WITH_DEPS` | `--models-with-deps` | Default models to sync with dependencies (comma-separated) | +| `AGILITY_ROOT_PATH` | `--rootPath` | Default root directory | +| `AGILITY_VERBOSE` | `--verbose` | Default verbose output setting | +| `AGILITY_HEADLESS` | `--headless` | Default headless mode setting | +| `AGILITY_UPDATE` | `--update` | Default fresh data setting (both pull and sync) | +| `AGILITY_OVERWRITE` | `--overwrite` | Default overwrite setting (sync only) | +| `AGILITY_TOKEN` | `--token` | Personal Access Token for headless/CI authentication (see below) | ### Personal Access Token (PAT) Authentication @@ -367,11 +372,13 @@ This opens a browser window to authenticate and stores your OAuth token in the s **Step 2 — Extract your OAuth token from the keychain** On macOS: + ```bash security find-generic-password -s "agility-cli" -a "cli-auth-token:prod" -w | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['access_token'])" ``` On Linux (GNOME keyring): + ```bash secret-tool lookup service agility-cli account cli-auth-token:prod | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['access_token'])" ``` @@ -380,13 +387,13 @@ secret-tool lookup service agility-cli account cli-auth-token:prod | python3 -c The URL is determined by the last character(s) of your instance GUID: -| GUID suffix | Base URL | -|---|---| -| `u` | `https://mgmt.aglty.io` | -| `c` | `https://mgmt-ca.aglty.io` | -| `e` | `https://mgmt-eu.aglty.io` | -| `a` | `https://mgmt-aus.aglty.io` | -| `us2` | `https://mgmt-usa2.aglty.io` | +| GUID suffix | Base URL | +| ----------- | ---------------------------- | +| `u` | `https://mgmt.aglty.io` | +| `c` | `https://mgmt-ca.aglty.io` | +| `e` | `https://mgmt-eu.aglty.io` | +| `a` | `https://mgmt-aus.aglty.io` | +| `us2` | `https://mgmt-usa2.aglty.io` | **Step 4 — Create the PAT** @@ -419,6 +426,7 @@ A successful `201` response looks like: **Copy the `token` value immediately — it is only returned at creation time and cannot be retrieved again.** **Constraints:** + - Maximum **10 active tokens** per user - Maximum **2-year expiry** per token - Tokens **cannot** be used to create or manage other tokens — OAuth login is required for token management diff --git a/changelog.md b/changelog.md index fbd9bb1..c27218c 100644 --- a/changelog.md +++ b/changelog.md @@ -13,78 +13,78 @@ This document tracks the major development phases and completed work on the Agil ## Phase 1: Establish Central Pull Service (`pull.ts`) - [x] **Task 1.1:** Create `src/lib/services/pull.ts`. - - [x] **Sub-task 1.1.1:** Define a `Pull` class within `pull.ts`. - - [x] **Sub-task 1.1.2:** Define a `pullInstance(guid, apiKey, locale, channel, isPreview, rootPath, options, multibar)` method in the `Pull` class. This will be the main entry point for pulling an entire instance. + - [x] **Sub-task 1.1.1:** Define a `Pull` class within `pull.ts`. + - [x] **Sub-task 1.1.2:** Define a `pullInstance(guid, apiKey, locale, channel, isPreview, rootPath, options, multibar)` method in the `Pull` class. This will be the main entry point for pulling an entire instance. - [x] **Task 1.2:** Identify core pulling logic. - - [x] **Sub-task 1.2.1:** Read `src/lib/prompts/push-prompt.ts` to understand the `downloadFiles` function's logic. (Noted: `push-prompt.ts` contains `pushFiles`, primary pull logic seems to be in `sync.ts`) - - [x] **Sub-task 1.2.2:** Read `src/index.ts` to understand its instance pulling logic. (Noted: `index.ts` orchestrates calls, `sync.ts` contains pull methods) - - [x] **Sub-task 1.2.3:** Consolidate the general structure of instance pulling (e.g., initial sync, then fetching specific items) into a high-level flow within `pullInstance`. (High-level flow defined based on `sync.ts`'s `pullFiles`, `getPages`, `getPageTemplates`) + - [x] **Sub-task 1.2.1:** Read `src/lib/prompts/push-prompt.ts` to understand the `downloadFiles` function's logic. (Noted: `push-prompt.ts` contains `pushFiles`, primary pull logic seems to be in `sync.ts`) + - [x] **Sub-task 1.2.2:** Read `src/index.ts` to understand its instance pulling logic. (Noted: `index.ts` orchestrates calls, `sync.ts` contains pull methods) + - [x] **Sub-task 1.2.3:** Consolidate the general structure of instance pulling (e.g., initial sync, then fetching specific items) into a high-level flow within `pullInstance`. (High-level flow defined based on `sync.ts`'s `pullFiles`, `getPages`, `getPageTemplates`) --- ## Phase 2: Modularize Item-Specific Download Logic - [x] **Task 2.1: Refactor `sync.ts` for Templates & Pages** - - [x] **Sub-task 2.1.1:** Create `src/lib/downloaders/download-templates.ts`. - - [x] Move `getPageTemplates` logic from `src/lib/services/sync.ts` here. - - [x] Rename/refactor it to a function like `downloadAllTemplates(guid, locale, isPreview, options, multibar, basePath)`. - - [x] Implement a check: if the target template folder is empty, then execute the download. - - [x] **Sub-task 2.1.2:** Create `src/lib/downloaders/download-pages.ts`. - - [x] Move `getPages` logic from `src/lib/services/sync.ts` here. - - [x] Rename/refactor it to a function like `downloadAllPages(guid, locale, isPreview, options, multibar, basePath)`. - - [x] Implement a check: if the target page folder is empty, then execute the download. - - [x] **Sub-task 2.1.3:** Modify `src/lib/services/sync.ts`'s `sync` method. It should still perform the `agilitySync.runSync()`. The calls to `this.getPages()` and `this.getPageTemplates()` have been removed. The `pullFiles` method has been refactored, its dependencies on getPages/Templates removed, and its file operations simplified/commented for future refactoring by the Pull service. + - [x] **Sub-task 2.1.1:** Create `src/lib/downloaders/download-templates.ts`. + - [x] Move `getPageTemplates` logic from `src/lib/services/sync.ts` here. + - [x] Rename/refactor it to a function like `downloadAllTemplates(guid, locale, isPreview, options, multibar, basePath)`. + - [x] Implement a check: if the target template folder is empty, then execute the download. + - [x] **Sub-task 2.1.2:** Create `src/lib/downloaders/download-pages.ts`. + - [x] Move `getPages` logic from `src/lib/services/sync.ts` here. + - [x] Rename/refactor it to a function like `downloadAllPages(guid, locale, isPreview, options, multibar, basePath)`. + - [x] Implement a check: if the target page folder is empty, then execute the download. + - [x] **Sub-task 2.1.3:** Modify `src/lib/services/sync.ts`'s `sync` method. It should still perform the `agilitySync.runSync()`. The calls to `this.getPages()` and `this.getPageTemplates()` have been removed. The `pullFiles` method has been refactored, its dependencies on getPages/Templates removed, and its file operations simplified/commented for future refactoring by the Pull service. - [x] **Task 2.2: Create/Update Downloaders for Assets, Containers, Content, Models** - - **Assets:** - - [x] **Sub-task 2.2.A.1:** Create `src/lib/downloaders/download-assets.ts`. (Now split into galleries and asset-files) - - [x] **Sub-task 2.2.A.2:** Reviewed `src/lib/services/assets.ts`; it contains rich logic for fetching and saving (getAssets, getGalleries). - - [x] **Sub-task 2.2.A.3:** `downloadAllAssets` uses the existing service methods from `assets.ts`. (Now split) - - [x] **Sub-task 2.2.A.4:** Implemented folder check in `downloadAllAssets` before calling service methods. (Now split) - - **Galleries (from Assets):** - - [x] **Sub-task 2.2.G.1:** Create `src/lib/downloaders/download-galleries.ts`. - - [x] **Sub-task 2.2.G.2:** Uses `AssetsService.getGalleries`. - - [x] **Sub-task 2.2.G.3:** Implemented folder check for `assets/galleries`. - - **Asset Files (from Assets):** - - [x] **Sub-task 2.2.AF.1:** Create `src/lib/downloaders/download-asset-files.ts`. - - [x] **Sub-task 2.2.AF.2:** Uses `AssetsService.getAssets`. - - [x] **Sub-task 2.2.AF.3:** Implemented folder check for `assets/json` or general asset content. - - **Containers:** - - [x] **Sub-task 2.2.C.1:** Create `src/lib/downloaders/download-containers.ts`. - - [x] **Sub-task 2.2.C.2:** Reviewed `src/lib/services/containers.ts`; it contains `getContainers` for fetching and saving. - - [x] **Sub-task 2.2.C.3:** `downloadAllContainers` uses the existing `getContainers` method from `containers.ts`. - - [x] **Sub-task 2.2.C.4:** Implemented folder check in `downloadAllContainers` before calling `getContainers`. - - **Content Items:** - - [x] **Sub-task 2.2.CI.1:** Create `src/lib/downloaders/download-content.ts`. - - [x] **Sub-task 2.2.CI.2:** Reviewed `src/lib/services/content.ts`; it lacks a "download all" method. Assumed syncSDK handles raw content file downloads. - - [x] **Sub-task 2.2.CI.3:** `downloadAllContent` checks for pre-existing content folders (e.g., `content`, `items`) populated by the main sync process. It does not make new API calls for content. - - [x] **Sub-task 2.2.CI.4:** Implemented folder check in `downloadAllContent` and reports status. - - **Models:** - - [x] **Sub-task 2.2.M.1:** Create `src/lib/downloaders/download-models.ts`. - - [x] **Sub-task 2.2.M.2:** Reviewed `src/lib/services/models.ts`; it contains `getModels` for fetching and saving content and page models. - - [x] **Sub-task 2.2.M.3:** `downloadAllModels` uses the existing `getModels` method from `models.ts`, passing `basePath` as `baseFolder`. - - [x] **Sub-task 2.2.M.4:** Implemented folder check in `downloadAllModels` before calling `getModels`. + - **Assets:** + - [x] **Sub-task 2.2.A.1:** Create `src/lib/downloaders/download-assets.ts`. (Now split into galleries and asset-files) + - [x] **Sub-task 2.2.A.2:** Reviewed `src/lib/services/assets.ts`; it contains rich logic for fetching and saving (getAssets, getGalleries). + - [x] **Sub-task 2.2.A.3:** `downloadAllAssets` uses the existing service methods from `assets.ts`. (Now split) + - [x] **Sub-task 2.2.A.4:** Implemented folder check in `downloadAllAssets` before calling service methods. (Now split) + - **Galleries (from Assets):** + - [x] **Sub-task 2.2.G.1:** Create `src/lib/downloaders/download-galleries.ts`. + - [x] **Sub-task 2.2.G.2:** Uses `AssetsService.getGalleries`. + - [x] **Sub-task 2.2.G.3:** Implemented folder check for `assets/galleries`. + - **Asset Files (from Assets):** + - [x] **Sub-task 2.2.AF.1:** Create `src/lib/downloaders/download-asset-files.ts`. + - [x] **Sub-task 2.2.AF.2:** Uses `AssetsService.getAssets`. + - [x] **Sub-task 2.2.AF.3:** Implemented folder check for `assets/json` or general asset content. + - **Containers:** + - [x] **Sub-task 2.2.C.1:** Create `src/lib/downloaders/download-containers.ts`. + - [x] **Sub-task 2.2.C.2:** Reviewed `src/lib/services/containers.ts`; it contains `getContainers` for fetching and saving. + - [x] **Sub-task 2.2.C.3:** `downloadAllContainers` uses the existing `getContainers` method from `containers.ts`. + - [x] **Sub-task 2.2.C.4:** Implemented folder check in `downloadAllContainers` before calling `getContainers`. + - **Content Items:** + - [x] **Sub-task 2.2.CI.1:** Create `src/lib/downloaders/download-content.ts`. + - [x] **Sub-task 2.2.CI.2:** Reviewed `src/lib/services/content.ts`; it lacks a "download all" method. Assumed syncSDK handles raw content file downloads. + - [x] **Sub-task 2.2.CI.3:** `downloadAllContent` checks for pre-existing content folders (e.g., `content`, `items`) populated by the main sync process. It does not make new API calls for content. + - [x] **Sub-task 2.2.CI.4:** Implemented folder check in `downloadAllContent` and reports status. + - **Models:** + - [x] **Sub-task 2.2.M.1:** Create `src/lib/downloaders/download-models.ts`. + - [x] **Sub-task 2.2.M.2:** Reviewed `src/lib/services/models.ts`; it contains `getModels` for fetching and saving content and page models. + - [x] **Sub-task 2.2.M.3:** `downloadAllModels` uses the existing `getModels` method from `models.ts`, passing `basePath` as `baseFolder`. + - [x] **Sub-task 2.2.M.4:** Implemented folder check in `downloadAllModels` before calling `getModels`. --- ## Phase 3: Integrate Downloaders into `Pull` Service - [x] **Task 3.1:** Update `pullInstance` in `src/lib/services/pull.ts`. - - [x] **Sub-task 3.1.1:** Call `agilitySync.getSyncClient(...).runSync()` as the first step. Relies on `storeInterfaceFileSystem` for correct file placement, omitting previous complex file move/delete logic from `sync.ts`. - - [x] **Sub-task 3.1.2:** After the base sync, call the respective `downloadAll[ItemType]s` functions from each of the `src/lib/downloaders/` modules. + - [x] **Sub-task 3.1.1:** Call `agilitySync.getSyncClient(...).runSync()` as the first step. Relies on `storeInterfaceFileSystem` for correct file placement, omitting previous complex file move/delete logic from `sync.ts`. + - [x] **Sub-task 3.1.2:** After the base sync, call the respective `downloadAll[ItemType]s` functions from each of the `src/lib/downloaders/` modules. --- ## Phase 4: Update Call Sites & Cleanup - [x] **Task 4.1:** Refactor `src/lib/prompts/push-prompt.ts`. - - No direct pull logic was found in `push-prompt.ts` that required replacement. It instructs the user to pull if needed. + - No direct pull logic was found in `push-prompt.ts` that required replacement. It instructs the user to pull if needed. - [x] **Task 4.2:** Refactor `src/index.ts` & other pull initiation points. - - Refactored `src/lib/prompts/pull-prompt.ts` (downloadFiles function) to use `new Pull().pullInstance()`. - - Refactored the `pull` command handler in `src/index.ts` to use `new Pull().pullInstance()`. + - Refactored `src/lib/prompts/pull-prompt.ts` (downloadFiles function) to use `new Pull().pullInstance()`. + - Refactored the `pull` command handler in `src/index.ts` to use `new Pull().pullInstance()`. - [x] **Task 4.3:** Remove redundant/old pulling logic from `sync.ts` (`getPages`, `getPageTemplates`, parts of `pullFiles` if fully superseded). - - `getPages` and `getPageTemplates` methods were removed from `sync.ts` in Phase 2. - - `sync.pullFiles()` was heavily simplified to be a thin wrapper around `sync.sync()` with a deprecation note; its complex pulling logic is superseded by the `Pull` service. + - `getPages` and `getPageTemplates` methods were removed from `sync.ts` in Phase 2. + - `sync.pullFiles()` was heavily simplified to be a thin wrapper around `sync.sync()` with a deprecation note; its complex pulling logic is superseded by the `Pull` service. --- @@ -111,50 +111,52 @@ This document tracks the major development phases and completed work on the Agil - [x] Define `ProgressCallbackType` in `src/lib/services/pull.ts`. - [x] For each `downloadAll...` function call in `pull.ts`: - - [x] Create a specific `progressCallback` instance. - - [x] Wrap the `downloadAll...` call in a `try/catch` block for granular error reporting to the UI. - - [x] Pass the `progressCallback` as the new last argument to the `downloadAll...` function. + - [x] Create a specific `progressCallback` instance. + - [x] Wrap the `downloadAll...` call in a `try/catch` block for granular error reporting to the UI. + - [x] Pass the `progressCallback` as the new last argument to the `downloadAll...` function. - **Update Downloader Signatures and Implement Callback Logic**: - - For each downloader file in `src/lib/downloaders/`: - - `download-all-templates.ts` - - [x] Modify function signature to accept `progressCallback?: ProgressCallbackType`. - - [x] Call `progressCallback` incrementally after each template is processed. - - [x] Log start, each item processed, and completion/error. - - [x] Call `progressCallback` with `(total, total, 'success')` on successful completion or `(processedAtError, total, 'error')` on error. - - `download-all-pages.ts` - - [x] Modify function signature to accept `progressCallback?: ProgressCallbackType`. - - [x] Call `progressCallback` incrementally after each page reference is processed. - - [x] Log start, each item processed, and completion/error. - - [x] Call `progressCallback` with `(total, total, 'success')` on successful completion or `(processedAtError, total, 'error')` on error. - - `download-all-galleries.ts` - - [x] Modify function signature to accept `progressCallback?: ProgressCallbackType`. - - [x] Call `progressCallback` at start (0%) and end (100% or error) of `AssetsService.getGalleries()` call. - - [x] Log start and completion/error of the overall gallery download operation. - - `download-all-assets.ts` - - [x] Modify function signature to accept `progressCallback?: ProgressCallbackType`. - - [x] Call `progressCallback` at start (0%) and end (100% or error) of `AssetsService.getAssets()` call. - - [x] Log start and completion/error of the overall asset download operation. - - `download-all-containers.ts` - - [x] Modify function signature to accept `progressCallback?: ProgressCallbackType`. - - [x] Call `progressCallback` at start (0%) and end (100% or error) of `ContainersService.getContainers()` call. - - [x] Log start and completion/error of the overall container download operation. - - `download-all-content.ts` - - [x] Modify function signature to accept `progressCallback?: ProgressCallbackType`. - - [x] Call `progressCallback` to indicate completion (this step checks for existing content, doesn't loop items). - - [x] Log the outcome of the content check. - - `download-all-models.ts` - - [x] Modify function signature to accept `progressCallback?: ProgressCallbackType`. - - [x] Call `progressCallback` at start (0%) and end (100% or error) of `ModelsService.getModels()` call. - - [x] Log start and completion/error of the overall model download operation. + - For each downloader file in `src/lib/downloaders/`: + - `download-all-templates.ts` + - [x] Modify function signature to accept `progressCallback?: ProgressCallbackType`. + - [x] Call `progressCallback` incrementally after each template is processed. + - [x] Log start, each item processed, and completion/error. + - [x] Call `progressCallback` with `(total, total, 'success')` on successful completion or `(processedAtError, total, 'error')` on error. + - `download-all-pages.ts` + - [x] Modify function signature to accept `progressCallback?: ProgressCallbackType`. + - [x] Call `progressCallback` incrementally after each page reference is processed. + - [x] Log start, each item processed, and completion/error. + - [x] Call `progressCallback` with `(total, total, 'success')` on successful completion or `(processedAtError, total, 'error')` on error. + - `download-all-galleries.ts` + - [x] Modify function signature to accept `progressCallback?: ProgressCallbackType`. + - [x] Call `progressCallback` at start (0%) and end (100% or error) of `AssetsService.getGalleries()` call. + - [x] Log start and completion/error of the overall gallery download operation. + - `download-all-assets.ts` + - [x] Modify function signature to accept `progressCallback?: ProgressCallbackType`. + - [x] Call `progressCallback` at start (0%) and end (100% or error) of `AssetsService.getAssets()` call. + - [x] Log start and completion/error of the overall asset download operation. + - `download-all-containers.ts` + - [x] Modify function signature to accept `progressCallback?: ProgressCallbackType`. + - [x] Call `progressCallback` at start (0%) and end (100% or error) of `ContainersService.getContainers()` call. + - [x] Log start and completion/error of the overall container download operation. + - `download-all-content.ts` + - [x] Modify function signature to accept `progressCallback?: ProgressCallbackType`. + - [x] Call `progressCallback` to indicate completion (this step checks for existing content, doesn't loop items). + - [x] Log the outcome of the content check. + - `download-all-models.ts` + - [x] Modify function signature to accept `progressCallback?: ProgressCallbackType`. + - [x] Call `progressCallback` at start (0%) and end (100% or error) of `ModelsService.getModels()` call. + - [x] Log start and completion/error of the overall model download operation. # Agility CLI - 2-Pass Dependency Chain Analysis System ✅ **COMPLETED** ## Project Overview ✅ + Developed a comprehensive 6-step dependency chain analysis system that provides complete visibility into entity relationships across 6,000+ Agility CMS entities, replacing the previous single-pass recursive approach with a robust analysis-first methodology. ## Implementation Results ✅ ### Core Architecture Delivered + - **✅ Universal Dependency Analyzer**: Handles all entity types (Pages, Content, Models, Templates, Containers, Assets, Galleries) - **✅ 6-Step Chain Analysis**: Complete dependency hierarchy visualization - **✅ 100% Entity Reconciliation**: All 6,043 entities tracked and accounted for @@ -165,35 +167,41 @@ Developed a comprehensive 6-step dependency chain analysis system that provides ### Analysis Framework ✅ #### Step 1: All Page Chains ✅ + - Complete page dependency hierarchies - Template → Container → Model → Content → Asset → Gallery chains - Folder page and structural page handling - Zone-based content traversal -#### Step 2: All Container Chains ✅ +#### Step 2: All Container Chains ✅ + - Containers not in page chains - Enhanced display with content/asset dependencies - Smart truncation for large content lists - Nested container relationship tracking #### Step 3: All Model-to-Model Chains ✅ + - Independent model dependency chains - Content Definition field relationship mapping - Circular reference detection - Clean model hierarchy visualization #### Step 4: Broken Chains ✅ + - Missing template identification - Source data validation - User-friendly error reporting - Actionable dependency resolution #### Step 5: Items Outside Chains ✅ + - Non-chained entity identification by type - Structural vs content-bearing classification - Standalone asset and gallery tracking #### Step 6: Reconciliation Summary ✅ + - Concise entity breakdown (1 line per type) - Clear sync readiness assessment - Broken item enumeration @@ -202,25 +210,24 @@ Developed a comprehensive 6-step dependency chain analysis system that provides ### Key Technical Achievements ✅ #### Asset Handling Resolution ✅ + ```typescript // Fixed asset matching to support all URL types -const asset = sourceEntities.assets?.find((a: any) => - a.originUrl === assetRef.url || - a.url === assetRef.url || - a.edgeUrl === assetRef.url +const asset = sourceEntities.assets?.find( + (a: any) => a.originUrl === assetRef.url || a.url === assetRef.url || a.edgeUrl === assetRef.url ); ``` #### Gallery Data Structure Fix ✅ + ```typescript // Proper gallery loading from assetMediaGroupings array -const galleryLists = loadJsonFiles('assets/galleries'); -sourceEntities.galleries = galleryLists.flatMap((galleryList: any) => - galleryList.assetMediaGroupings || [] -); +const galleryLists = loadJsonFiles("assets/galleries"); +sourceEntities.galleries = galleryLists.flatMap((galleryList: any) => galleryList.assetMediaGroupings || []); ``` #### Template Display Cleanup ✅ + ```typescript // Clean template display without redundant naming console.log(`Template:${template.pageTemplateName}`); @@ -229,16 +236,19 @@ console.log(`Template:${template.pageTemplateName}`); ### Final Output Quality ✅ The system now provides: -- **📊 Total entities: 6,046** + +- **📊 Total entities: 6,046** - **✅ Ready to sync: 5,779 items** - **⚠️ Will be skipped: 5 broken items** (missing templates) - **📈 100% entity reconciliation** across all types - **🎯 Clear actionable sync prompt** ### Broken Chain Root Cause Analysis ✅ + All broken chains traced to missing source data: + - `PageID:24 (einstants)` - Missing `RightSideBarTemplate` -- `PageID:38 (my-details)` - Missing `LeftSideBarTemplate` +- `PageID:38 (my-details)` - Missing `LeftSideBarTemplate` - `PageID:39 (messages)` - Missing `LeftSideBarTemplate` - `PageID:41 (favorites)` - Missing `LeftSideBarTemplate` - `PageID:48 (virtual-card)` - Missing `LeftSideBarTemplate` @@ -246,6 +256,7 @@ All broken chains traced to missing source data: These represent user deletions of templates, not system errors. ### Production Readiness ✅ + - **Type Safety**: Full TypeScript compliance, no `any` types - **Error Handling**: Graceful degradation for missing entities - **Performance**: Efficient analysis of 6,000+ entities @@ -257,4 +268,4 @@ This comprehensive dependency analysis system provides the foundation for reliab --- **Status**: ✅ **COMPLETED** - Production ready dependency chain analysis system -**Next Phase**: Implementation of actual 2-pass sync operations using this analysis framework \ No newline at end of file +**Next Phase**: Implementation of actual 2-pass sync operations using this analysis framework diff --git a/jest.config.js b/jest.config.js index 812d4a2..4e7cc44 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,15 +1,15 @@ module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', + preset: "ts-jest", + testEnvironment: "node", // Default: unit tests only (exclude integration tests) - testMatch: ['**/src/**/tests/**/*.test.ts'], - testPathIgnorePatterns: ['/node_modules/', '/dist/', '/src/index.ts', 'integration\\.test\\.ts'], - setupFilesAfterEnv: ['/src/tests/setup.ts'], + testMatch: ["**/src/**/tests/**/*.test.ts"], + testPathIgnorePatterns: ["/node_modules/", "/dist/", "/src/index.ts", "integration\\.test\\.ts"], + setupFilesAfterEnv: ["/src/tests/setup.ts"], // Map TypeScript path aliases to actual paths moduleNameMapper: { - '^core/(.*)$': '/src/core/$1', - '^core$': '/src/core', - '^lib/(.*)$': '/src/lib/$1', - '^types/(.*)$': '/src/types/$1', + "^core/(.*)$": "/src/core/$1", + "^core$": "/src/core", + "^lib/(.*)$": "/src/lib/$1", + "^types/(.*)$": "/src/types/$1", }, -}; \ No newline at end of file +}; diff --git a/package-lock.json b/package-lock.json index c3513b5..298b74e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agility/cli", - "version": "1.0.0-beta.13.10", + "version": "1.0.0-beta.13.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agility/cli", - "version": "1.0.0-beta.13.10", + "version": "1.0.0-beta.13.14", "license": "ISC", "dependencies": { "@agility/content-fetch": "^2.0.10", @@ -46,6 +46,7 @@ "@typescript-eslint/parser": "^8.59.4", "eslint": "^9.39.4", "jest": "^29.7.0", + "prettier": "3.8.3", "ts-jest": "^29.3.4", "ts-node": "^10.9.2", "typescript": "^5.8.3" @@ -7830,6 +7831,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", diff --git a/package.json b/package.json index 165af37..2f76a2a 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,15 @@ "main": "dist/index.js", "scripts": { "start": "node dist/index.js", - "build": "tsc -p .", - "postbuild": "chmod +x dist/index.js", + "build": "tsc -p . && node -e \"console.log('✓ TypeScript compiled to dist/')\"", + "postbuild": "node -e \"require('fs').chmodSync('dist/index.js', 0o755); console.log('✓ chmod +x dist/index.js')\"", "refresh": "rm -rf ./node_modules ./package-lock.json && npm install", "test": "jest", "test:unit": "jest", "test:integration": "jest --testMatch=\"**/*.integration.test.ts\" --testPathIgnorePatterns=\"/node_modules/|/dist/|/src/index.ts\"", - "lint": "eslint 'src/**/*.ts'", + "lint": "eslint src", + "format": "prettier --write .", + "format:check": "prettier --check .", "type-check": "tsc --noEmit", "debug": "node --inspect-brk -r ts-node/register src/index.ts" }, @@ -80,6 +82,7 @@ "@typescript-eslint/parser": "^8.59.4", "eslint": "^9.39.4", "jest": "^29.7.0", + "prettier": "3.8.3", "ts-jest": "^29.3.4", "ts-node": "^10.9.2", "typescript": "^5.8.3" diff --git a/src/core/arg-normalizer.ts b/src/core/arg-normalizer.ts index 7116a89..55f3a0c 100644 --- a/src/core/arg-normalizer.ts +++ b/src/core/arg-normalizer.ts @@ -1,6 +1,6 @@ /** * Command-line argument normalizer - * + * * Handles edge cases where rich text editors (Word, Notepad, etc.) convert * characters when copying/pasting CLI arguments: * - Em/en dashes (—, –) → double hyphen (--) @@ -13,7 +13,7 @@ */ function normalizeDashes(str: string): string { // Em dash (—) and en dash (–) → double hyphen (--) - return str.replace(/[—–]/g, '--'); + return str.replace(/[—–]/g, "--"); } /** @@ -25,22 +25,24 @@ function normalizeQuotes(str: string): string { // Use Unicode ranges to catch all quote-like characters // This is more comprehensive than listing individual characters // Also use explicit character codes as fallback - return str - // Replace all left/right double quotes (U+201C-U+201F) with straight double quote - .replace(/[\u201C-\u201F]/g, '"') - // Also explicitly match common curly quote characters (fallback) - .replace(/[""]/g, '"') // Left/right double quotes - .replace(/[„‟]/g, '"') // Double low-9 and high-reversed-9 quotes - // Replace all left/right single quotes (U+2018-U+201B) with straight single quote - .replace(/[\u2018-\u201B]/g, "'") - // Also explicitly match common curly single quotes (fallback) - .replace(/['']/g, "'") // Left/right single quotes - .replace(/[‚‛]/g, "'") // Single low-9 and high-reversed-9 quotes - // Also handle any other quote-like characters that might slip through - // Left-pointing double angle quotation mark (U+00AB) and right (U+00BB) - .replace(/[\u00AB\u00BB]/g, '"') - // Left-pointing single angle quotation mark (U+2039) and right (U+203A) - .replace(/[\u2039\u203A]/g, "'"); + return ( + str + // Replace all left/right double quotes (U+201C-U+201F) with straight double quote + .replace(/[\u201C-\u201F]/g, '"') + // Also explicitly match common curly quote characters (fallback) + .replace(/[""]/g, '"') // Left/right double quotes + .replace(/[„‟]/g, '"') // Double low-9 and high-reversed-9 quotes + // Replace all left/right single quotes (U+2018-U+201B) with straight single quote + .replace(/[\u2018-\u201B]/g, "'") + // Also explicitly match common curly single quotes (fallback) + .replace(/['']/g, "'") // Left/right single quotes + .replace(/[‚‛]/g, "'") // Single low-9 and high-reversed-9 quotes + // Also handle any other quote-like characters that might slip through + // Left-pointing double angle quotation mark (U+00AB) and right (U+00BB) + .replace(/[\u00AB\u00BB]/g, '"') + // Left-pointing single angle quotation mark (U+2039) and right (U+203A) + .replace(/[\u2039\u203A]/g, "'") + ); } /** @@ -53,16 +55,16 @@ function normalizeArg(arg: string): string { /** * Normalizes process.argv to handle rich text editor character conversions - * + * * This fixes common issues when users copy/paste CLI arguments from: * - Microsoft Word * - Notepad (Windows 11+ with smart quotes enabled) * - Other rich text editors - * + * * Modifies process.argv in-place to normalize: * - Argument names: --models-with-deps → --models-with-deps (if copied as —models-with-deps) * - Argument values: "" → "" (if copied with curly quotes) - * + * * @returns true if any normalization occurred, false otherwise */ export function normalizeProcessArgs(): boolean { @@ -86,33 +88,36 @@ export function normalizeProcessArgs(): boolean { * Normalizes a string value by removing curly quotes and dashes * Also strips leading/trailing quotes (both curly and straight) since they're * often included when copy/pasting from rich text editors. - * + * * @param value - String value to normalize * @returns Normalized string */ function normalizeStringValue(value: string): string { let normalized = normalizeQuotes(normalizeDashes(value)); - + // Strip leading/trailing quotes (both curly and straight variants) // This handles cases where the entire value is quoted: "value" or "value" // Use Unicode ranges to catch all quote variants - normalized = normalized.replace(/^[\u201C-\u201F\u2018-\u201B\u00AB\u00BB\u2039\u203A"']+|[\u201C-\u201F\u2018-\u201B\u00AB\u00BB\u2039\u203A"']+$/g, ''); - + normalized = normalized.replace( + /^[\u201C-\u201F\u2018-\u201B\u00AB\u00BB\u2039\u203A"']+|[\u201C-\u201F\u2018-\u201B\u00AB\u00BB\u2039\u203A"']+$/g, + "" + ); + return normalized; } /** * Recursively normalizes all string values in an argv object - * + * * This normalizes the entire parsed argv object from yargs, ensuring all * string arguments (sourceGuid, targetGuid, locale, models, modelsWithDeps, etc.) * are cleaned before they reach setState(). - * + * * Handles: * - String values: normalizes quotes and dashes, strips leading/trailing quotes * - String arrays: normalizes each element * - Other types: left unchanged - * + * * @param obj - The argv object (or any object) to normalize * @returns The normalized object (mutates in place, but also returns for convenience) */ @@ -122,21 +127,21 @@ export function normalizeArgv(obj: any): any { } // Handle strings - this is the most common case for argument values - if (typeof obj === 'string') { + if (typeof obj === "string") { return normalizeStringValue(obj); } // Handle arrays - normalize each element if (Array.isArray(obj)) { - return obj.map(item => normalizeArgv(item)); + return obj.map((item) => normalizeArgv(item)); } // Handle objects - recursively normalize all properties - if (typeof obj === 'object') { + if (typeof obj === "object") { // Check if it's a plain object (not Date, RegExp, etc.) if (obj.constructor !== Object && obj.constructor !== undefined) { // For non-plain objects, try to normalize if it's string-like - if (typeof obj.toString === 'function' && obj.toString() !== '[object Object]') { + if (typeof obj.toString === "function" && obj.toString() !== "[object Object]") { const str = String(obj); if (str !== obj) { // It's string-like, normalize it @@ -146,20 +151,20 @@ export function normalizeArgv(obj: any): any { // Otherwise return as-is return obj; } - + const normalized: any = {}; - + for (const key in obj) { // Skip yargs internal properties, but normalize the key itself in case it has issues - if (key === '_' || key === '$0') { + if (key === "_" || key === "$0") { normalized[key] = obj[key]; continue; } - + // Normalize the property value normalized[key] = normalizeArgv(obj[key]); } - + return normalized; } diff --git a/src/core/assets.ts b/src/core/assets.ts index 104bb00..de445f8 100644 --- a/src/core/assets.ts +++ b/src/core/assets.ts @@ -8,15 +8,15 @@ export class assets { _multibar: cliProgress.MultiBar; unProcessedAssets: { [key: number]: string }; private _fileOps: fileOperations; - private _progressCallback?: (processed: number, total: number, status?: 'success' | 'error' | 'progress') => void; + private _progressCallback?: (processed: number, total: number, status?: "success" | "error" | "progress") => void; constructor( options: mgmtApi.Options, multibar: cliProgress.MultiBar, fileOps: fileOperations, - legacyFolders:boolean = false, - progressCallback?: (processed: number, total: number, status?: 'success' | 'error' | 'progress') => void - ) { + legacyFolders: boolean = false, + progressCallback?: (processed: number, total: number, status?: "success" | "error" | "progress") => void + ) { this._options = options; this._multibar = multibar; this.unProcessedAssets = {}; @@ -28,17 +28,13 @@ export class assets { // - getGalleries -> download-galleries.ts // - getAssets -> download-assets.ts - async deleteAllGalleries(guid:string, locale: string, isPreview: boolean = true){ + async deleteAllGalleries(guid: string, locale: string, isPreview: boolean = true) { // TODO: delete all galleries let apiClient = new mgmtApi.ApiClient(this._options); const galleries = await apiClient.assetMethods.getGalleries(guid, null, 250, 0); } - async deleteAllAssets( - guid: string, - locale: string, - isPreview: boolean = true - ) { + async deleteAllAssets(guid: string, locale: string, isPreview: boolean = true) { let apiClient = new mgmtApi.ApiClient(this._options); let pageSize = 250; @@ -46,11 +42,7 @@ export class assets { let index = 1; let multiExport = false; - let initialRecords = await apiClient.assetMethods.getMediaList( - pageSize, - recordOffset, - guid - ); + let initialRecords = await apiClient.assetMethods.getMediaList(pageSize, recordOffset, guid); let totalRecords = initialRecords.totalCount; let allRecords = initialRecords.assetMedias; @@ -69,28 +61,22 @@ export class assets { progressBar.update(0, { name: "Deleting Assets" }); for (let i = 0; i < iterations; i++) { - let assets = await apiClient.assetMethods.getMediaList( - pageSize, - recordOffset, - guid - ); + let assets = await apiClient.assetMethods.getMediaList(pageSize, recordOffset, guid); allRecords = allRecords.concat(assets.assetMedias); assets.assetMedias.forEach(async (mediaItem) => { - - if(mediaItem.isFolder) { - const d = await apiClient.assetMethods.deleteFolder(mediaItem.originKey, guid, mediaItem.mediaID); - console.log('Deleted', d); + if (mediaItem.isFolder) { + const d = await apiClient.assetMethods.deleteFolder(mediaItem.originKey, guid, mediaItem.mediaID); + console.log("Deleted", d); } else { - await apiClient.assetMethods.deleteFile(mediaItem.mediaID, guid); + await apiClient.assetMethods.deleteFile(mediaItem.mediaID, guid); } progressBar.increment(); - }); recordOffset += pageSize; } - + return allRecords; } } diff --git a/src/core/auth.ts b/src/core/auth.ts index 2fc35f2..eb7588e 100644 --- a/src/core/auth.ts +++ b/src/core/auth.ts @@ -131,9 +131,9 @@ export class Auth { const env = this.getEnv(); const auth0Key = this.getEnvKey(env); const patKey = `cli-pat-token:${env}`; - + let removedAny = false; - + const kt = getKeytar(); if (!kt) { console.log(ansiColors.yellow("Keychain not available on this system. Nothing to log out.")); @@ -172,7 +172,6 @@ export class Auth { } determineBaseUrl(guid?: string): string { - let baseGUID = guid; if (!baseGUID) { baseGUID = state.sourceGuid[0]; @@ -290,7 +289,7 @@ export class Auth { } async executeGet(apiPath: string, guid: string, userBaseUrl: string = null) { - const baseUrl = this.getBaseUrl(guid) + const baseUrl = this.getBaseUrl(guid); const url = `${baseUrl}${apiPath}`; try { @@ -367,7 +366,7 @@ export class Auth { let code = await this.generateCode(); const baseUrl = this.determineBaseUrl(); - + const redirectUri = `${baseUrl}/oauth/CliAuth`; const authUrl = `${baseUrl}/oauth/Authorize?response_type=code&redirect_uri=${encodeURIComponent( redirectUri @@ -498,7 +497,9 @@ export class Auth { //Get the locales for the TARGET GUID let targetLocales: string[] = []; if (state.targetGuid.length > 0) { - targetLocales = (await state.cachedApiClient.instanceMethods.getLocales(state.targetGuid[0])).map((locale: any) => locale.localeCode); + targetLocales = (await state.cachedApiClient.instanceMethods.getLocales(state.targetGuid[0])).map( + (locale: any) => locale.localeCode + ); // Determine which locales to validate based on user input let localesToValidate: string[]; @@ -512,10 +513,14 @@ export class Auth { localesToValidate = sourceLocales; } - const missingLocales = localesToValidate.filter(locale => !targetLocales.includes(locale)); + const missingLocales = localesToValidate.filter((locale) => !targetLocales.includes(locale)); if (missingLocales.length > 0) { - const validationScope = state.locale.length > 0 ? 'specified' : 'source'; - console.log(ansiColors.yellow(`⚠️ Target instance ${state.targetGuid[0]}: Missing ${validationScope} locales ${missingLocales.join(', ')} (available: ${targetLocales.join(', ')})`)); + const validationScope = state.locale.length > 0 ? "specified" : "source"; + console.log( + ansiColors.yellow( + `⚠️ Target instance ${state.targetGuid[0]}: Missing ${validationScope} locales ${missingLocales.join(", ")} (available: ${targetLocales.join(", ")})` + ) + ); return false; // Cannot proceed with missing locales } } @@ -545,8 +550,6 @@ export class Auth { state.locale = localesToUse; // Set the state locale list to the determined locales state.guidLocaleMap = guidLocaleMap; - - } catch (error) { // If we also failed to get keys for any GUIDs, this is likely an auth/GUID problem — fail fast // This should never happen, but just in case @@ -611,7 +614,8 @@ export class Auth { } } catch (error) { throw new Error( - `${instanceType.charAt(0).toUpperCase() + instanceType.slice(1)} instance authentication failed: ${error.message + `${instanceType.charAt(0).toUpperCase() + instanceType.slice(1)} instance authentication failed: ${ + error.message }` ); } @@ -742,7 +746,7 @@ export class Auth { // Priority 1: Check if token came from --token flag or AGILITY_TOKEN env var // We need to check the ORIGINAL source, not state.token which Auth0 also populates const userProvidedToken = await this.getUserProvidedToken(); - + if (userProvidedToken && userProvidedToken.trim().length > 0) { // Validate PAT format (basic check) if (await this.validatePersonalAccessToken(userProvidedToken)) { @@ -756,12 +760,12 @@ export class Auth { // Priority 2: Check for PAT stored in keytar from previous session const env = this.getEnv(); const patKey = `cli-pat-token:${env}`; - + try { const kt = getKeytar(); if (kt) { const storedPAT = await kt.getPassword(SERVICE_NAME, patKey); - if (storedPAT && await this.validatePersonalAccessToken(storedPAT)) { + if (storedPAT && (await this.validatePersonalAccessToken(storedPAT))) { return storedPAT; } } @@ -779,18 +783,18 @@ export class Auth { private async getUserProvidedToken(): Promise { // Priority 1: Check if token was provided via command line argument const args = process.argv; - + // Handle both --token=value and --token value formats for (let i = 0; i < args.length; i++) { const arg = args[i]; - + // Format: --token=value - if (arg.startsWith('--token=')) { - return arg.substring('--token='.length); + if (arg.startsWith("--token=")) { + return arg.substring("--token=".length); } - + // Format: --token value - if (arg === '--token' && i + 1 < args.length) { + if (arg === "--token" && i + 1 < args.length) { return args[i + 1]; } } @@ -813,7 +817,9 @@ export class Auth { const kt = getKeytar(); if (!kt) { - throw new Error(`❌ Keychain not available on this system. Use --token or set the AGILITY_TOKEN environment variable.`); + throw new Error( + `❌ Keychain not available on this system. Use --token or set the AGILITY_TOKEN environment variable.` + ); } const tokenRaw = await kt.getPassword(SERVICE_NAME, key); @@ -872,7 +878,7 @@ export class Auth { if (userProvidedToken && userProvidedToken.trim().length > 0) { const env = this.getEnv(); const patKey = `cli-pat-token:${env}`; - + try { const kt = getKeytar(); if (kt) await kt.setPassword(SERVICE_NAME, patKey, userProvidedToken); @@ -920,9 +926,7 @@ export class Auth { } const textResponse = await response.text(); - return textResponse.startsWith('"') && textResponse.endsWith('"') - ? textResponse.slice(1, -1) - : textResponse; + return textResponse.startsWith('"') && textResponse.endsWith('"') ? textResponse.slice(1, -1) : textResponse; } catch (err) { throw err; } @@ -949,9 +953,7 @@ export class Auth { } const textResponse = await response.text(); - return textResponse.startsWith('"') && textResponse.endsWith('"') - ? textResponse.slice(1, -1) - : textResponse; + return textResponse.startsWith('"') && textResponse.endsWith('"') ? textResponse.slice(1, -1) : textResponse; } catch (err) { throw err; } @@ -1078,7 +1080,8 @@ export class Auth { // Both push and sync require source and target GUIDs if (!state.sourceGuid || state.sourceGuid.length === 0) missingFields.push("sourceGuid (use --sourceGuid or AGILITY_GUID in .env)"); - if (!state.targetGuid || state.targetGuid.length === 0) missingFields.push("targetGuid (use --targetGuid or AGILITY_TARGET_GUID in .env)"); + if (!state.targetGuid || state.targetGuid.length === 0) + missingFields.push("targetGuid (use --targetGuid or AGILITY_TARGET_GUID in .env)"); // Check for locales: either user-specified OR auto-detected per-GUID mappings const hasSyncUserLocales = state.locale && state.locale.length > 0; diff --git a/src/core/batch-workflows.ts b/src/core/batch-workflows.ts index bfbcd67..7a1bc36 100644 --- a/src/core/batch-workflows.ts +++ b/src/core/batch-workflows.ts @@ -1,23 +1,23 @@ /** * Batch Workflows Core Service - * + * * Core batch workflow operations using the SDK's * BatchWorkflowContent and BatchWorkflowPages methods. - * + * * Supports: Publish, Unpublish, Approve, Decline, RequestApproval */ -import { state, getApiClient } from './state'; -import ansiColors from 'ansi-colors'; -import { WorkflowOperationType, BatchWorkflowResult } from '../types'; -import { getOperationName } from '../lib/workflows/workflow-helpers'; +import { state, getApiClient } from "./state"; +import ansiColors from "ansi-colors"; +import { WorkflowOperationType, BatchWorkflowResult } from "../types"; +import { getOperationName } from "../lib/workflows/workflow-helpers"; // Re-export types for convenience export { WorkflowOperationType, BatchWorkflowResult }; // Re-export helpers from workflows folder -export { getOperationName, getOperationVerb, getOperationIcon } from '../lib/workflows/workflow-helpers'; -export { parseWorkflowOptions, parseOperationType } from '../lib/workflows/workflow-options'; +export { getOperationName, getOperationVerb, getOperationIcon } from "../lib/workflows/workflow-helpers"; +export { parseWorkflowOptions, parseOperationType } from "../lib/workflows/workflow-options"; /** * Batch size for processing - smaller batches for workflow operations @@ -40,105 +40,102 @@ const BATCH_POLL_INTERVAL_MS = 3000; /** * Run a promise with progress indicator logging */ -async function withProgressIndicator( - promise: Promise, - label: string -): Promise { - let dotCount = 0; - const startTime = Date.now(); - - // Log progress dots while waiting - const progressInterval = setInterval(() => { - dotCount++; - const elapsed = Math.round((Date.now() - startTime) / 1000); - process.stdout.write(`.`); - // Every 10 dots (20 seconds), log elapsed time - if (dotCount % 10 === 0) { - process.stdout.write(` (${elapsed}s)`); - } - }, PROGRESS_INTERVAL_MS); +async function withProgressIndicator(promise: Promise, label: string): Promise { + let dotCount = 0; + const startTime = Date.now(); - try { - const result = await promise; - clearInterval(progressInterval); - if (dotCount > 0) { - console.log(''); // New line after dots - } - return result; - } catch (error) { - clearInterval(progressInterval); - if (dotCount > 0) { - console.log(''); // New line after dots - } - throw error; + // Log progress dots while waiting + const progressInterval = setInterval(() => { + dotCount++; + const elapsed = Math.round((Date.now() - startTime) / 1000); + process.stdout.write(`.`); + // Every 10 dots (20 seconds), log elapsed time + if (dotCount % 10 === 0) { + process.stdout.write(` (${elapsed}s)`); } + }, PROGRESS_INTERVAL_MS); + + try { + const result = await promise; + clearInterval(progressInterval); + if (dotCount > 0) { + console.log(""); // New line after dots + } + return result; + } catch (error) { + clearInterval(progressInterval); + if (dotCount > 0) { + console.log(""); // New line after dots + } + throw error; + } } /** * Extract detailed error message from various error formats */ function extractErrorDetails(error: any): string { - // Check for nested error structures (common in SDK exceptions) - if (error.innerError) { - return extractErrorDetails(error.innerError); - } - - // Check for response data from API - if (error.response?.data) { - if (typeof error.response.data === 'string') { - return error.response.data; - } - if (error.response.data.message) { - return error.response.data.message; - } - if (error.response.data.error) { - return error.response.data.error; - } - return JSON.stringify(error.response.data); + // Check for nested error structures (common in SDK exceptions) + if (error.innerError) { + return extractErrorDetails(error.innerError); + } + + // Check for response data from API + if (error.response?.data) { + if (typeof error.response.data === "string") { + return error.response.data; } - - // Check for status code - if (error.response?.status) { - return `HTTP ${error.response.status}: ${error.response.statusText || 'Unknown error'}`; + if (error.response.data.message) { + return error.response.data.message; } - - // Check for message property - if (error.message) { - return error.message; + if (error.response.data.error) { + return error.response.data.error; } - - // Fallback - return String(error) || 'Unknown workflow error'; + return JSON.stringify(error.response.data); + } + + // Check for status code + if (error.response?.status) { + return `HTTP ${error.response.status}: ${error.response.statusText || "Unknown error"}`; + } + + // Check for message property + if (error.message) { + return error.message; + } + + // Fallback + return String(error) || "Unknown workflow error"; } /** * Parse partial success from error data JSON * Returns {successCount, failureCount, totalItems} if parseable, null otherwise */ -function parsePartialSuccessFromError(errorMessage: string): { - successCount: number; - failureCount: number; - totalItems: number; - failedItems?: any[]; +function parsePartialSuccessFromError(errorMessage: string): { + successCount: number; + failureCount: number; + totalItems: number; + failedItems?: any[]; } | null { - try { - // Try to extract JSON from error message - const jsonMatch = errorMessage.match(/\{[\s\S]*\}/); - if (jsonMatch) { - const errorData = JSON.parse(jsonMatch[0]); - if (typeof errorData.successCount === 'number' && typeof errorData.failureCount === 'number') { - return { - successCount: errorData.successCount, - failureCount: errorData.failureCount, - totalItems: errorData.totalItems || errorData.successCount + errorData.failureCount, - failedItems: errorData.failedItems - }; - } - } - } catch { - // Not parseable as JSON with partial success info + try { + // Try to extract JSON from error message + const jsonMatch = errorMessage.match(/\{[\s\S]*\}/); + if (jsonMatch) { + const errorData = JSON.parse(jsonMatch[0]); + if (typeof errorData.successCount === "number" && typeof errorData.failureCount === "number") { + return { + successCount: errorData.successCount, + failureCount: errorData.failureCount, + totalItems: errorData.totalItems || errorData.successCount + errorData.failureCount, + failedItems: errorData.failedItems, + }; + } } - return null; + } catch { + // Not parseable as JSON with partial success info + } + return null; } /** @@ -149,131 +146,138 @@ function parsePartialSuccessFromError(errorMessage: string): { * Create a simple progress bar string */ function createProgressBar(percent: number, width: number = 20): string { - const filled = Math.round((percent / 100) * width); - const empty = width - filled; - return `[${'█'.repeat(filled)}${'░'.repeat(empty)}]`; + const filled = Math.round((percent / 100) * width); + const empty = width - filled; + return `[${"█".repeat(filled)}${"░".repeat(empty)}]`; } async function pollBatchWorkflow( - batchID: number, - targetGuid: string, - type: 'content' | 'pages', - totalItems: number, // Total items in this batch for progress display - maxRetries: number = BATCH_POLL_MAX_RETRIES, - intervalMs: number = BATCH_POLL_INTERVAL_MS -): Promise<{ - success: boolean; - processedIds: number[]; - partialSuccess?: { successCount: number; failureCount: number; failedItems?: any[] }; - error?: string; + batchID: number, + targetGuid: string, + type: "content" | "pages", + totalItems: number, // Total items in this batch for progress display + maxRetries: number = BATCH_POLL_MAX_RETRIES, + intervalMs: number = BATCH_POLL_INTERVAL_MS +): Promise<{ + success: boolean; + processedIds: number[]; + partialSuccess?: { successCount: number; failureCount: number; failedItems?: any[] }; + error?: string; }> { - const apiClient = getApiClient(); - let retryCount = 0; - let lastBatchState = -1; - const startTime = Date.now(); - - while (retryCount < maxRetries) { - try { - const batch = await apiClient.batchMethods.getBatch(batchID, targetGuid); - const batchType = type === 'content' ? 'Content' : 'Page'; - // BatchState.Processed = 3 - if (batch.batchState === 3) { - // Clear the progress line and show completion - const elapsed = Math.round((Date.now() - startTime) / 1000); - process.stdout.write(ansiColors.yellow.dim(`\r${batchType} batch ${batchID}: ${createProgressBar(100)} ${totalItems}/${totalItems} (${elapsed}s)\n`)); - - // Batch completed - check for errors in errorData - if (batch.errorData && batch.errorData.length > 0) { - // Try to parse partial success from error data - const partialSuccess = parsePartialSuccessFromError(batch.errorData); - - if (partialSuccess && partialSuccess.successCount > 0) { - // Partial success - some items succeeded - const processedIds: number[] = []; - if (batch.items && Array.isArray(batch.items)) { - batch.items.forEach((item: any) => { - if (item.itemID > 0 && !item.errorMessage) { - processedIds.push(item.itemID); - } - }); - } - - return { - success: true, // Treat as success since some items were processed - processedIds, - partialSuccess: { - successCount: partialSuccess.successCount, - failureCount: partialSuccess.failureCount, - failedItems: partialSuccess.failedItems - } - }; - } - - // Full failure - return { - success: false, - processedIds: [], - error: `Batch ${batchID} completed with errors: ${batch.errorData}` - }; - } - - // Success - extract processed IDs - const processedIds: number[] = []; - if (batch.items && Array.isArray(batch.items)) { - batch.items.forEach((item: any) => { - if (item.itemID > 0) { - processedIds.push(item.itemID); - } - }); + const apiClient = getApiClient(); + let retryCount = 0; + let lastBatchState = -1; + const startTime = Date.now(); + + while (retryCount < maxRetries) { + try { + const batch = await apiClient.batchMethods.getBatch(batchID, targetGuid); + const batchType = type === "content" ? "Content" : "Page"; + // BatchState.Processed = 3 + if (batch.batchState === 3) { + // Clear the progress line and show completion + const elapsed = Math.round((Date.now() - startTime) / 1000); + process.stdout.write( + ansiColors.yellow.dim( + `\r${batchType} batch ${batchID}: ${createProgressBar(100)} ${totalItems}/${totalItems} (${elapsed}s)\n` + ) + ); + + // Batch completed - check for errors in errorData + if (batch.errorData && batch.errorData.length > 0) { + // Try to parse partial success from error data + const partialSuccess = parsePartialSuccessFromError(batch.errorData); + + if (partialSuccess && partialSuccess.successCount > 0) { + // Partial success - some items succeeded + const processedIds: number[] = []; + if (batch.items && Array.isArray(batch.items)) { + batch.items.forEach((item: any) => { + if (item.itemID > 0 && !item.errorMessage) { + processedIds.push(item.itemID); } - - return { success: true, processedIds }; + }); } - - // Still processing - show progress with numItemsProcessed - const numProcessed = typeof batch.numItemsProcessed === 'number' ? batch.numItemsProcessed : 0; - const percentComplete = totalItems > 0 ? Math.round((numProcessed / totalItems) * 100) : 0; - const elapsed = Math.round((Date.now() - startTime) / 1000); - process.stdout.write(ansiColors.yellow.dim(`\r${batchType} batch ${batchID}: ${createProgressBar(percentComplete)} ${numProcessed}/${totalItems} (${elapsed}s) `)); - - if (batch.batchState !== lastBatchState) { - lastBatchState = batch.batchState; - } - - retryCount++; - await new Promise(resolve => setTimeout(resolve, intervalMs)); - - } catch (pollError: any) { - // Network error during polling - retry - retryCount++; - if (retryCount >= maxRetries) { - return { - success: false, - processedIds: [], - error: `Batch ${batchID} polling failed after ${maxRetries} retries: ${pollError.message}` - }; + + return { + success: true, // Treat as success since some items were processed + processedIds, + partialSuccess: { + successCount: partialSuccess.successCount, + failureCount: partialSuccess.failureCount, + failedItems: partialSuccess.failedItems, + }, + }; + } + + // Full failure + return { + success: false, + processedIds: [], + error: `Batch ${batchID} completed with errors: ${batch.errorData}`, + }; + } + + // Success - extract processed IDs + const processedIds: number[] = []; + if (batch.items && Array.isArray(batch.items)) { + batch.items.forEach((item: any) => { + if (item.itemID > 0) { + processedIds.push(item.itemID); } - await new Promise(resolve => setTimeout(resolve, intervalMs)); + }); } + + return { success: true, processedIds }; + } + + // Still processing - show progress with numItemsProcessed + const numProcessed = typeof batch.numItemsProcessed === "number" ? batch.numItemsProcessed : 0; + const percentComplete = totalItems > 0 ? Math.round((numProcessed / totalItems) * 100) : 0; + const elapsed = Math.round((Date.now() - startTime) / 1000); + process.stdout.write( + ansiColors.yellow.dim( + `\r${batchType} batch ${batchID}: ${createProgressBar(percentComplete)} ${numProcessed}/${totalItems} (${elapsed}s) ` + ) + ); + + if (batch.batchState !== lastBatchState) { + lastBatchState = batch.batchState; + } + + retryCount++; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } catch (pollError: any) { + // Network error during polling - retry + retryCount++; + if (retryCount >= maxRetries) { + return { + success: false, + processedIds: [], + error: `Batch ${batchID} polling failed after ${maxRetries} retries: ${pollError.message}`, + }; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); } - - // Timeout - return { - success: false, - processedIds: [], - error: `Batch ${batchID} timed out after ${Math.round(maxRetries * intervalMs / 60000)} minutes. Please check the Batches page in the Agility Content Manager app.` - }; + } + + // Timeout + return { + success: false, + processedIds: [], + error: `Batch ${batchID} timed out after ${Math.round((maxRetries * intervalMs) / 60000)} minutes. Please check the Batches page in the Agility Content Manager app.`, + }; } /** * Item type for batch workflow operations */ -export type BatchItemType = 'content' | 'pages'; +export type BatchItemType = "content" | "pages"; /** * Unified batch workflow operation for content items or pages * Uses custom polling with batch ID tracking for better error messages - * + * * @param ids - Array of IDs to process * @param locale - Target locale * @param operation - Workflow operation type @@ -281,121 +285,128 @@ export type BatchItemType = 'content' | 'pages'; * @returns Promise with batch result */ export async function batchWorkflow( - ids: number[], - locale: string, - operation: WorkflowOperationType, - type: BatchItemType + ids: number[], + locale: string, + operation: WorkflowOperationType, + type: BatchItemType ): Promise { - const label = type === 'content' ? 'content items' : 'pages'; - - try { - const apiClient = getApiClient(); - const targetGuid = state.targetGuid; + const label = type === "content" ? "content items" : "pages"; - if (!apiClient) { - throw new Error('API client not available in state'); - } - if (!targetGuid || targetGuid.length === 0) { - throw new Error('Target GUID not available in state'); - } - if (!locale) { - throw new Error('Locale not available in state'); - } - if (!ids || ids.length === 0) { - throw new Error(`${label} IDs array is empty`); - } + try { + const apiClient = getApiClient(); + const targetGuid = state.targetGuid; - // Call appropriate SDK method with returnBatchId=true for custom polling - // Get batch ID immediately using returnBatchId=true - const batchIdResult = type === 'content' - ? await apiClient.contentMethods.batchWorkflowContent(ids, targetGuid[0], locale, operation, true) - : await apiClient.pageMethods.batchWorkflowPages(ids, targetGuid[0], locale, operation, true); - - const batchID = Array.isArray(batchIdResult) ? batchIdResult[0] : batchIdResult; - - if (!batchID || batchID <= 0) { - throw new Error(`Failed to create batch for ${label}`); - } - - // Custom polling with batch ID tracking and progress display - const pollResult = await pollBatchWorkflow(batchID, targetGuid[0], type, ids.length); - - if (pollResult.success) { - // Handle partial success - if (pollResult.partialSuccess) { - const { successCount, failureCount, failedItems } = pollResult.partialSuccess; - console.log(ansiColors.yellow(`\n ⚠️ Batch ${batchID} completed with errors: ${successCount}/${successCount + failureCount} items succeeded`)); - - // Log failed items details if available - if (failedItems && failedItems.length > 0 && state.verbose) { - failedItems.forEach((item: any) => { - console.log(ansiColors.red(` - Item ${item.itemId}: ${item.errorMessage || 'Unknown error'}`)); - }); - } - - return { - success: true, // Partial success is still success - processedIds: pollResult.processedIds, - failedCount: failureCount, - batchId: batchID, - partialSuccess: { - successCount, - failureCount, - batchId: batchID - } - }; - } - - return { - success: true, - processedIds: pollResult.processedIds, - failedCount: 0, - batchId: batchID - }; - } else { - // Full failure - console.error(ansiColors.red(`\n ❌ ${pollResult.error}`)); - return { - success: false, - processedIds: [], - failedCount: ids.length, - batchId: batchID, - error: pollResult.error - }; - } - } catch (error: any) { - // Log the full error for debugging - const errorDetails = extractErrorDetails(error); - console.error(ansiColors.red(`\n ❌ Batch ${type} workflow failed: ${errorDetails}`)); - - // Log additional error context if available - if (error.response) { - console.error(ansiColors.gray(` Status: ${error.response.status}`)); - if (error.response.data) { - console.error(ansiColors.gray(` Response: ${JSON.stringify(error.response.data, null, 2).substring(0, 500)}`)); - } - } - if (error.stack && state.verbose) { - console.error(ansiColors.gray(` Stack: ${error.stack}`)); + if (!apiClient) { + throw new Error("API client not available in state"); + } + if (!targetGuid || targetGuid.length === 0) { + throw new Error("Target GUID not available in state"); + } + if (!locale) { + throw new Error("Locale not available in state"); + } + if (!ids || ids.length === 0) { + throw new Error(`${label} IDs array is empty`); + } + + // Call appropriate SDK method with returnBatchId=true for custom polling + // Get batch ID immediately using returnBatchId=true + const batchIdResult = + type === "content" + ? await apiClient.contentMethods.batchWorkflowContent(ids, targetGuid[0], locale, operation, true) + : await apiClient.pageMethods.batchWorkflowPages(ids, targetGuid[0], locale, operation, true); + + const batchID = Array.isArray(batchIdResult) ? batchIdResult[0] : batchIdResult; + + if (!batchID || batchID <= 0) { + throw new Error(`Failed to create batch for ${label}`); + } + + // Custom polling with batch ID tracking and progress display + const pollResult = await pollBatchWorkflow(batchID, targetGuid[0], type, ids.length); + + if (pollResult.success) { + // Handle partial success + if (pollResult.partialSuccess) { + const { successCount, failureCount, failedItems } = pollResult.partialSuccess; + console.log( + ansiColors.yellow( + `\n ⚠️ Batch ${batchID} completed with errors: ${successCount}/${successCount + failureCount} items succeeded` + ) + ); + + // Log failed items details if available + if (failedItems && failedItems.length > 0 && state.verbose) { + failedItems.forEach((item: any) => { + console.log(ansiColors.red(` - Item ${item.itemId}: ${item.errorMessage || "Unknown error"}`)); + }); } - + return { - success: false, - processedIds: [], - failedCount: ids.length, - error: errorDetails - // No batchId here since we may have failed before getting one + success: true, // Partial success is still success + processedIds: pollResult.processedIds, + failedCount: failureCount, + batchId: batchID, + partialSuccess: { + successCount, + failureCount, + batchId: batchID, + }, }; + } + + return { + success: true, + processedIds: pollResult.processedIds, + failedCount: 0, + batchId: batchID, + }; + } else { + // Full failure + console.error(ansiColors.red(`\n ❌ ${pollResult.error}`)); + return { + success: false, + processedIds: [], + failedCount: ids.length, + batchId: batchID, + error: pollResult.error, + }; + } + } catch (error: any) { + // Log the full error for debugging + const errorDetails = extractErrorDetails(error); + console.error(ansiColors.red(`\n ❌ Batch ${type} workflow failed: ${errorDetails}`)); + + // Log additional error context if available + if (error.response) { + console.error(ansiColors.gray(` Status: ${error.response.status}`)); + if (error.response.data) { + console.error( + ansiColors.gray(` Response: ${JSON.stringify(error.response.data, null, 2).substring(0, 500)}`) + ); + } } + if (error.stack && state.verbose) { + console.error(ansiColors.gray(` Stack: ${error.stack}`)); + } + + return { + success: false, + processedIds: [], + failedCount: ids.length, + error: errorDetails, + // No batchId here since we may have failed before getting one + }; + } } /** * Create batches of items for processing */ export function createBatches(items: T[], batchSize: number = BATCH_SIZE): T[][] { - const batches: T[][] = []; - for (let i = 0; i < items.length; i += batchSize) { - batches.push(items.slice(i, i + batchSize)); - } - return batches; + const batches: T[][] = []; + for (let i = 0; i < items.length; i += batchSize) { + batches.push(items.slice(i, i + batchSize)); + } + return batches; } diff --git a/src/core/content.ts b/src/core/content.ts index 3203dad..206e7a7 100644 --- a/src/core/content.ts +++ b/src/core/content.ts @@ -16,7 +16,7 @@ export class content { this._multibar = multibar; this._guid = guid; this._locale = locale; - this._rootPath = 'agility-files'; + this._rootPath = "agility-files"; this._isPreview = true; this.skippedContentItems = {}; } @@ -153,6 +153,8 @@ export class content { } camelize(str: string) { - return str.replace(/(?:^\w|[A-Z]|\b\w)/g, (char, index) => (index === 0 ? char.toLowerCase() : char)).replace(/[_\s]+/g, ''); + return str + .replace(/(?:^\w|[A-Z]|\b\w)/g, (char, index) => (index === 0 ? char.toLowerCase() : char)) + .replace(/[_\s]+/g, ""); } } diff --git a/src/core/fileOperations.ts b/src/core/fileOperations.ts index 906a3d5..133febc 100644 --- a/src/core/fileOperations.ts +++ b/src/core/fileOperations.ts @@ -1,12 +1,11 @@ -import * as fs from 'fs'; -import * as Https from 'https'; -import * as path from 'path'; -const os = require('os'); -import { state } from './state'; +import * as fs from "fs"; +import * as Https from "https"; +import * as path from "path"; +const os = require("os"); +import { state } from "./state"; os.tmpDir = os.tmpdir; export class fileOperations { - private _rootPath: string; private _guid: string; private _locale: string; @@ -21,7 +20,7 @@ export class fileOperations { constructor(guid: string, locale?: string) { this._rootPath = state.rootPath; this._guid = guid; - this._isGuidLevel = locale === undefined || locale === null || locale === "" + this._isGuidLevel = locale === undefined || locale === null || locale === ""; this._locale = locale ?? ""; this._legacyFolders = state.legacyFolders; @@ -33,16 +32,18 @@ export class fileOperations { if (state.legacyFolders) { // Legacy mode: flat structure this._basePath = this._resolvedRootPath; - this._mappingsPath = path.join(this._resolvedRootPath, 'mappings'); - this._instanceLogDir = path.join(this._resolvedRootPath, 'logs'); + this._mappingsPath = path.join(this._resolvedRootPath, "mappings"); + this._instanceLogDir = path.join(this._resolvedRootPath, "logs"); } else { // Normal mode: nested structure - this._basePath = this._isGuidLevel ? path.join(this._resolvedRootPath, this._guid) : path.join(this._resolvedRootPath, this._guid, this._locale); - this._mappingsPath = path.join(this._resolvedRootPath, this._guid, 'mappings'); - this._instanceLogDir = path.join(this._basePath, 'logs'); + this._basePath = this._isGuidLevel + ? path.join(this._resolvedRootPath, this._guid) + : path.join(this._resolvedRootPath, this._guid, this._locale); + this._mappingsPath = path.join(this._resolvedRootPath, this._guid, "mappings"); + this._instanceLogDir = path.join(this._basePath, "logs"); } - this._currentLogFilePath = path.join(this._instanceLogDir, 'instancelog.txt'); + this._currentLogFilePath = path.join(this._instanceLogDir, "instancelog.txt"); } // Public getters for path access @@ -78,10 +79,10 @@ export class fileOperations { */ private stripAnsiCodes(text: string): string { // eslint-disable-next-line no-control-regex - let cleaned = text.replace(/\x1b\[[0-9;]*m/g, ''); + let cleaned = text.replace(/\x1b\[[0-9;]*m/g, ""); // Clean up JSON formatting: replace \n with actual newlines for better readability - cleaned = cleaned.replace(/\\n/g, '\n'); + cleaned = cleaned.replace(/\\n/g, "\n"); // Remove unnecessary escaped quotes in JSON context cleaned = cleaned.replace(/\\"/g, '"'); @@ -97,57 +98,61 @@ export class fileOperations { if (obj === null || obj === undefined) { return obj; } - - if (typeof obj !== 'object') { + + if (typeof obj !== "object") { return obj; } - + // Handle arrays if (Array.isArray(obj)) { - return obj.map(item => this.sanitizeForJson(item)); + return obj.map((item) => this.sanitizeForJson(item)); } - + // Skip known non-serializable types const constructorName = obj.constructor?.name; - if (constructorName === 'Agent' || - constructorName === 'ClientRequest' || - constructorName === 'IncomingMessage' || - constructorName === 'Socket' || - constructorName === 'TLSSocket') { + if ( + constructorName === "Agent" || + constructorName === "ClientRequest" || + constructorName === "IncomingMessage" || + constructorName === "Socket" || + constructorName === "TLSSocket" + ) { return undefined; } - + // Create a clean copy of the object const cleanObj: any = {}; for (const key of Object.keys(obj)) { // Skip properties that are likely to contain circular references - if (key === 'agent' || - key === '_httpMessage' || - key === 'socket' || - key === 'connection' || - key === 'request' || - key === 'response' || - key === '_events' || - key === '_eventsCount' || - key === 'httpsAgent' || - key === 'httpAgent') { + if ( + key === "agent" || + key === "_httpMessage" || + key === "socket" || + key === "connection" || + key === "request" || + key === "response" || + key === "_events" || + key === "_eventsCount" || + key === "httpsAgent" || + key === "httpAgent" + ) { continue; } - + const value = obj[key]; - + // Skip functions - if (typeof value === 'function') { + if (typeof value === "function") { continue; } - + // Recursively sanitize nested objects const sanitizedValue = this.sanitizeForJson(value); if (sanitizedValue !== undefined) { cleanObj[key] = sanitizedValue; } } - + return cleanObj; } @@ -178,7 +183,7 @@ export class fileOperations { } const fileName = path.join(directoryForFile, `${fileIdentifier}.json`); - + // Sanitize the object to remove non-serializable properties (like HTTPS Agents) // This prevents "Converting circular structure to JSON" errors with --local mode const sanitizedObject = this.sanitizeForJson(extractedObject); @@ -196,7 +201,7 @@ export class fileOperations { } createLogFile(folder: string, fileIdentifier: any, baseFolder?: string) { - if (baseFolder === undefined || baseFolder === '') { + if (baseFolder === undefined || baseFolder === "") { baseFolder = this._basePath; } if (!fs.existsSync(`${baseFolder}`)) { @@ -206,7 +211,7 @@ export class fileOperations { fs.mkdirSync(`${baseFolder}/${folder}`); } let fileName = `${baseFolder}/${folder}/${fileIdentifier}.txt`; - fs.closeSync(fs.openSync(fileName, 'w')) + fs.closeSync(fs.openSync(fileName, "w")); } appendLogFile(data: string) { @@ -234,7 +239,7 @@ export class fileOperations { const segments = normalizedPath.split(path.sep); // Start from the root and create each directory - let currentPath = ''; + let currentPath = ""; for (const segment of segments) { currentPath = path.join(currentPath, segment); @@ -258,13 +263,13 @@ export class fileOperations { return false; } } catch (error) { - console.error('Error in createFolder:', error); + console.error("Error in createFolder:", error); return false; } } createBaseFolder(folder?: string) { - if (folder === undefined || folder === '') { + if (folder === undefined || folder === "") { folder = this._basePath; } if (!fs.existsSync(folder)) { @@ -292,7 +297,7 @@ export class fileOperations { fs.mkdirSync(targetDir, { recursive: true }); } - Https.get(url, response => { + Https.get(url, (response) => { const code = response.statusCode ?? 0; if (code >= 400) { @@ -300,22 +305,20 @@ export class fileOperations { } if (code > 300 && code < 400 && !!response.headers.location) { - return resolve( - this.downloadFile(response.headers.location, targetFile) - ); + return resolve(this.downloadFile(response.headers.location, targetFile)); } const fileWriter = fs .createWriteStream(targetFile) - .on('finish', () => { + .on("finish", () => { resolve({}); }) - .on('error', (err) => { + .on("error", (err) => { reject(err); }); response.pipe(fileWriter); - }).on('error', error => { + }).on("error", (error) => { console.error(`Error downloading from ${url}:`, error); reject(error); }); @@ -359,30 +362,38 @@ export class fileOperations { // Mapping file operations getMappingFilePath(sourceGuid: string, targetGuid: string, locale?: string | null): string { // Store mappings centrally in /agility-files/mappings/ instead of per-instance - return path.join(this._rootPath, 'mappings', `${sourceGuid}-${targetGuid}`, locale ?? ''); + return path.join(this._rootPath, "mappings", `${sourceGuid}-${targetGuid}`, locale ?? ""); } getMappingFile(type: string, sourceGuid: string, targetGuid: string, locale?: string | null): any[] { - const centralMappingsPath = path.join(this._rootPath, 'mappings', `${sourceGuid}-${targetGuid}`, locale ?? '', type); + const centralMappingsPath = path.join( + this._rootPath, + "mappings", + `${sourceGuid}-${targetGuid}`, + locale ?? "", + type + ); if (fs.existsSync(centralMappingsPath)) { - const fullPath = path.join(centralMappingsPath, 'mappings.json'); + const fullPath = path.join(centralMappingsPath, "mappings.json"); if (!fs.existsSync(fullPath)) { //initialize empty mappings file if it doesn't exist fs.writeFileSync(fullPath, "[]"); } - const data = fs.readFileSync(fullPath, 'utf8'); + const data = fs.readFileSync(fullPath, "utf8"); const jsonData = JSON.parse(data); return jsonData; - - } - else { + } else { return []; } } - - saveMappingFile(mappingData: any[], type?: string, sourceGuid?: string, targetGuid?: string, locale?: string | null): void { - + saveMappingFile( + mappingData: any[], + type?: string, + sourceGuid?: string, + targetGuid?: string, + locale?: string | null + ): void { const mappingRootPath = this.getMappingFilePath(sourceGuid, targetGuid, locale); const centralMappingsPath = path.join(mappingRootPath, type); @@ -396,14 +407,13 @@ export class fileOperations { fs.writeFileSync(mappingFilePath, JSON.stringify(mappingData, null, 2)); } - /** * Get reverse mapping file path for fallback lookups * For B→A sync: when A→B mapping file exists, use it by flipping the source/target GUIDs */ getReverseMappingFilePath(sourceGuid: string, targetGuid: string, locale?: string): string { const localeToUse = locale || this._locale; - const centralMappingsPath = path.join(this._rootPath, 'mappings'); + const centralMappingsPath = path.join(this._rootPath, "mappings"); return path.join(centralMappingsPath, `${targetGuid}-to-${sourceGuid}-${localeToUse}.json`); } @@ -461,7 +471,9 @@ export class fileOperations { try { const content = this.readFile(reverseMappingFilePath); const reverseMappingData = JSON.parse(content); - console.log(`[FileOps] Loaded reverse mapping file: ${targetGuid}→${sourceGuid} (for ${sourceGuid}→${targetGuid} sync)`); + console.log( + `[FileOps] Loaded reverse mapping file: ${targetGuid}→${sourceGuid} (for ${sourceGuid}→${targetGuid} sync)` + ); return reverseMappingData; } catch (error) { console.error(`Error loading reverse mapping file ${reverseMappingFilePath}:`, error); @@ -499,11 +511,9 @@ export class fileOperations { getFilePath(folderName?: string, fileName?: string): string { if (folderName && fileName) { return path.join(this._basePath, folderName, fileName); - } - else if (folderName) { + } else if (folderName) { return path.join(this._basePath, folderName); - } - else if (fileName) { + } else if (fileName) { return path.join(this._basePath, fileName); } return this._basePath; @@ -512,18 +522,16 @@ export class fileOperations { getDataFilePath(folderName?: string, fileName?: string): string { if (folderName && fileName) { return path.join(this._basePath, folderName, fileName); - } - else if (folderName) { + } else if (folderName) { return path.join(this._basePath, folderName); - } - else if (fileName) { + } else if (fileName) { return path.join(this._basePath, fileName); } return this._basePath; } getNestedSitemapPath(): string { - return path.join(this._basePath, 'nestedsitemap', 'website.json'); + return path.join(this._basePath, "nestedsitemap", "website.json"); } // Path utilities @@ -541,7 +549,7 @@ export class fileOperations { if (!fs.existsSync(fullPath)) { return null; } - const content = fs.readFileSync(fullPath, 'utf8'); + const content = fs.readFileSync(fullPath, "utf8"); return JSON.parse(content); } catch (error: any) { console.warn(`[FileOps] Error reading JSON file ${relativePath}: ${error.message}`); @@ -551,7 +559,7 @@ export class fileOperations { readJsonFileAbsolute(absolutePath: string): any | null { try { - const content = fs.readFileSync(absolutePath, 'utf8'); + const content = fs.readFileSync(absolutePath, "utf8"); return JSON.parse(content); } catch (error: any) { return null; @@ -559,19 +567,19 @@ export class fileOperations { } } - readJsonFilesFromFolder(folderName: string, fileExtension: string = '.json'): any[] { + readJsonFilesFromFolder(folderName: string, fileExtension: string = ".json"): any[] { try { const folderPath = this.getDataFolderPath(folderName); if (!fs.existsSync(folderPath)) { return []; } - const files = fs.readdirSync(folderPath).filter(file => file.endsWith(fileExtension)); + const files = fs.readdirSync(folderPath).filter((file) => file.endsWith(fileExtension)); const results: any[] = []; for (const file of files) { try { - const content = fs.readFileSync(path.join(folderPath, file), 'utf8'); + const content = fs.readFileSync(path.join(folderPath, file), "utf8"); const parsed = JSON.parse(content); results.push(parsed); } catch (error: any) { @@ -595,7 +603,7 @@ export class fileOperations { let files = fs.readdirSync(folderPath); if (fileExtension) { - files = files.filter(file => file.endsWith(fileExtension)); + files = files.filter((file) => file.endsWith(fileExtension)); } return files; @@ -606,7 +614,7 @@ export class fileOperations { } readTempFile(fileName: string) { - let appName = 'mgmt-cli-code'; + let appName = "mgmt-cli-code"; let tmpFolder = os.tmpDir(); let tmpDir = `${tmpFolder}/${appName}`; let fileData = this.readFile(`${tmpDir}/${fileName}`); @@ -614,15 +622,14 @@ export class fileOperations { } createTempFile(fileName: string, content: string) { - let appName = 'mgmt-cli-code'; + let appName = "mgmt-cli-code"; let tmpFolder = os.tmpDir(); let tmpDir = `${tmpFolder}/${appName}`; fs.access(tmpDir, (error) => { if (error) { fs.mkdirSync(tmpDir); this.createFile(`${tmpDir}/${fileName}`, content); - } - else { + } else { this.createFile(`${tmpDir}/${fileName}`, content); } }); @@ -634,58 +641,54 @@ export class fileOperations { } readDirectory(folderName: string, baseFolder?: string) { - if (baseFolder === undefined || baseFolder === '') { + if (baseFolder === undefined || baseFolder === "") { baseFolder = this._basePath; } let directory = `${baseFolder}/${folderName}`; let files: string[] = []; - fs.readdirSync(directory).forEach(file => { + fs.readdirSync(directory).forEach((file) => { let readFile = this.readFile(`${directory}/${file}`); files.push(readFile); - }) + }); return files; } folderExists(folderName: string, baseFolder?: string) { - if (baseFolder === undefined || baseFolder === '') { + if (baseFolder === undefined || baseFolder === "") { baseFolder = this._basePath; } let directory = `${baseFolder}/${folderName}`; if (fs.existsSync(directory)) { return true; - } - else { + } else { return false; } } codeFileExists() { - let appName = 'mgmt-cli-code'; + let appName = "mgmt-cli-code"; let tmpFolder = os.tmpDir(); let tmpDir = `${tmpFolder}/${appName}/code.json`; if (fs.existsSync(tmpDir)) { return true; - } - else { + } else { return false; } } deleteCodeFile() { - let appName = 'mgmt-cli-code'; + let appName = "mgmt-cli-code"; let tmpFolder = os.tmpDir(); let tmpDir = `${tmpFolder}/${appName}/code.json`; if (fs.existsSync(tmpDir)) { - fs.rmSync(tmpDir); - console.log('Logged out successfully'); + console.log("Logged out successfully"); return true; - } - else { + } else { return false; } } @@ -719,13 +722,23 @@ export class fileOperations { } } - public finalizeLogFile(operationType: 'pull' | 'push' | 'sync'): string { + public finalizeLogFile(operationType: "pull" | "push" | "sync"): string { const now = new Date(); // Create semantic filename like "2025-may-12-at-10-15-32-am.txt" const months = [ - 'january', 'february', 'march', 'april', 'may', 'june', - 'july', 'august', 'september', 'october', 'november', 'december' + "january", + "february", + "march", + "april", + "may", + "june", + "july", + "august", + "september", + "october", + "november", + "december", ]; const year = now.getFullYear(); @@ -734,10 +747,10 @@ export class fileOperations { const hour = now.getHours(); const minute = now.getMinutes(); const second = now.getSeconds(); - const ampm = hour >= 12 ? 'pm' : 'am'; + const ampm = hour >= 12 ? "pm" : "am"; const hour12 = hour % 12 || 12; - const pad = (num: number) => String(num).padStart(2, '0'); + const pad = (num: number) => String(num).padStart(2, "0"); const semanticTimestamp = `${year}-${month}-${pad(day)}-at-${pad(hour12)}-${pad(minute)}-${pad(second)}-${ampm}`; if (!fs.existsSync(this._currentLogFilePath)) { diff --git a/src/core/index.ts b/src/core/index.ts index 4845c7c..c331638 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -4,58 +4,54 @@ */ // Core authentication and state management -export { Auth } from './auth'; -export { state, setState, resetState, primeFromEnv, getState, getUIMode, configureSSL } from './state'; -export { systemArgs, type SystemArgsType } from './system-args'; -export { normalizeProcessArgs, normalizeArgv } from './arg-normalizer'; +export { Auth } from "./auth"; +export { state, setState, resetState, primeFromEnv, getState, getUIMode, configureSSL } from "./state"; +export { systemArgs, type SystemArgsType } from "./system-args"; +export { normalizeProcessArgs, normalizeArgv } from "./arg-normalizer"; // Main operation services // export { Sync } from './sync_bak'; // Publishing service -export { PublishService, type PublishResult, type PublishOptions } from './publish'; +export { PublishService, type PublishResult, type PublishOptions } from "./publish"; // Workflow operation standalone module -export { WorkflowOperation } from '../lib/workflows'; +export { WorkflowOperation } from "../lib/workflows"; // Batch workflows service - core batch operations -export { - batchWorkflow, - type BatchItemType, - createBatches -} from './batch-workflows'; +export { batchWorkflow, type BatchItemType, createBatches } from "./batch-workflows"; // Workflow module - orchestration, options, helpers export { - workflowOrchestrator, - parseWorkflowOptions, - parseOperationType, - getOperationName, - getOperationVerb, - getOperationIcon -} from '../lib/workflows'; + workflowOrchestrator, + parseWorkflowOptions, + parseOperationType, + getOperationName, + getOperationVerb, + getOperationIcon, +} from "../lib/workflows"; // Re-export all workflow types from central types folder export { - WorkflowOperationType, - BatchWorkflowResult, - WorkflowOrchestratorResult, - WorkflowOptions, - WorkflowOperationResult, - ContentMapping, - PageMapping, - MappingReadResult, - MappingUpdateResult, - ItemState, - SourceItemData, - PublishStatusResult -} from '../types'; + WorkflowOperationType, + BatchWorkflowResult, + WorkflowOrchestratorResult, + WorkflowOptions, + WorkflowOperationResult, + ContentMapping, + PageMapping, + MappingReadResult, + MappingUpdateResult, + ItemState, + SourceItemData, + PublishStatusResult, +} from "../types"; // Content and data services -export { content } from './content'; -export { assets } from './assets'; -export { fileOperations } from './fileOperations'; -export { getApiClient } from './state'; +export { content } from "./content"; +export { assets } from "./assets"; +export { fileOperations } from "./fileOperations"; +export { getApiClient } from "./state"; // File system integration // Note: store-interface-filesystem uses module.exports, import directly if needed diff --git a/src/core/logs.ts b/src/core/logs.ts index c13a4f3..0926fc7 100644 --- a/src/core/logs.ts +++ b/src/core/logs.ts @@ -10,54 +10,58 @@ import { generateLogHeader } from "../lib/shared"; */ function safeStringify(obj: any, indent?: number): string { const seen = new WeakSet(); - + const replacer = (key: string, value: any) => { // Skip known non-serializable properties - if (key === 'agent' || - key === '_httpMessage' || - key === 'socket' || - key === 'connection' || - key === 'request' || - key === 'response' || - key === '_events' || - key === '_eventsCount' || - key === 'httpsAgent' || - key === 'httpAgent' || - key === 'sockets' || - key === 'freeSockets' || - key === '_currentRequest') { + if ( + key === "agent" || + key === "_httpMessage" || + key === "socket" || + key === "connection" || + key === "request" || + key === "response" || + key === "_events" || + key === "_eventsCount" || + key === "httpsAgent" || + key === "httpAgent" || + key === "sockets" || + key === "freeSockets" || + key === "_currentRequest" + ) { return undefined; } - + // Skip functions - if (typeof value === 'function') { + if (typeof value === "function") { return undefined; } - + // Handle circular references - if (typeof value === 'object' && value !== null) { + if (typeof value === "object" && value !== null) { // Skip known non-serializable types const constructorName = value.constructor?.name; - if (constructorName === 'Agent' || - constructorName === 'ClientRequest' || - constructorName === 'IncomingMessage' || - constructorName === 'Socket' || - constructorName === 'TLSSocket' || - constructorName === 'Writable' || - constructorName === 'ReadableState' || - constructorName === 'WritableState') { + if ( + constructorName === "Agent" || + constructorName === "ClientRequest" || + constructorName === "IncomingMessage" || + constructorName === "Socket" || + constructorName === "TLSSocket" || + constructorName === "Writable" || + constructorName === "ReadableState" || + constructorName === "WritableState" + ) { return `[${constructorName}]`; } - + if (seen.has(value)) { - return '[Circular]'; + return "[Circular]"; } seen.add(value); } - + return value; }; - + try { return JSON.stringify(obj, replacer, indent); } catch (e) { @@ -348,16 +352,16 @@ export class Logs { ? status === "success" ? ansiColors.green(this.formatGuidWithColor(guid)) : status === "failed" - ? ansiColors.red(`[${guid}]`) - : this.formatGuidWithColor(guid) + ? ansiColors.red(`[${guid}]`) + : this.formatGuidWithColor(guid) : ""; const styledItemName = itemName && this.config.showColors ? status === "success" ? ansiColors.cyan.underline(itemName) : status === "failed" - ? ansiColors.red.underline(itemName) - : ansiColors.cyan.underline(itemName) + ? ansiColors.red.underline(itemName) + : ansiColors.cyan.underline(itemName) : itemName; const styledDetails = details && this.config.showColors ? ansiColors.gray(`${details}`) : details; const detailsDisplay = styledDetails ? `${styledDetails}` : ""; @@ -365,8 +369,8 @@ export class Logs { ? status === "success" ? ansiColors.gray(action) : status === "failed" - ? ansiColors.red(action) - : ansiColors.gray(action) + ? ansiColors.red(action) + : ansiColors.gray(action) : action; const localeDisplay = locale && this.config.showColors ? ansiColors.gray(`[${locale}]`) : locale ? `[${locale}]` : ""; @@ -377,8 +381,8 @@ export class Logs { ? status === "success" ? ansiColors.white(entityType) : status === "failed" - ? ansiColors.red(entityType) - : ansiColors.white(entityType) + ? ansiColors.red(entityType) + : ansiColors.white(entityType) : entityType; const entityTypeDisplay = (message = `${symbol}${guidDisplay}${localeDisplay ? `${localeDisplay}` : ""}${ @@ -665,45 +669,48 @@ export class Logs { error: (payload: any, apiError: any, targetGuid?: string) => { const itemName = payload?.fileName || payload?.name || `Asset ${payload?.mediaID || "Unknown"}`; - + // Extract the actual API error message from nested SDK exception structure let errorDetails = "Unknown error"; let apiUrl = ""; let httpStatus = ""; let responseData = ""; - - if (typeof apiError === 'string') { + + if (typeof apiError === "string") { errorDetails = apiError; } else { // Try multiple paths to find the actual error const innerErr = apiError?.innerError; - + // Get URL from config if (innerErr?.config) { - apiUrl = `${innerErr.config.baseURL || ''}${innerErr.config.url || ''}`; + apiUrl = `${innerErr.config.baseURL || ""}${innerErr.config.url || ""}`; } - + // Get status code if (innerErr?.response?.status) { httpStatus = String(innerErr.response.status); } else if (innerErr?.code) { httpStatus = innerErr.code; // e.g., ECONNREFUSED, ENOTFOUND } - + // Get response data (the actual server error message) if (innerErr?.response?.data) { const data = innerErr.response.data; - responseData = typeof data === 'string' ? data : JSON.stringify(data); - errorDetails = typeof data === 'string' ? data : (data.message || data.error || data.Message || data.title || JSON.stringify(data)); + responseData = typeof data === "string" ? data : JSON.stringify(data); + errorDetails = + typeof data === "string" + ? data + : data.message || data.error || data.Message || data.title || JSON.stringify(data); } else if (innerErr?.message) { errorDetails = innerErr.message; } else if (apiError?.message) { errorDetails = apiError.message; } } - + this.logDataElement("asset", "failed", "failed", itemName, targetGuid || this.guid, errorDetails); - + // Log comprehensive error details for debugging console.log(ansiColors.red(` API Error: ${errorDetails}`)); if (apiUrl) { @@ -749,7 +756,9 @@ export class Logs { const baseMessage = apiError?.message || "Unknown error"; const responseData = apiError?.response?.data ?? apiError?.response?.body ?? apiError?.data; const responseStatus = apiError?.response?.status ?? apiError?.status; - const serverDetail = responseData ? ` | Server response (${responseStatus ?? "?"}): ${typeof responseData === "string" ? responseData : safeStringify(responseData)}` : ""; + const serverDetail = responseData + ? ` | Server response (${responseStatus ?? "?"}): ${typeof responseData === "string" ? responseData : safeStringify(responseData)}` + : ""; const errorDetails = `${baseMessage}${serverDetail}`; this.logDataElement("model", "error", "failed", itemName, targetGuid || this.guid, errorDetails); }, @@ -797,8 +806,10 @@ export class Logs { const referenceName = entity?.properties?.referenceName; const contentID = entity?.contentID; // Show both referenceName and contentID for debugging: "referenceName (contentID: 123)" - const itemName = referenceName - ? (contentID ? `${referenceName} (contentID: ${contentID})` : referenceName) + const itemName = referenceName + ? contentID + ? `${referenceName} (contentID: ${contentID})` + : referenceName : `Content ${contentID || "Unknown"}`; this.logDataElement("content", "downloaded", "success", itemName, this.guid, details, locale); }, @@ -806,8 +817,10 @@ export class Logs { uploaded: (entity: any, details?: string, locale?: string, targetGuid?: string) => { const referenceName = entity?.properties?.referenceName || entity?.fields?.title || entity?.fields?.name; const contentID = entity?.contentID; - const itemName = referenceName - ? (contentID ? `${referenceName} (contentID: ${contentID})` : referenceName) + const itemName = referenceName + ? contentID + ? `${referenceName} (contentID: ${contentID})` + : referenceName : `Content ${contentID || "Unknown"}`; this.logDataElement("content", "uploaded", "success", itemName, targetGuid || this.guid, details, locale); }, @@ -815,8 +828,10 @@ export class Logs { created: (entity: any, details?: string, locale?: string, targetGuid?: string) => { const referenceName = entity?.properties?.referenceName || entity?.fields?.title || entity?.fields?.name; const contentID = entity?.contentID; - const itemName = referenceName - ? (contentID ? `${referenceName} (contentID: ${contentID})` : referenceName) + const itemName = referenceName + ? contentID + ? `${referenceName} (contentID: ${contentID})` + : referenceName : `Content ${contentID || "Unknown"}`; this.logDataElement("content", "created", "success", itemName, targetGuid || this.guid, details, locale); }, @@ -824,8 +839,10 @@ export class Logs { updated: (entity: any, details?: string, locale?: string, targetGuid?: string) => { const referenceName = entity?.properties?.referenceName || entity?.fields?.title || entity?.fields?.name; const contentID = entity?.contentID; - const itemName = referenceName - ? (contentID ? `${referenceName} (contentID: ${contentID})` : referenceName) + const itemName = referenceName + ? contentID + ? `${referenceName} (contentID: ${contentID})` + : referenceName : `Content ${contentID || "Unknown"}`; this.logDataElement("content", "updated", "success", itemName, targetGuid || this.guid, details, locale); }, @@ -833,8 +850,10 @@ export class Logs { skipped: (entity: any, details?: string, locale?: string, targetGuid?: string) => { const referenceName = entity?.properties?.referenceName || entity?.fields?.title || entity?.fields?.name; const contentID = entity?.contentID; - const itemName = referenceName - ? (contentID ? `${referenceName} (contentID: ${contentID})` : referenceName) + const itemName = referenceName + ? contentID + ? `${referenceName} (contentID: ${contentID})` + : referenceName : `Content ${contentID || "Unknown"}`; this.logDataElement("content", "skipped", "skipped", itemName, targetGuid || this.guid, details, locale); }, @@ -842,8 +861,10 @@ export class Logs { error: (payload: any, apiError: any, locale?: string, targetGuid?: string) => { const referenceName = payload?.properties?.referenceName || payload?.fields?.title || payload?.fields?.name; const contentID = payload?.contentID; - const itemName = referenceName - ? (contentID ? `${referenceName} (contentID: ${contentID})` : referenceName) + const itemName = referenceName + ? contentID + ? `${referenceName} (contentID: ${contentID})` + : referenceName : `Content ${contentID || "Unknown"}`; const errorDetails = apiError?.message || apiError || "Unknown error"; // we need a better error logger for data elements diff --git a/src/core/publish.ts b/src/core/publish.ts index 7219b05..8516a58 100644 --- a/src/core/publish.ts +++ b/src/core/publish.ts @@ -3,11 +3,9 @@ * Uses simple publisher functions that mirror the SDK patterns */ -import * as mgmtApi from '@agility/management-sdk'; -import { getState, getApiClient } from './state'; -import { - publishContentItem -} from '../lib/publishers'; +import * as mgmtApi from "@agility/management-sdk"; +import { getState, getApiClient } from "./state"; +import { publishContentItem } from "../lib/publishers"; const ansiColors = require("ansi-colors"); @@ -42,11 +40,11 @@ export class PublishService { constructor(options: PublishOptions = {}) { const state = getState(); - + if (!state.targetGuid?.length) { - throw new Error('PublishService requires targetGuid to be set in state'); + throw new Error("PublishService requires targetGuid to be set in state"); } - + this.apiClient = getApiClient(); this.targetGuid = state.targetGuid[0]; this.options = { verbose: false, ...options }; @@ -55,10 +53,10 @@ export class PublishService { /** * Publish a batch of content items using simple publisher functions */ - async publishContentBatch(contentIds: number[], locale: string): Promise { - const result: PublishResult['contentItems'] = { + async publishContentBatch(contentIds: number[], locale: string): Promise { + const result: PublishResult["contentItems"] = { successful: [], - failed: [] + failed: [], }; if (contentIds.length === 0) { @@ -73,14 +71,14 @@ export class PublishService { for (const contentId of contentIds) { try { const publishResult = await publishContentItem(contentId, locale); - + if (publishResult.success) { result.successful.push(contentId); if (this.options.verbose) { console.log(`✓ Content item ${ansiColors.cyan.underline(contentId)} published.`); } } else { - result.failed.push({ id: contentId, error: publishResult.error || 'Unknown error' }); + result.failed.push({ id: contentId, error: publishResult.error || "Unknown error" }); if (this.options.verbose) { console.error(ansiColors.red(`❌ Failed to publish content item ${contentId}: ${publishResult.error}`)); } diff --git a/src/core/pull.ts b/src/core/pull.ts index 37b8117..03b2fe5 100644 --- a/src/core/pull.ts +++ b/src/core/pull.ts @@ -17,7 +17,7 @@ export class Pull { async pullInstances(fromPush: boolean = false): Promise<{ success: boolean; results: any[]; elapsedTime: number }> { const state = getState(); - + // Initialize logger inside the method so it works correctly when called from push operations // But only if not called from push operation (to avoid conflicts with push logger) if (!fromPush) { @@ -72,7 +72,7 @@ export class Pull { if (!fromPush) { for (const guid of allGuids) { try { - await waitForFetchApiSync(guid, 'fetch', false); + await waitForFetchApiSync(guid, "fetch", false); } catch (error: any) { // Log warning but don't fail the pull - the API might not support this endpoint yet console.log(ansiColors.yellow(`⚠️ Could not check Fetch API status for ${guid}: ${error.message}`)); @@ -105,10 +105,8 @@ export class Pull { const logger = getLogger(); if (logger) { // Collect log file paths - const logFilePaths = results - .map(res => res.logFilePath) - .filter(path => path); - + const logFilePaths = results.map((res) => res.logFilePath).filter((path) => path); + logger.orchestratorSummary(results, totalElapsedTime, success, logFilePaths); } @@ -120,9 +118,8 @@ export class Pull { return { success, results, - elapsedTime: totalElapsedTime + elapsedTime: totalElapsedTime, }; - } catch (error: any) { console.error(ansiColors.red("\n❌ An error occurred during the pull command:"), error); throw error; // Let calling code handle error response diff --git a/src/core/push.ts b/src/core/push.ts index d610361..85970a7 100644 --- a/src/core/push.ts +++ b/src/core/push.ts @@ -1,6 +1,16 @@ import * as path from "path"; import * as fs from "fs"; -import { getState, initializeLogger, finalizeLogger, getLogger, state, setState, clearFailedContentRegistry, getPageCmsLink, getContentCmsLink } from "./state"; +import { + getState, + initializeLogger, + finalizeLogger, + getLogger, + state, + setState, + clearFailedContentRegistry, + getPageCmsLink, + getContentCmsLink, +} from "./state"; import ansiColors from "ansi-colors"; import { markPushStart, clearTimestamps } from "../lib/incremental"; @@ -17,10 +27,10 @@ export class Push { async pushInstances(fromSync: boolean = false): Promise<{ success: boolean; results: any[]; elapsedTime: number }> { const { isSync, sourceGuid, targetGuid, models, modelsWithDeps, autoPublish } = state; - + // Clear failed content registry from any previous sync clearFailedContentRegistry(); - + // Initialize logger for push operation // Determine if this is a sync operation by checking if both source and target GUIDs exist initializeLogger(isSync ? "sync" : "push"); @@ -37,25 +47,24 @@ export class Push { // IMPORTANT: For sync operations, we need ALL elements downloaded to enable proper change detection // Model filtering happens at the processing level, not the download level - const { } = state; + const {} = state; if (models && models.trim().length > 0 && (!modelsWithDeps || modelsWithDeps.trim().length === 0)) { // For simple --models flag (not --models-with-deps), we can restrict downloads to save time // But for sync operations, we still need all elements for change detection if (!isSync) { const { setState } = await import("./state"); - setState({ elements: 'Models' }); + setState({ elements: "Models" }); } // For sync operations, leave elements as default to download everything } - // pull the instance data const pull = new Pull(); await pull.pullInstances(true); - + // Re-initialize logger after pull operation (pull finalizes its logger) initializeLogger(isSync ? "sync" : "push"); - + // CONSOLE.LOG - Calculate total operations using per-GUID locale mapping let totalOperations = 0; const operationDetails: string[] = []; @@ -96,15 +105,21 @@ export class Push { }); // Collect log file paths for display at the very end - const logFilePaths = results - .map(res => res.logFilePath) - .filter(path => path) as string[]; + const logFilePaths = results.map((res) => res.logFilePath).filter((path) => path) as string[]; // Collect sync failure details from results for error summary let totalSyncFailures = 0; const syncErrors: Array<{ locale?: string; type: string; error: string }> = []; - const syncFailureDetails: Array<{ name: string; error: string; type?: 'content' | 'page'; pageID?: number; contentID?: number; guid?: string; locale?: string }> = []; - + const syncFailureDetails: Array<{ + name: string; + error: string; + type?: "content" | "page"; + pageID?: number; + contentID?: number; + guid?: string; + locale?: string; + }> = []; + results.forEach((result: PushResults) => { // Track item-level failures from totalFailures if (result.totalFailures > 0) { @@ -116,8 +131,8 @@ export class Push { } // Track operation-level failures if (result.failed && result.failed.length > 0) { - result.failed.forEach(f => { - syncErrors.push({ type: 'sync', error: `${f.operation}: ${f.error}` }); + result.failed.forEach((f) => { + syncErrors.push({ type: "sync", error: `${f.operation}: ${f.error}` }); }); } }); @@ -144,21 +159,21 @@ export class Push { // Final error summary - show if there were ANY failures (sync or auto-publish) const hasFailures = totalSyncFailures > 0 || syncErrors.length > 0 || autoPublishErrors.length > 0; - + if (hasFailures) { - console.log(ansiColors.red('\n' + '═'.repeat(50))); - console.log(ansiColors.red('⚠️ ERROR SUMMARY')); - console.log(ansiColors.red('═'.repeat(50))); - + console.log(ansiColors.red("\n" + "═".repeat(50))); + console.log(ansiColors.red("⚠️ ERROR SUMMARY")); + console.log(ansiColors.red("═".repeat(50))); + // Show sync failure details line by line with links if (syncFailureDetails.length > 0) { console.log(ansiColors.red(`\n Sync Failures (${syncFailureDetails.length}):`)); syncFailureDetails.forEach(({ name, error, type, pageID, contentID, guid, locale }) => { // Format: [guid][locale] • name: error - const prefix = guid && locale ? `[${guid}][${locale}]` : guid ? `[${guid}]` : ''; + const prefix = guid && locale ? `[${guid}][${locale}]` : guid ? `[${guid}]` : ""; console.log(ansiColors.red(` ${prefix} • ${name}: ${error}`)); // Add link for page failures - if (type === 'page' && pageID && guid && locale) { + if (type === "page" && pageID && guid && locale) { const pageLink = getPageCmsLink(guid, locale, pageID); console.log(ansiColors.gray(` ${pageLink}`)); // Also show content link if page failed due to missing content mapping @@ -168,7 +183,7 @@ export class Push { } } // Add link for content failures - if (type === 'content' && contentID && guid && locale) { + if (type === "content" && contentID && guid && locale) { const link = getContentCmsLink(guid, locale, contentID); console.log(ansiColors.gray(` ${link}`)); } @@ -177,21 +192,21 @@ export class Push { // Fallback if no detailed failure info available console.log(ansiColors.red(` Sync: ${totalSyncFailures} items failed (see details above)`)); } - + // Show detailed sync errors (operation-level) if (syncErrors.length > 0) { console.log(ansiColors.red(`\n Operation Errors:`)); syncErrors.forEach(({ locale, type, error }) => { - const localeDisplay = locale ? `[${locale}]` : ''; + const localeDisplay = locale ? `[${locale}]` : ""; console.log(ansiColors.red(` • ${localeDisplay} ${type}: ${error}`)); }); } - + // Show auto-publish errors if (autoPublishErrors.length > 0) { console.log(ansiColors.red(`\n Auto-Publish Errors:`)); autoPublishErrors.forEach(({ locale, type, error }) => { - const localeDisplay = locale ? `[${locale}]` : ''; + const localeDisplay = locale ? `[${locale}]` : ""; console.log(ansiColors.red(` • ${localeDisplay} ${type}: ${error}`)); }); } @@ -199,27 +214,26 @@ export class Push { // Show log file paths at the very end if (logFilePaths.length > 0) { - console.log(ansiColors.cyan('\n📄 Log Files:')); + console.log(ansiColors.cyan("\n📄 Log Files:")); logFilePaths.forEach((path) => { console.log(` ${path}`); }); } - + // Only exit if not called from another operation - + return { success, results, elapsedTime: totalElapsedTime, }; - } catch (error: any) { console.error(ansiColors.red("\n❌ An error occurred during the push command:"), error); finalizeLogger(); // Finalize logger even on error - + // Only exit if not called from another operation // process.exit(1); - + throw error; // Let calling code handle error response } } @@ -229,7 +243,10 @@ export class Push { * IMPORTANT: Publishes items per-locale since the batch workflow API requires a locale parameter * Returns array of errors for display in final summary */ - private async executeAutoPublish(results: PushResults[], autoPublishMode: string): Promise> { + private async executeAutoPublish( + results: PushResults[], + autoPublishMode: string + ): Promise> { // Collect per-locale publishable IDs from sync results const contentIdsByLocale = new Map(); const pageIdsByLocale = new Map(); @@ -251,42 +268,46 @@ export class Push { } // Determine what to publish based on mode - const publishContent = autoPublishMode === 'content' || autoPublishMode === 'both'; - const publishPages = autoPublishMode === 'pages' || autoPublishMode === 'both'; + const publishContent = autoPublishMode === "content" || autoPublishMode === "both"; + const publishPages = autoPublishMode === "pages" || autoPublishMode === "both"; // Get all locales that have items to publish const allLocales = new Set(); if (publishContent) { - Array.from(contentIdsByLocale.keys()).forEach(locale => allLocales.add(locale)); + Array.from(contentIdsByLocale.keys()).forEach((locale) => allLocales.add(locale)); } if (publishPages) { - Array.from(pageIdsByLocale.keys()).forEach(locale => allLocales.add(locale)); + Array.from(pageIdsByLocale.keys()).forEach((locale) => allLocales.add(locale)); } if (allLocales.size === 0) { - console.log(ansiColors.yellow('\n⚠️ Auto-publish: No items to publish from sync operation')); + console.log(ansiColors.yellow("\n⚠️ Auto-publish: No items to publish from sync operation")); return []; } // Calculate totals for summary let totalContent = 0; let totalPages = 0; - contentIdsByLocale.forEach((ids) => { if (publishContent) totalContent += new Set(ids).size; }); - pageIdsByLocale.forEach((ids) => { if (publishPages) totalPages += new Set(ids).size; }); - - console.log(ansiColors.cyan('\n' + '═'.repeat(50))); - console.log(ansiColors.cyan('🚀 AUTO-PUBLISH')); - console.log(ansiColors.cyan('═'.repeat(50))); + contentIdsByLocale.forEach((ids) => { + if (publishContent) totalContent += new Set(ids).size; + }); + pageIdsByLocale.forEach((ids) => { + if (publishPages) totalPages += new Set(ids).size; + }); + + console.log(ansiColors.cyan("\n" + "═".repeat(50))); + console.log(ansiColors.cyan("🚀 AUTO-PUBLISH")); + console.log(ansiColors.cyan("═".repeat(50))); console.log(ansiColors.gray(`Mode: ${autoPublishMode}`)); - console.log(ansiColors.gray(`Locales to publish: ${Array.from(allLocales).join(', ')}`)); + console.log(ansiColors.gray(`Locales to publish: ${Array.from(allLocales).join(", ")}`)); console.log(ansiColors.gray(`Total content items: ${totalContent}`)); console.log(ansiColors.gray(`Total pages: ${totalPages}`)); // Import workflow dependencies - const { processBatches } = await import('../lib/workflows/process-batches'); - const { WorkflowOperationType } = await import('../types'); - const { updateMappingsAfterPublish } = await import('../lib/mappers/mapping-version-updater'); - const { waitForFetchApiSync } = await import('../lib/shared/get-fetch-api-status'); + const { processBatches } = await import("../lib/workflows/process-batches"); + const { WorkflowOperationType } = await import("../types"); + const { updateMappingsAfterPublish } = await import("../lib/mappers/mapping-version-updater"); + const { waitForFetchApiSync } = await import("../lib/shared/get-fetch-api-status"); // Track all errors for summary const allErrors: { locale: string; type: string; error: string }[] = []; @@ -314,7 +335,13 @@ export class Push { // Publish content for this locale if (contentIds.length > 0) { - const contentResult = await processBatches(contentIds, 'content', locale, WorkflowOperationType.Publish, errors); + const contentResult = await processBatches( + contentIds, + "content", + locale, + WorkflowOperationType.Publish, + errors + ); publishedContentIds.push(...contentResult.processedIds); if (contentResult.failed > 0) { console.log(ansiColors.yellow(` ⚠️ ${contentResult.failed} content items failed`)); @@ -323,7 +350,7 @@ export class Push { // Publish pages for this locale if (pageIds.length > 0) { - const pageResult = await processBatches(pageIds, 'pages', locale, WorkflowOperationType.Publish, errors); + const pageResult = await processBatches(pageIds, "pages", locale, WorkflowOperationType.Publish, errors); publishedPageIds.push(...pageResult.processedIds); if (pageResult.failed > 0) { console.log(ansiColors.yellow(` ⚠️ ${pageResult.failed} pages failed`)); @@ -339,46 +366,46 @@ export class Push { } // Collect errors for summary - errors.forEach(err => { - allErrors.push({ locale, type: 'publish', error: err }); + errors.forEach((err) => { + allErrors.push({ locale, type: "publish", error: err }); }); } - console.log(ansiColors.green('\n✓ Auto-publish complete')); + console.log(ansiColors.green("\n✓ Auto-publish complete")); // Refresh target instance data and update mappings after publishing // This ensures the mappings are up-to-date with the newly published content const targetGuid = state.targetGuid?.[0]; const sourceGuid = state.sourceGuid?.[0]; - + if (targetGuid && sourceGuid) { const hasPublishedItems = publishedContentIdsByLocale.size > 0 || publishedPageIdsByLocale.size > 0; - + if (hasPublishedItems) { // Step 1: Wait for Fetch API sync to complete (ONCE for all locales) - console.log(ansiColors.cyan('\nRefreshing target instance data...')); + console.log(ansiColors.cyan("\nRefreshing target instance data...")); try { - await waitForFetchApiSync(targetGuid, 'fetch', false); + await waitForFetchApiSync(targetGuid, "fetch", false); } catch (error: any) { console.log(ansiColors.yellow(` ⚠️ Could not check Fetch API status: ${error.message}`)); // Continue anyway - status check is best-effort } - + // Step 2: Do ONE pull to refresh target data (ONCE for all locales) const pull = new Pull(); const pullResult = await pull.pullInstances(true); - + if (!pullResult.success) { - console.log(ansiColors.yellow(' ⚠️ Target refresh failed - skipping mapping version updates')); - console.log(ansiColors.gray(' Run a manual pull to refresh data and update mappings')); + console.log(ansiColors.yellow(" ⚠️ Target refresh failed - skipping mapping version updates")); + console.log(ansiColors.gray(" Run a manual pull to refresh data and update mappings")); } else { - console.log(ansiColors.green('✓ Target instance data refreshed')); - + console.log(ansiColors.green("✓ Target instance data refreshed")); + // Step 3: Update mappings for each locale that had published items for (const locale of Array.from(allLocales)) { const contentIds = publishedContentIdsByLocale.get(locale) || []; const pageIds = publishedPageIdsByLocale.get(locale) || []; - + if (contentIds.length > 0 || pageIds.length > 0) { try { const mappingResult = await updateMappingsAfterPublish( @@ -388,22 +415,22 @@ export class Push { targetGuid, locale ); - + // Log any mapping errors if (mappingResult.result.errors.length > 0) { - mappingResult.result.errors.forEach(err => { - allErrors.push({ - locale, - type: 'mapping', - error: err + mappingResult.result.errors.forEach((err) => { + allErrors.push({ + locale, + type: "mapping", + error: err, }); }); } } catch (refreshError: any) { - allErrors.push({ - locale, - type: 'refresh', - error: `Mappings update failed: ${refreshError.message}` + allErrors.push({ + locale, + type: "refresh", + error: `Mappings update failed: ${refreshError.message}`, }); } } @@ -411,10 +438,9 @@ export class Push { } } } - } catch (error: any) { console.error(ansiColors.red(`\n❌ Auto-publish failed: ${error.message}`)); - allErrors.push({ locale: 'all', type: 'fatal', error: error.message }); + allErrors.push({ locale: "all", type: "fatal", error: error.message }); } // Return errors for display in final summary diff --git a/src/core/state.ts b/src/core/state.ts index 4b06d0f..2ea2870 100644 --- a/src/core/state.ts +++ b/src/core/state.ts @@ -3,11 +3,11 @@ * Simple state object that gets populated from argv and referenced throughout the app */ -import * as mgmtApi from '@agility/management-sdk'; -import fs from 'fs'; -import path from 'path'; -import { Logs, OperationType, EntityType } from './logs'; -import { Options } from '@agility/management-sdk'; +import * as mgmtApi from "@agility/management-sdk"; +import fs from "fs"; +import path from "path"; +import { Logs, OperationType, EntityType } from "./logs"; +import { Options } from "@agility/management-sdk"; export interface State { // Environment modes @@ -22,7 +22,7 @@ export interface State { // Instance/Connection sourceGuid: string[]; // Array of source GUIDs targetGuid: string[]; // Array of target GUIDs - locale: string[]; // Array of locales (for backward compatibility / user-specified) + locale: string[]; // Array of locales (for backward compatibility / user-specified) availableLocales: string[]; // Detected locales from getLocales() during auth guidLocaleMap: Map; // Per-GUID locale mapping for matrix operations channel: string; @@ -139,7 +139,7 @@ export const state: State = { reset: false, update: true, dryRun: false, - autoPublish: '', // Empty string = disabled + autoPublish: "", // Empty string = disabled // Explicit ID overrides (bypass mappings lookup) explicitContentIDs: [], @@ -187,9 +187,10 @@ export function setState(argv: any) { // Instance/Connection - Multi-GUID parsing logic if (argv.sourceGuid !== undefined) { - if (argv.sourceGuid.includes(',')) { + if (argv.sourceGuid.includes(",")) { // Multi-GUID specification - state.sourceGuid = argv.sourceGuid.split(',') + state.sourceGuid = argv.sourceGuid + .split(",") .map((g: string) => g.trim()) .filter((g: string) => g.length > 0); } else { @@ -199,9 +200,10 @@ export function setState(argv: any) { } if (argv.targetGuid !== undefined) { - if (argv.targetGuid.includes(',')) { + if (argv.targetGuid.includes(",")) { // Multi-GUID specification - state.targetGuid = argv.targetGuid.split(',') + state.targetGuid = argv.targetGuid + .split(",") .map((g: string) => g.trim()) .filter((g: string) => g.length > 0); } else { @@ -215,9 +217,10 @@ export function setState(argv: any) { if (argv.locale.trim() === "") { // Empty string = auto-detection state.locale = []; - } else if (argv.locale.includes(',') || argv.locale.includes(' ')) { + } else if (argv.locale.includes(",") || argv.locale.includes(" ")) { // Multi-locale specification - state.locale = argv.locale.split(/[,\s]+/) + state.locale = argv.locale + .split(/[,\s]+/) .map((l: string) => l.trim()) .filter((l: string) => l.length > 0); } else { @@ -255,17 +258,17 @@ export function setState(argv: any) { // Explicit ID overrides - parse comma-separated strings into number arrays if (argv.contentIDs !== undefined && argv.contentIDs !== "") { state.explicitContentIDs = String(argv.contentIDs) - .split(',') + .split(",") .map((id: string) => parseInt(id.trim(), 10)) .filter((id: number) => !isNaN(id) && id > 0); } if (argv.pageIDs !== undefined && argv.pageIDs !== "") { state.explicitPageIDs = String(argv.pageIDs) - .split(',') + .split(",") .map((id: string) => parseInt(id.trim(), 10)) .filter((id: number) => !isNaN(id) && id > 0); } - + // Direct array assignment for programmatic use (e.g., auto-publish) if (argv.explicitContentIDs !== undefined && Array.isArray(argv.explicitContentIDs)) { state.explicitContentIDs = argv.explicitContentIDs; @@ -300,147 +303,149 @@ export function configureSSL() { * This allows .env values to be overridden by command line arguments */ export function primeFromEnv(): { hasEnvFile: boolean; primedValues: string[] } { - const envFiles = ['.env', '.env.local', '.env.development', '.env.production']; + const envFiles = [".env", ".env.local", ".env.development", ".env.production"]; const primedValues: string[] = []; // Match KEY=value only on uncommented lines (line start or after newline, optional whitespace, no #) - const uncommentedLine = (key: string) => new RegExp(`(?:^|\\n)\\s*${key}=([^\\n]+)`, 'm'); + const uncommentedLine = (key: string) => new RegExp(`(?:^|\\n)\\s*${key}=([^\\n]+)`, "m"); for (const envFile of envFiles) { const envPath = path.join(process.cwd(), envFile); if (fs.existsSync(envPath)) { - const envContent = fs.readFileSync(envPath, 'utf8'); + const envContent = fs.readFileSync(envPath, "utf8"); // Parse all relevant environment variables (uncommented lines only) const envVars = { - AGILITY_GUID: envContent.match(uncommentedLine('AGILITY_GUID')), - AGILITY_TARGET_GUID: envContent.match(uncommentedLine('AGILITY_TARGET_GUID')), - AGILITY_WEBSITE: envContent.match(uncommentedLine('AGILITY_WEBSITE')), - AGILITY_LOCALES: envContent.match(uncommentedLine('AGILITY_LOCALES')), - AGILITY_TEST: envContent.match(uncommentedLine('AGILITY_TEST')), - AGILITY_OVERWRITE: envContent.match(uncommentedLine('AGILITY_OVERWRITE')), - - AGILITY_PREVIEW: envContent.match(uncommentedLine('AGILITY_PREVIEW')), - AGILITY_VERBOSE: envContent.match(uncommentedLine('AGILITY_VERBOSE')), - AGILITY_HEADLESS: envContent.match(uncommentedLine('AGILITY_HEADLESS')), - AGILITY_ELEMENTS: envContent.match(uncommentedLine('AGILITY_ELEMENTS')), - AGILITY_ROOT_PATH: envContent.match(uncommentedLine('AGILITY_ROOT_PATH')), - AGILITY_BASE_URL: envContent.match(uncommentedLine('AGILITY_BASE_URL')), - AGILITY_DEV: envContent.match(uncommentedLine('AGILITY_DEV')), - AGILITY_LOCAL: envContent.match(uncommentedLine('AGILITY_LOCAL')), - AGILITY_PREPROD: envContent.match(uncommentedLine('AGILITY_PREPROD')), - AGILITY_LEGACY_FOLDERS: envContent.match(uncommentedLine('AGILITY_LEGACY_FOLDERS')), - AGILITY_INSECURE: envContent.match(uncommentedLine('AGILITY_INSECURE')), - - AGILITY_MODELS: envContent.match(uncommentedLine('AGILITY_MODELS')), - AGILITY_TOKEN: envContent.match(uncommentedLine('AGILITY_TOKEN')), + AGILITY_GUID: envContent.match(uncommentedLine("AGILITY_GUID")), + AGILITY_TARGET_GUID: envContent.match(uncommentedLine("AGILITY_TARGET_GUID")), + AGILITY_WEBSITE: envContent.match(uncommentedLine("AGILITY_WEBSITE")), + AGILITY_LOCALES: envContent.match(uncommentedLine("AGILITY_LOCALES")), + AGILITY_TEST: envContent.match(uncommentedLine("AGILITY_TEST")), + AGILITY_OVERWRITE: envContent.match(uncommentedLine("AGILITY_OVERWRITE")), + + AGILITY_PREVIEW: envContent.match(uncommentedLine("AGILITY_PREVIEW")), + AGILITY_VERBOSE: envContent.match(uncommentedLine("AGILITY_VERBOSE")), + AGILITY_HEADLESS: envContent.match(uncommentedLine("AGILITY_HEADLESS")), + AGILITY_ELEMENTS: envContent.match(uncommentedLine("AGILITY_ELEMENTS")), + AGILITY_ROOT_PATH: envContent.match(uncommentedLine("AGILITY_ROOT_PATH")), + AGILITY_BASE_URL: envContent.match(uncommentedLine("AGILITY_BASE_URL")), + AGILITY_DEV: envContent.match(uncommentedLine("AGILITY_DEV")), + AGILITY_LOCAL: envContent.match(uncommentedLine("AGILITY_LOCAL")), + AGILITY_PREPROD: envContent.match(uncommentedLine("AGILITY_PREPROD")), + AGILITY_LEGACY_FOLDERS: envContent.match(uncommentedLine("AGILITY_LEGACY_FOLDERS")), + AGILITY_INSECURE: envContent.match(uncommentedLine("AGILITY_INSECURE")), + + AGILITY_MODELS: envContent.match(uncommentedLine("AGILITY_MODELS")), + AGILITY_TOKEN: envContent.match(uncommentedLine("AGILITY_TOKEN")), }; // Only prime state values that aren't already set from command line if (envVars.AGILITY_GUID && envVars.AGILITY_GUID[1] && state.sourceGuid.length === 0) { state.sourceGuid = [envVars.AGILITY_GUID[1].trim()]; - primedValues.push('sourceGuid'); + primedValues.push("sourceGuid"); } if (envVars.AGILITY_WEBSITE && envVars.AGILITY_WEBSITE[1] && !state.channel) { state.channel = envVars.AGILITY_WEBSITE[1].trim(); - primedValues.push('channel'); + primedValues.push("channel"); } if (envVars.AGILITY_LOCALES && envVars.AGILITY_LOCALES[1] && state.locale.length === 0) { - state.locale = envVars.AGILITY_LOCALES[1].trim().split(','); - primedValues.push('locale'); + state.locale = envVars.AGILITY_LOCALES[1].trim().split(","); + primedValues.push("locale"); } // Handle boolean flags - prefer command line args over .env if (envVars.AGILITY_TEST && envVars.AGILITY_TEST[1] && state.test === undefined) { - state.test = envVars.AGILITY_TEST[1].trim().toLowerCase() === 'true'; - primedValues.push('test'); + state.test = envVars.AGILITY_TEST[1].trim().toLowerCase() === "true"; + primedValues.push("test"); } if (envVars.AGILITY_OVERWRITE && envVars.AGILITY_OVERWRITE[1] && state.overwrite === undefined) { - state.overwrite = envVars.AGILITY_OVERWRITE[1].trim().toLowerCase() === 'true'; - primedValues.push('overwrite'); + state.overwrite = envVars.AGILITY_OVERWRITE[1].trim().toLowerCase() === "true"; + primedValues.push("overwrite"); } if (envVars.AGILITY_PREVIEW && envVars.AGILITY_PREVIEW[1] && state.preview === undefined) { - state.preview = envVars.AGILITY_PREVIEW[1].trim().toLowerCase() === 'true'; - primedValues.push('preview'); + state.preview = envVars.AGILITY_PREVIEW[1].trim().toLowerCase() === "true"; + primedValues.push("preview"); } if (envVars.AGILITY_VERBOSE && envVars.AGILITY_VERBOSE[1] && state.verbose === undefined) { - state.verbose = envVars.AGILITY_VERBOSE[1].trim().toLowerCase() === 'true'; - primedValues.push('verbose'); + state.verbose = envVars.AGILITY_VERBOSE[1].trim().toLowerCase() === "true"; + primedValues.push("verbose"); } if (envVars.AGILITY_HEADLESS && envVars.AGILITY_HEADLESS[1] && state.headless === undefined) { - state.headless = envVars.AGILITY_HEADLESS[1].trim().toLowerCase() === 'true'; - primedValues.push('headless'); + state.headless = envVars.AGILITY_HEADLESS[1].trim().toLowerCase() === "true"; + primedValues.push("headless"); } if (envVars.AGILITY_ELEMENTS && envVars.AGILITY_ELEMENTS[1] && !state.elements) { state.elements = envVars.AGILITY_ELEMENTS[1].trim(); - primedValues.push('elements'); + primedValues.push("elements"); } if (envVars.AGILITY_ROOT_PATH && envVars.AGILITY_ROOT_PATH[1] && !state.rootPath) { state.rootPath = envVars.AGILITY_ROOT_PATH[1].trim(); - primedValues.push('rootPath'); + primedValues.push("rootPath"); } if (envVars.AGILITY_BASE_URL && envVars.AGILITY_BASE_URL[1] && !state.baseUrl) { state.baseUrl = envVars.AGILITY_BASE_URL[1].trim(); - primedValues.push('baseUrl'); + primedValues.push("baseUrl"); } // Additional system args if (envVars.AGILITY_TARGET_GUID && envVars.AGILITY_TARGET_GUID[1] && state.targetGuid.length === 0) { state.targetGuid = [envVars.AGILITY_TARGET_GUID[1].trim()]; - primedValues.push('targetGuid'); + primedValues.push("targetGuid"); } if (envVars.AGILITY_DEV && envVars.AGILITY_DEV[1] && state.dev === undefined) { - state.dev = envVars.AGILITY_DEV[1].trim().toLowerCase() === 'true'; - primedValues.push('dev'); + state.dev = envVars.AGILITY_DEV[1].trim().toLowerCase() === "true"; + primedValues.push("dev"); } if (envVars.AGILITY_LOCAL && envVars.AGILITY_LOCAL[1] && state.local === undefined) { - state.local = envVars.AGILITY_LOCAL[1].trim().toLowerCase() === 'true'; - primedValues.push('local'); + state.local = envVars.AGILITY_LOCAL[1].trim().toLowerCase() === "true"; + primedValues.push("local"); } if (envVars.AGILITY_PREPROD && envVars.AGILITY_PREPROD[1] && state.preprod === undefined) { - state.preprod = envVars.AGILITY_PREPROD[1].trim().toLowerCase() === 'true'; - primedValues.push('preprod'); + state.preprod = envVars.AGILITY_PREPROD[1].trim().toLowerCase() === "true"; + primedValues.push("preprod"); } if (envVars.AGILITY_LEGACY_FOLDERS && envVars.AGILITY_LEGACY_FOLDERS[1] && state.legacyFolders === undefined) { - state.legacyFolders = envVars.AGILITY_LEGACY_FOLDERS[1].trim().toLowerCase() === 'true'; - primedValues.push('legacyFolders'); + state.legacyFolders = envVars.AGILITY_LEGACY_FOLDERS[1].trim().toLowerCase() === "true"; + primedValues.push("legacyFolders"); } if (envVars.AGILITY_INSECURE && envVars.AGILITY_INSECURE[1] && state.insecure === undefined) { - state.insecure = envVars.AGILITY_INSECURE[1].trim().toLowerCase() === 'true'; - primedValues.push('insecure'); + state.insecure = envVars.AGILITY_INSECURE[1].trim().toLowerCase() === "true"; + primedValues.push("insecure"); } if (envVars.AGILITY_MODELS && envVars.AGILITY_MODELS[1] && !state.models) { state.models = envVars.AGILITY_MODELS[1].trim(); - primedValues.push('models'); + primedValues.push("models"); } if (envVars.AGILITY_TOKEN && envVars.AGILITY_TOKEN[1] && !state.token) { // Strip quotes from token value if present let tokenValue = envVars.AGILITY_TOKEN[1].trim(); - if ((tokenValue.startsWith('"') && tokenValue.endsWith('"')) || - (tokenValue.startsWith("'") && tokenValue.endsWith("'"))) { + if ( + (tokenValue.startsWith('"') && tokenValue.endsWith('"')) || + (tokenValue.startsWith("'") && tokenValue.endsWith("'")) + ) { tokenValue = tokenValue.slice(1, -1).trim(); } // Only prime token when we actually have a non-empty value if (tokenValue.length > 0) { state.token = tokenValue; process.env.AGILITY_TOKEN = tokenValue; - primedValues.push('token'); + primedValues.push("token"); } } @@ -498,7 +503,7 @@ export function resetState() { // Workflow operation control state.operationType = undefined; state.dryRun = false; - state.autoPublish = ''; + state.autoPublish = ""; // Explicit ID overrides state.explicitContentIDs = []; @@ -551,8 +556,8 @@ export function getApiClient(): mgmtApi.ApiClient { // throw new Error('Management API options not initialized. Call auth.init() first.'); } - if(!state.mgmtApiOptions && !state.token) { - throw new Error('Management API options not initialized. Call auth.init() first.'); + if (!state.mgmtApiOptions && !state.token) { + throw new Error("Management API options not initialized. Call auth.init() first."); } else if (!state.mgmtApiOptions && state.token) { state.mgmtApiOptions = new Options(); state.mgmtApiOptions.token = state.token; @@ -597,7 +602,7 @@ export function getUIMode() { * Get API keys for a specific GUID */ export function getApiKeysForGuid(guid: string): { previewKey: string; fetchKey: string } | null { - const apiKeyEntry = state.apiKeys.find(item => item.guid === guid); + const apiKeyEntry = state.apiKeys.find((item) => item.guid === guid); return apiKeyEntry ? { previewKey: apiKeyEntry.previewKey, fetchKey: apiKeyEntry.fetchKey } : null; } @@ -619,7 +624,7 @@ export function validateLocaleFormat(locale: string): boolean { /** * Validate array of locales and return valid/invalid splits */ -export function validateLocales(locales: string[]): { valid: string[], invalid: string[] } { +export function validateLocales(locales: string[]): { valid: string[]; invalid: string[] } { const valid: string[] = []; const invalid: string[] = []; @@ -639,15 +644,15 @@ export function validateLocales(locales: string[]): { valid: string[], invalid: */ export function initializeLogger(operationType: OperationType): Logs { state.logger = new Logs(operationType); - + // Configure based on current state state.logger.configure({ logToConsole: !state.headless, logToFile: true, showColors: !state.headless, - useStructuredFormat: true + useStructuredFormat: true, }); - + return state.logger; } @@ -658,17 +663,17 @@ export function initializeGuidLogger(guid: string, operationType: OperationType, if (!state.loggerRegistry) { state.loggerRegistry = new Map(); } - + const logger = new Logs(operationType, entityType, guid); - + // Configure based on current state logger.configure({ logToConsole: !state.headless, logToFile: true, showColors: !state.headless, - useStructuredFormat: true + useStructuredFormat: true, }); - + state.loggerRegistry.set(guid, logger); return logger; } @@ -680,13 +685,13 @@ export function getLoggerForGuid(guid: string): Logs | null { if (!state.loggerRegistry) { return null; } - + const logger = state.loggerRegistry.get(guid); if (logger && !logger.getGuid()) { // Ensure the logger has the GUID set logger.setGuid(guid); } - + return logger || null; } @@ -717,13 +722,13 @@ export function finalizeGuidLogger(guid: string): string | null { */ export function finalizeAllGuidLoggers(): string[] { const results: string[] = []; - + if (state.loggerRegistry) { const entries = Array.from(state.loggerRegistry.entries()); - + for (const [guid, logger] of entries) { const logCount = logger.getLogCount(); - + if (logCount > 0) { const result = logger.saveLogs(); if (result) { @@ -734,7 +739,7 @@ export function finalizeAllGuidLoggers(): string[] { } state.loggerRegistry.clear(); } - + return results; } @@ -745,7 +750,7 @@ export function finalizeLogger(): string | null { if (state.logger) { const result = state.logger.saveLogs(); state.logger = undefined; - + // Return result without automatically displaying it // The calling code will handle display if needed return result; @@ -765,7 +770,6 @@ export function endTimer(): void { } } - /** * Clear the current logger from state */ @@ -786,12 +790,7 @@ export function clearLogger(): void { * @param error - The error message * @param locale - The locale being processed */ -export function registerFailedContent( - contentID: number, - referenceName: string, - error: string, - locale: string -): void { +export function registerFailedContent(contentID: number, referenceName: string, error: string, locale: string): void { state.failedContentRegistry.set(contentID, { referenceName, error, locale }); } @@ -800,7 +799,9 @@ export function registerFailedContent( * @param contentID - The source content ID to look up * @returns The failure info if found, or undefined */ -export function getFailedContent(contentID: number): { referenceName: string; error: string; locale: string } | undefined { +export function getFailedContent( + contentID: number +): { referenceName: string; error: string; locale: string } | undefined { return state.failedContentRegistry.get(contentID); } @@ -818,9 +819,9 @@ export function clearFailedContentRegistry(): void { */ export function getCmsAppUrl(): string { if (state.dev || state.local || state.preprod) { - return 'https://app-qa.publishwithagility.com'; + return "https://app-qa.publishwithagility.com"; } - return 'https://app.agilitycms.com'; + return "https://app.agilitycms.com"; } /** @@ -843,9 +844,9 @@ export function getContentCmsLink(guid: string, locale: string, contentID: numbe * Check if a content item file exists in source data */ export function contentExistsInSourceData(guid: string, locale: string, contentID: number): boolean { - const fs = require('fs'); - const path = require('path'); - const contentPath = path.resolve(state.rootPath, guid, locale, 'item', `${contentID}.json`); + const fs = require("fs"); + const path = require("path"); + const contentPath = path.resolve(state.rootPath, guid, locale, "item", `${contentID}.json`); return fs.existsSync(contentPath); } @@ -856,8 +857,8 @@ export function contentExistsInSourceData(guid: string, locale: string, contentI * Returns the locale where it exists, or null if not found anywhere. */ export function contentExistsInOtherLocale(guid: string, currentLocale: string, contentID: number): string | null { - const fs = require('fs'); - const path = require('path'); + const fs = require("fs"); + const path = require("path"); const validLocales = (state.availableLocales || []).filter((l) => l !== currentLocale); if (validLocales.length === 0) return null; @@ -866,7 +867,7 @@ export function contentExistsInOtherLocale(guid: string, currentLocale: string, if (!fs.existsSync(guidPath)) return null; for (const locale of validLocales) { - const contentPath = path.join(guidPath, locale, 'item', `${contentID}.json`); + const contentPath = path.join(guidPath, locale, "item", `${contentID}.json`); if (fs.existsSync(contentPath)) { return locale; } diff --git a/src/core/system-args.ts b/src/core/system-args.ts index ebfbd78..90e1b33 100644 --- a/src/core/system-args.ts +++ b/src/core/system-args.ts @@ -8,7 +8,6 @@ * These should be spread into command builders: ...systemArgs */ export const systemArgs = { - // tokens token: { describe: "Provide your personal access token. Or use AGILITY_TOKEN from .env file if available.", @@ -62,17 +61,19 @@ export const systemArgs = { // Instance/Connection args locale: { - describe: "Provide locale(s) for the operation. Comma-separated for multiple locales (e.g., 'en-us,en-ca,fr-fr'). If not provided, all available locales will be auto-detected and used.", + describe: + "Provide locale(s) for the operation. Comma-separated for multiple locales (e.g., 'en-us,en-ca,fr-fr'). If not provided, all available locales will be auto-detected and used.", demandOption: false, type: "string" as const, alias: ["locales", "Locales", "LOCALES"], // No default - auto-detection when not specified }, channel: { - describe: "Provide the channel for the operation. If not provided, will use AGILITY_WEBSITE from .env file if available.", + describe: + "Provide the channel for the operation. If not provided, will use AGILITY_WEBSITE from .env file if available.", demandOption: false, type: "string" as const, - default: "website" + default: "website", }, preview: { describe: "Whether to use preview or live environment data.", @@ -81,7 +82,8 @@ export const systemArgs = { default: true, }, elements: { - describe: "Comma-separated list of elements to process (Models,Galleries,Assets,Containers,Content,Templates,Pages,Sitemaps)", + describe: + "Comma-separated list of elements to process (Models,Galleries,Assets,Containers,Content,Templates,Pages,Sitemaps)", demandOption: false, type: "string" as const, default: "Models,Galleries,Assets,Containers,Content,Templates,Pages,Sitemaps", @@ -95,16 +97,13 @@ export const systemArgs = { }, baseUrl: { describe: "(Optional) Specify a base URL for the Agility API, if different from default.", - type: "string" as const + type: "string" as const, }, - - - - // **NEW: Selective Model-Based Sync Parameter (Task 103)** models: { - describe: "Comma-separated list of model reference names to sync. Filters only specified models and their direct content.", + describe: + "Comma-separated list of model reference names to sync. Filters only specified models and their direct content.", demandOption: false, alias: ["model", "Model", "MODEL"], type: "string" as const, @@ -113,7 +112,8 @@ export const systemArgs = { // **NEW: Model-Based Sync with Dependencies (Task 20.2)** modelsWithDeps: { - describe: "Comma-separated list of model reference names to sync with full dependency tree. Automatically includes all dependent content, pages, assets, galleries, templates, and containers.", + describe: + "Comma-separated list of model reference names to sync with full dependency tree. Automatically includes all dependent content, pages, assets, galleries, templates, and containers.", demandOption: false, alias: ["models-with-deps", "modelswithDeps", "ModelsWithDeps", "MODELSWITHSDEPS"], type: "string" as const, @@ -122,13 +122,15 @@ export const systemArgs = { // Debug/Analysis args test: { - describe: "Enable test mode: bypasses authentication checks for analysis-only operations. Shows detailed analysis and debugging information.", + describe: + "Enable test mode: bypasses authentication checks for analysis-only operations. Shows detailed analysis and debugging information.", demandOption: false, type: "boolean" as const, default: false, }, dryRun: { - describe: "Dry run mode: show what items would be processed without executing the operation. Useful for previewing workflow operations.", + describe: + "Dry run mode: show what items would be processed without executing the operation. Useful for previewing workflow operations.", demandOption: false, type: "boolean" as const, alias: ["dry-run", "dryrun", "DryRun", "DRY_RUN"], @@ -137,14 +139,16 @@ export const systemArgs = { // **Explicit ID Override for Workflow Operations** contentIDs: { - describe: "Comma-separated list of target content IDs to process. Bypasses mappings lookup when provided (e.g., --contentIDs=121,1221,345).", + describe: + "Comma-separated list of target content IDs to process. Bypasses mappings lookup when provided (e.g., --contentIDs=121,1221,345).", demandOption: false, alias: ["content-ids", "contentIds", "ContentIDs", "CONTENTIDS"], type: "string" as const, default: "", }, pageIDs: { - describe: "Comma-separated list of target page IDs to process. Bypasses mappings lookup when provided (e.g., --pageIDs=12,11,45).", + describe: + "Comma-separated list of target page IDs to process. Bypasses mappings lookup when provided (e.g., --pageIDs=12,11,45).", demandOption: false, alias: ["page-ids", "pageIds", "PageIDs", "PAGEIDS"], type: "string" as const, @@ -153,59 +157,91 @@ export const systemArgs = { // Instance identification args sourceGuid: { - describe: "Provide the source instance GUID(s). Comma-separated for multiple instances (e.g., 'guid1,guid2,guid3'). If not provided, will use AGILITY_GUID from .env file if available.", - alias: ["source-guid","sourceGuid","sourceguid", "source", "SourceGuid", "SourceGUID", "SOURCE", "SOURCEGUID", "sourceGuids", "source-guids", "SourceGuids", "SOURCEGUIDS"], + describe: + "Provide the source instance GUID(s). Comma-separated for multiple instances (e.g., 'guid1,guid2,guid3'). If not provided, will use AGILITY_GUID from .env file if available.", + alias: [ + "source-guid", + "sourceGuid", + "sourceguid", + "source", + "SourceGuid", + "SourceGUID", + "SOURCE", + "SOURCEGUID", + "sourceGuids", + "source-guids", + "SourceGuids", + "SOURCEGUIDS", + ], demandOption: false, type: "string" as const, }, targetGuid: { - describe: "Provide the target instance GUID(s) for sync operations. Comma-separated for multiple instances (e.g., 'guid1,guid2,guid3').", - alias: ["target-guid","targetGuid","targetguid", "target", "TargetGuid", "TargetGUID", "TARGET", "TARGETGUID", "targetGuids", "target-guids", "TargetGuids", "TARGETGUIDS"], + describe: + "Provide the target instance GUID(s) for sync operations. Comma-separated for multiple instances (e.g., 'guid1,guid2,guid3').", + alias: [ + "target-guid", + "targetGuid", + "targetguid", + "target", + "TargetGuid", + "TargetGUID", + "TARGET", + "TARGETGUID", + "targetGuids", + "target-guids", + "TargetGuids", + "TARGETGUIDS", + ], demandOption: false, type: "string" as const, }, // Force operation args overwrite: { - describe: "For sync commands only: force update existing items in target instance instead of creating new items with -1 IDs. Default: false (safer behavior to prevent overwriting existing content).", + describe: + "For sync commands only: force update existing items in target instance instead of creating new items with -1 IDs. Default: false (safer behavior to prevent overwriting existing content).", type: "boolean" as const, alias: ["overwrite", "Overwrite", "OVERWRITE"], - default: false + default: false, }, force: { - describe: "Override target safety conflicts during sync operations. When target instance has changes AND change delta has updates, --force will apply sync changes anyway. Default: false (safer behavior to prevent data loss).", + describe: + "Override target safety conflicts during sync operations. When target instance has changes AND change delta has updates, --force will apply sync changes anyway. Default: false (safer behavior to prevent data loss).", type: "boolean" as const, alias: ["force", "Force", "FORCE"], - default: false + default: false, }, update: { - describe: "Controls file downloading behavior. --update=false (default): Skip existing files during download (normal efficient behavior). --update=true: Force download/overwrite existing files and clear sync tokens for complete refresh.", + describe: + "Controls file downloading behavior. --update=false (default): Skip existing files during download (normal efficient behavior). --update=true: Force download/overwrite existing files and clear sync tokens for complete refresh.", type: "boolean" as const, alias: ["reset", "Reset", "RESET", "forceUpdate", "ForceUpdate", "FORCEUPDATE"], - default: false + default: false, }, reset: { - describe: "Nuclear reset option: completely delete instance GUID folder including sync tokens. Forces full fresh download for all SDKs. To reset only Content Sync SDK: manually delete agility-files/GUID/locale/preview/state folder. Default: false.", + describe: + "Nuclear reset option: completely delete instance GUID folder including sync tokens. Forces full fresh download for all SDKs. To reset only Content Sync SDK: manually delete agility-files/GUID/locale/preview/state folder. Default: false.", type: "boolean" as const, - default: false + default: false, }, // Auto-publish after sync autoPublish: { - describe: "Automatically publish content and/or pages after sync completes. Options: 'content' (publish only content), 'pages' (publish only pages), 'both' (publish content and pages). Default: both when flag is provided.", + describe: + "Automatically publish content and/or pages after sync completes. Options: 'content' (publish only content), 'pages' (publish only pages), 'both' (publish content and pages). Default: both when flag is provided.", demandOption: false, alias: ["auto-publish", "autoPublish", "AutoPublish", "AUTO_PUBLISH", "autopublish"], type: "string" as const, coerce: (value: string | boolean) => { // Handle --autoPublish without value (defaults to 'both') - if (value === true || value === '') return 'both'; - if (value === false) return ''; + if (value === true || value === "") return "both"; + if (value === false) return ""; const lower = String(value).toLowerCase(); - if (['content', 'pages', 'both'].includes(lower)) return lower; - return 'both'; // Default to 'both' for any other value - } + if (["content", "pages", "both"].includes(lower)) return lower; + return "both"; // Default to 'both' for any other value + }, }, - }; /** diff --git a/src/core/tests/arg-normalizer.test.ts b/src/core/tests/arg-normalizer.test.ts index a03812d..01ec330 100644 --- a/src/core/tests/arg-normalizer.test.ts +++ b/src/core/tests/arg-normalizer.test.ts @@ -1,141 +1,141 @@ -import { normalizeProcessArgs, normalizeArgv } from '../arg-normalizer'; +import { normalizeProcessArgs, normalizeArgv } from "../arg-normalizer"; beforeEach(() => { // Reset process.argv to a clean baseline before each test - process.argv = ['node', 'script.js']; + process.argv = ["node", "script.js"]; }); -describe('normalizeProcessArgs', () => { - it('returns false when no normalization needed', () => { - process.argv = ['node', 'script.js', '--sourceGuid', 'abc123']; +describe("normalizeProcessArgs", () => { + it("returns false when no normalization needed", () => { + process.argv = ["node", "script.js", "--sourceGuid", "abc123"]; expect(normalizeProcessArgs()).toBe(false); }); - it('replaces em dash with double hyphen', () => { - process.argv = ['node', 'script.js', '—models-with-deps']; + it("replaces em dash with double hyphen", () => { + process.argv = ["node", "script.js", "—models-with-deps"]; expect(normalizeProcessArgs()).toBe(true); - expect(process.argv[2]).toBe('--models-with-deps'); + expect(process.argv[2]).toBe("--models-with-deps"); }); - it('replaces en dash with double hyphen', () => { - process.argv = ['node', 'script.js', '–sourceGuid']; + it("replaces en dash with double hyphen", () => { + process.argv = ["node", "script.js", "–sourceGuid"]; expect(normalizeProcessArgs()).toBe(true); - expect(process.argv[2]).toBe('--sourceGuid'); + expect(process.argv[2]).toBe("--sourceGuid"); }); - it('replaces left/right double curly quotes with straight quotes', () => { - process.argv = ['node', 'script.js', '“hello”']; + it("replaces left/right double curly quotes with straight quotes", () => { + process.argv = ["node", "script.js", "“hello”"]; expect(normalizeProcessArgs()).toBe(true); expect(process.argv[2]).toBe('"hello"'); }); - it('replaces left/right single curly quotes with straight quotes', () => { - process.argv = ['node', 'script.js', '‘hello’']; + it("replaces left/right single curly quotes with straight quotes", () => { + process.argv = ["node", "script.js", "‘hello’"]; expect(normalizeProcessArgs()).toBe(true); expect(process.argv[2]).toBe("'hello'"); }); - it('does not touch argv[0] or argv[1] (node and script path)', () => { - const nodeExe = 'node'; - const scriptPath = '/usr/bin/script.js'; - process.argv = [nodeExe, scriptPath, '—flag']; + it("does not touch argv[0] or argv[1] (node and script path)", () => { + const nodeExe = "node"; + const scriptPath = "/usr/bin/script.js"; + process.argv = [nodeExe, scriptPath, "—flag"]; normalizeProcessArgs(); expect(process.argv[0]).toBe(nodeExe); expect(process.argv[1]).toBe(scriptPath); }); - it('normalizes multiple args in one pass', () => { - process.argv = ['node', 'script.js', '—sourceGuid', '“my-guid”']; + it("normalizes multiple args in one pass", () => { + process.argv = ["node", "script.js", "—sourceGuid", "“my-guid”"]; expect(normalizeProcessArgs()).toBe(true); - expect(process.argv[2]).toBe('--sourceGuid'); + expect(process.argv[2]).toBe("--sourceGuid"); expect(process.argv[3]).toBe('"my-guid"'); }); - it('returns false when argv has only node and script (no user args)', () => { - process.argv = ['node', 'script.js']; + it("returns false when argv has only node and script (no user args)", () => { + process.argv = ["node", "script.js"]; expect(normalizeProcessArgs()).toBe(false); }); }); -describe('normalizeArgv', () => { - describe('null / undefined / primitives', () => { - it('returns null unchanged', () => expect(normalizeArgv(null)).toBeNull()); - it('returns undefined unchanged', () => expect(normalizeArgv(undefined)).toBeUndefined()); - it('returns numbers unchanged', () => expect(normalizeArgv(42)).toBe(42)); - it('returns booleans unchanged', () => expect(normalizeArgv(true)).toBe(true)); +describe("normalizeArgv", () => { + describe("null / undefined / primitives", () => { + it("returns null unchanged", () => expect(normalizeArgv(null)).toBeNull()); + it("returns undefined unchanged", () => expect(normalizeArgv(undefined)).toBeUndefined()); + it("returns numbers unchanged", () => expect(normalizeArgv(42)).toBe(42)); + it("returns booleans unchanged", () => expect(normalizeArgv(true)).toBe(true)); }); - describe('string normalization', () => { - it('replaces em dash in string values', () => { - expect(normalizeArgv('—flag')).toBe('--flag'); + describe("string normalization", () => { + it("replaces em dash in string values", () => { + expect(normalizeArgv("—flag")).toBe("--flag"); }); - it('replaces en dash in string values', () => { - expect(normalizeArgv('–flag')).toBe('--flag'); + it("replaces en dash in string values", () => { + expect(normalizeArgv("–flag")).toBe("--flag"); }); - it('replaces curly double quotes', () => { - expect(normalizeArgv('“hello”')).toBe('hello'); + it("replaces curly double quotes", () => { + expect(normalizeArgv("“hello”")).toBe("hello"); }); - it('replaces curly single quotes', () => { - expect(normalizeArgv('‘hello’')).toBe('hello'); + it("replaces curly single quotes", () => { + expect(normalizeArgv("‘hello’")).toBe("hello"); }); - it('strips leading/trailing straight quotes', () => { - expect(normalizeArgv('"guid-value"')).toBe('guid-value'); - expect(normalizeArgv("'guid-value'")).toBe('guid-value'); + it("strips leading/trailing straight quotes", () => { + expect(normalizeArgv('"guid-value"')).toBe("guid-value"); + expect(normalizeArgv("'guid-value'")).toBe("guid-value"); }); - it('leaves clean strings unchanged', () => { - expect(normalizeArgv('abc-123')).toBe('abc-123'); + it("leaves clean strings unchanged", () => { + expect(normalizeArgv("abc-123")).toBe("abc-123"); }); - it('leaves empty string unchanged', () => { - expect(normalizeArgv('')).toBe(''); + it("leaves empty string unchanged", () => { + expect(normalizeArgv("")).toBe(""); }); }); - describe('array handling', () => { - it('normalizes each element in an array', () => { - const input = ['—flag', 'clean', '“quoted”']; - expect(normalizeArgv(input)).toEqual(['--flag', 'clean', 'quoted']); + describe("array handling", () => { + it("normalizes each element in an array", () => { + const input = ["—flag", "clean", "“quoted”"]; + expect(normalizeArgv(input)).toEqual(["--flag", "clean", "quoted"]); }); - it('leaves non-string array elements unchanged', () => { + it("leaves non-string array elements unchanged", () => { expect(normalizeArgv([1, true, null])).toEqual([1, true, null]); }); - it('returns empty array unchanged', () => { + it("returns empty array unchanged", () => { expect(normalizeArgv([])).toEqual([]); }); }); - describe('object handling', () => { - it('normalizes string values in a plain object', () => { - const input = { sourceGuid: '“my-guid”', locale: 'en-us' }; - expect(normalizeArgv(input)).toEqual({ sourceGuid: 'my-guid', locale: 'en-us' }); + describe("object handling", () => { + it("normalizes string values in a plain object", () => { + const input = { sourceGuid: "“my-guid”", locale: "en-us" }; + expect(normalizeArgv(input)).toEqual({ sourceGuid: "my-guid", locale: "en-us" }); }); - it('preserves _ and $0 keys unchanged', () => { - const input = { _: [], $0: 'agility', sourceGuid: '“my-guid”' }; + it("preserves _ and $0 keys unchanged", () => { + const input = { _: [], $0: "agility", sourceGuid: "“my-guid”" }; const result = normalizeArgv(input); expect(result._).toEqual([]); - expect(result.$0).toBe('agility'); - expect(result.sourceGuid).toBe('my-guid'); + expect(result.$0).toBe("agility"); + expect(result.sourceGuid).toBe("my-guid"); }); - it('normalizes nested string values recursively', () => { - const input = { nested: { value: '—flag' } }; - expect(normalizeArgv(input)).toEqual({ nested: { value: '--flag' } }); + it("normalizes nested string values recursively", () => { + const input = { nested: { value: "—flag" } }; + expect(normalizeArgv(input)).toEqual({ nested: { value: "--flag" } }); }); - it('normalizes string arrays within objects', () => { - const input = { models: ['“model1”', 'model2'] }; - expect(normalizeArgv(input)).toEqual({ models: ['model1', 'model2'] }); + it("normalizes string arrays within objects", () => { + const input = { models: ["“model1”", "model2"] }; + expect(normalizeArgv(input)).toEqual({ models: ["model1", "model2"] }); }); - it('leaves numeric and boolean object values unchanged', () => { + it("leaves numeric and boolean object values unchanged", () => { const input = { count: 5, active: false }; expect(normalizeArgv(input)).toEqual({ count: 5, active: false }); }); diff --git a/src/core/tests/assets.test.ts b/src/core/tests/assets.test.ts index 154d0fc..b5423d1 100644 --- a/src/core/tests/assets.test.ts +++ b/src/core/tests/assets.test.ts @@ -1,14 +1,14 @@ -import { assets } from '../assets'; -import { fileOperations } from '../fileOperations'; -import { resetState, setState } from '../state'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; +import { assets } from "../assets"; +import { fileOperations } from "../fileOperations"; +import { resetState, setState } from "../state"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-assets-tests-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-assets-tests-")); }); afterAll(() => { @@ -18,9 +18,9 @@ afterAll(() => { beforeEach(() => { resetState(); setState({ rootPath: tmpDir }); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -40,29 +40,29 @@ function makeMultibarStub() { // ─── Constructor ────────────────────────────────────────────────────────────── -describe('assets constructor', () => { - it('creates an instance without throwing', () => { - const fileOps = new fileOperations('test-guid', 'en-us'); +describe("assets constructor", () => { + it("creates an instance without throwing", () => { + const fileOps = new fileOperations("test-guid", "en-us"); const multibar = makeMultibarStub(); expect(() => new assets({} as any, multibar, fileOps)).not.toThrow(); }); - it('initializes unProcessedAssets as an empty object', () => { - const fileOps = new fileOperations('test-guid', 'en-us'); + it("initializes unProcessedAssets as an empty object", () => { + const fileOps = new fileOperations("test-guid", "en-us"); const multibar = makeMultibarStub(); const instance = new assets({} as any, multibar, fileOps); expect(instance.unProcessedAssets).toEqual({}); }); - it('accepts an optional progressCallback', () => { - const fileOps = new fileOperations('test-guid', 'en-us'); + it("accepts an optional progressCallback", () => { + const fileOps = new fileOperations("test-guid", "en-us"); const multibar = makeMultibarStub(); const cb = jest.fn(); expect(() => new assets({} as any, multibar, fileOps, false, cb)).not.toThrow(); }); - it('accepts legacyFolders flag', () => { - const fileOps = new fileOperations('test-guid', 'en-us'); + it("accepts legacyFolders flag", () => { + const fileOps = new fileOperations("test-guid", "en-us"); const multibar = makeMultibarStub(); expect(() => new assets({} as any, multibar, fileOps, true)).not.toThrow(); }); diff --git a/src/core/tests/auth.test.ts b/src/core/tests/auth.test.ts index 741dee6..da8e44e 100644 --- a/src/core/tests/auth.test.ts +++ b/src/core/tests/auth.test.ts @@ -1,13 +1,13 @@ -import { Auth } from '../auth'; -import { resetState, setState, getState } from '../state'; +import { Auth } from "../auth"; +import { resetState, setState, getState } from "../state"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); // Clear argv token flags between tests - process.argv = ['node', 'script.js']; + process.argv = ["node", "script.js"]; delete process.env.AGILITY_TOKEN; }); @@ -17,193 +17,193 @@ afterEach(() => { // ─── getEnv ──────────────────────────────────────────────────────────────────── -describe('Auth.getEnv', () => { +describe("Auth.getEnv", () => { it('returns "prod" by default', () => { const auth = new Auth(); - expect(auth.getEnv()).toBe('prod'); + expect(auth.getEnv()).toBe("prod"); }); it('returns "local" when state.local is true', () => { setState({ local: true }); const auth = new Auth(); - expect(auth.getEnv()).toBe('local'); + expect(auth.getEnv()).toBe("local"); }); it('returns "dev" when state.dev is true', () => { setState({ dev: true }); const auth = new Auth(); - expect(auth.getEnv()).toBe('dev'); + expect(auth.getEnv()).toBe("dev"); }); it('returns "preprod" when state.preprod is true', () => { setState({ preprod: true }); const auth = new Auth(); - expect(auth.getEnv()).toBe('preprod'); + expect(auth.getEnv()).toBe("preprod"); }); - it('local takes priority over dev', () => { + it("local takes priority over dev", () => { setState({ local: true, dev: true }); const auth = new Auth(); - expect(auth.getEnv()).toBe('local'); + expect(auth.getEnv()).toBe("local"); }); }); // ─── getEnvKey ───────────────────────────────────────────────────────────────── -describe('Auth.getEnvKey', () => { - it('returns the correct key format for prod', () => { +describe("Auth.getEnvKey", () => { + it("returns the correct key format for prod", () => { const auth = new Auth(); - expect(auth.getEnvKey('prod')).toBe('cli-auth-token:prod'); + expect(auth.getEnvKey("prod")).toBe("cli-auth-token:prod"); }); - it('returns the correct key format for dev', () => { + it("returns the correct key format for dev", () => { const auth = new Auth(); - expect(auth.getEnvKey('dev')).toBe('cli-auth-token:dev'); + expect(auth.getEnvKey("dev")).toBe("cli-auth-token:dev"); }); }); // ─── determineBaseUrl ────────────────────────────────────────────────────────── -describe('Auth.determineBaseUrl', () => { +describe("Auth.determineBaseUrl", () => { it('returns US mgmt URL for GUID ending in "u"', () => { const auth = new Auth(); - expect(auth.determineBaseUrl('my-instance-guid-u')).toBe('https://mgmt.aglty.io'); + expect(auth.determineBaseUrl("my-instance-guid-u")).toBe("https://mgmt.aglty.io"); }); it('returns CA mgmt URL for GUID ending in "c"', () => { const auth = new Auth(); - expect(auth.determineBaseUrl('my-instance-guid-c')).toBe('https://mgmt-ca.aglty.io'); + expect(auth.determineBaseUrl("my-instance-guid-c")).toBe("https://mgmt-ca.aglty.io"); }); it('returns EU mgmt URL for GUID ending in "e"', () => { const auth = new Auth(); - expect(auth.determineBaseUrl('my-instance-e')).toBe('https://mgmt-eu.aglty.io'); + expect(auth.determineBaseUrl("my-instance-e")).toBe("https://mgmt-eu.aglty.io"); }); it('returns AUS mgmt URL for GUID ending in "a"', () => { const auth = new Auth(); - expect(auth.determineBaseUrl('my-instance-a')).toBe('https://mgmt-aus.aglty.io'); + expect(auth.determineBaseUrl("my-instance-a")).toBe("https://mgmt-aus.aglty.io"); }); it('returns dev URL for GUID ending in "d"', () => { const auth = new Auth(); - expect(auth.determineBaseUrl('my-instance-d')).toBe('https://mgmt-dev.aglty.io'); + expect(auth.determineBaseUrl("my-instance-d")).toBe("https://mgmt-dev.aglty.io"); }); it('returns US2 URL for GUID ending in "us2"', () => { const auth = new Auth(); - expect(auth.determineBaseUrl('my-instance-us2')).toBe('https://mgmt-usa2.aglty.io'); + expect(auth.determineBaseUrl("my-instance-us2")).toBe("https://mgmt-usa2.aglty.io"); }); - it('returns localhost when state.local is true', () => { + it("returns localhost when state.local is true", () => { setState({ local: true }); const auth = new Auth(); - expect(auth.determineBaseUrl('any-guid')).toBe('https://localhost:5050'); + expect(auth.determineBaseUrl("any-guid")).toBe("https://localhost:5050"); }); - it('returns dev URL when state.dev is true', () => { + it("returns dev URL when state.dev is true", () => { setState({ dev: true }); const auth = new Auth(); - expect(auth.determineBaseUrl('any-guid')).toBe('https://mgmt-dev.aglty.io'); + expect(auth.determineBaseUrl("any-guid")).toBe("https://mgmt-dev.aglty.io"); }); - it('respects state.baseUrl override', () => { - setState({ baseUrl: 'https://custom.example.com' }); + it("respects state.baseUrl override", () => { + setState({ baseUrl: "https://custom.example.com" }); const auth = new Auth(); - expect(auth.determineBaseUrl('any-guid-u')).toBe('https://custom.example.com'); + expect(auth.determineBaseUrl("any-guid-u")).toBe("https://custom.example.com"); }); - it('returns default US URL when no GUID is given and no state flags', () => { + it("returns default US URL when no GUID is given and no state flags", () => { const auth = new Auth(); - expect(auth.determineBaseUrl()).toBe('https://mgmt.aglty.io'); + expect(auth.determineBaseUrl()).toBe("https://mgmt.aglty.io"); }); - it('falls back to sourceGuid[0] when no explicit guid is provided', () => { - setState({ sourceGuid: 'my-guid-c' }); + it("falls back to sourceGuid[0] when no explicit guid is provided", () => { + setState({ sourceGuid: "my-guid-c" }); const auth = new Auth(); - expect(auth.determineBaseUrl()).toBe('https://mgmt-ca.aglty.io'); + expect(auth.determineBaseUrl()).toBe("https://mgmt-ca.aglty.io"); }); }); // ─── determineFetchUrl ──────────────────────────────────────────────────────── -describe('Auth.determineFetchUrl', () => { +describe("Auth.determineFetchUrl", () => { it('returns US fetch URL for GUID ending in "u"', () => { const auth = new Auth(); - expect(auth.determineFetchUrl('my-guid-u')).toBe('https://api.aglty.io'); + expect(auth.determineFetchUrl("my-guid-u")).toBe("https://api.aglty.io"); }); it('returns CA fetch URL for GUID ending in "c"', () => { const auth = new Auth(); - expect(auth.determineFetchUrl('my-guid-c')).toBe('https://api-ca.aglty.io'); + expect(auth.determineFetchUrl("my-guid-c")).toBe("https://api-ca.aglty.io"); }); it('returns EU fetch URL for GUID ending in "e"', () => { const auth = new Auth(); - expect(auth.determineFetchUrl('my-guid-e')).toBe('https://api-eu.aglty.io'); + expect(auth.determineFetchUrl("my-guid-e")).toBe("https://api-eu.aglty.io"); }); it('returns AUS fetch URL for GUID ending in "a"', () => { const auth = new Auth(); - expect(auth.determineFetchUrl('my-guid-a')).toBe('https://api-aus.aglty.io'); + expect(auth.determineFetchUrl("my-guid-a")).toBe("https://api-aus.aglty.io"); }); it('returns dev fetch URL for GUID ending in "d"', () => { const auth = new Auth(); - expect(auth.determineFetchUrl('my-guid-d')).toBe('https://api-dev.aglty.io'); + expect(auth.determineFetchUrl("my-guid-d")).toBe("https://api-dev.aglty.io"); }); it('returns US2 fetch URL for GUID ending in "us2"', () => { const auth = new Auth(); - expect(auth.determineFetchUrl('my-guid-us2')).toBe('https://api-usa2.aglty.io'); + expect(auth.determineFetchUrl("my-guid-us2")).toBe("https://api-usa2.aglty.io"); }); - it('does NOT switch to localhost even when state.local is true (fetch API is always cloud)', () => { + it("does NOT switch to localhost even when state.local is true (fetch API is always cloud)", () => { setState({ local: true }); const auth = new Auth(); - expect(auth.determineFetchUrl('my-guid-u')).toBe('https://api.aglty.io'); + expect(auth.determineFetchUrl("my-guid-u")).toBe("https://api.aglty.io"); }); - it('returns default US fetch URL when no guid provided', () => { + it("returns default US fetch URL when no guid provided", () => { const auth = new Auth(); - expect(auth.determineFetchUrl()).toBe('https://api.aglty.io'); + expect(auth.determineFetchUrl()).toBe("https://api.aglty.io"); }); }); // ─── determineCloudMgmtUrl ──────────────────────────────────────────────────── -describe('Auth.determineCloudMgmtUrl', () => { - it('always returns cloud URL even when local flag is set', () => { +describe("Auth.determineCloudMgmtUrl", () => { + it("always returns cloud URL even when local flag is set", () => { setState({ local: true }); const auth = new Auth(); - expect(auth.determineCloudMgmtUrl('my-guid-u')).toBe('https://mgmt.aglty.io'); + expect(auth.determineCloudMgmtUrl("my-guid-u")).toBe("https://mgmt.aglty.io"); }); it('returns CA cloud mgmt URL for GUID ending in "c"', () => { const auth = new Auth(); - expect(auth.determineCloudMgmtUrl('my-guid-c')).toBe('https://mgmt-ca.aglty.io'); + expect(auth.determineCloudMgmtUrl("my-guid-c")).toBe("https://mgmt-ca.aglty.io"); }); }); // ─── getBaseUrl ─────────────────────────────────────────────────────────────── -describe('Auth.getBaseUrl', () => { - it('appends /oauth to the management base URL', () => { +describe("Auth.getBaseUrl", () => { + it("appends /oauth to the management base URL", () => { const auth = new Auth(); - const result = auth.getBaseUrl('my-guid-u'); - expect(result).toBe('https://mgmt.aglty.io/oauth'); + const result = auth.getBaseUrl("my-guid-u"); + expect(result).toBe("https://mgmt.aglty.io/oauth"); }); }); // ─── shouldSkipPermissionCheck ──────────────────────────────────────────────── -describe('Auth.shouldSkipPermissionCheck', () => { - it('returns false by default', () => { +describe("Auth.shouldSkipPermissionCheck", () => { + it("returns false by default", () => { const auth = new Auth(); expect(auth.shouldSkipPermissionCheck()).toBe(false); }); - it('returns true when state.test is true', () => { + it("returns true when state.test is true", () => { setState({ test: true }); const auth = new Auth(); expect(auth.shouldSkipPermissionCheck()).toBe(true); @@ -212,14 +212,14 @@ describe('Auth.shouldSkipPermissionCheck', () => { // ─── generateCode ───────────────────────────────────────────────────────────── -describe('Auth.generateCode', () => { - it('returns a 6-character alphanumeric code', async () => { +describe("Auth.generateCode", () => { + it("returns a 6-character alphanumeric code", async () => { const auth = new Auth(); const code = await auth.generateCode(); expect(code).toMatch(/^[a-z0-9]{6}$/); }); - it('generates different codes on successive calls', async () => { + it("generates different codes on successive calls", async () => { const auth = new Auth(); const codes = new Set(); for (let i = 0; i < 20; i++) { @@ -231,18 +231,18 @@ describe('Auth.generateCode', () => { // ─── setInsecureMode ────────────────────────────────────────────────────────── -describe('Auth insecure mode', () => { - it('defaults to secure mode', () => { +describe("Auth insecure mode", () => { + it("defaults to secure mode", () => { const auth = new Auth(); // createHttpsAgent is private, but we can verify the constructor accepts the flag expect(() => new Auth(false)).not.toThrow(); }); - it('can be constructed in insecure mode', () => { + it("can be constructed in insecure mode", () => { expect(() => new Auth(true)).not.toThrow(); }); - it('setInsecureMode does not throw', () => { + it("setInsecureMode does not throw", () => { const auth = new Auth(); expect(() => auth.setInsecureMode(true)).not.toThrow(); expect(() => auth.setInsecureMode(false)).not.toThrow(); @@ -251,41 +251,35 @@ describe('Auth insecure mode', () => { // ─── validateAndResolveParams ───────────────────────────────────────────────── -describe('Auth.validateAndResolveParams', () => { - it('returns params from args when all are provided', () => { +describe("Auth.validateAndResolveParams", () => { + it("returns params from args when all are provided", () => { const auth = new Auth(); const result = auth.validateAndResolveParams( - { sourceGuid: 'guid1', targetGuid: 'guid2', locale: 'en-us', channel: 'website' }, + { sourceGuid: "guid1", targetGuid: "guid2", locale: "en-us", channel: "website" }, [] ); - expect(result.sourceGuid).toBe('guid1'); - expect(result.targetGuid).toBe('guid2'); - expect(result.locale).toBe('en-us'); - expect(result.channel).toBe('website'); + expect(result.sourceGuid).toBe("guid1"); + expect(result.targetGuid).toBe("guid2"); + expect(result.locale).toBe("en-us"); + expect(result.channel).toBe("website"); }); - it('throws when a required field is missing', () => { + it("throws when a required field is missing", () => { const auth = new Auth(); - expect(() => - auth.validateAndResolveParams({ targetGuid: 'guid2' }, ['sourceGuid']) - ).toThrow(); + expect(() => auth.validateAndResolveParams({ targetGuid: "guid2" }, ["sourceGuid"])).toThrow(); }); - it('throws with helpful message for missing sourceGuid', () => { + it("throws with helpful message for missing sourceGuid", () => { const auth = new Auth(); - expect(() => - auth.validateAndResolveParams({}, ['sourceGuid']) - ).toThrow(/sourceGuid/i); + expect(() => auth.validateAndResolveParams({}, ["sourceGuid"])).toThrow(/sourceGuid/i); }); - it('throws with helpful message for missing targetGuid', () => { + it("throws with helpful message for missing targetGuid", () => { const auth = new Auth(); - expect(() => - auth.validateAndResolveParams({}, ['targetGuid']) - ).toThrow(/targetGuid/i); + expect(() => auth.validateAndResolveParams({}, ["targetGuid"])).toThrow(/targetGuid/i); }); - it('does not throw when no fields are required', () => { + it("does not throw when no fields are required", () => { const auth = new Auth(); expect(() => auth.validateAndResolveParams({}, [])).not.toThrow(); }); diff --git a/src/core/tests/batch-workflows.test.ts b/src/core/tests/batch-workflows.test.ts index d84ae52..00dbbbf 100644 --- a/src/core/tests/batch-workflows.test.ts +++ b/src/core/tests/batch-workflows.test.ts @@ -1,15 +1,15 @@ -import { createBatches } from '../batch-workflows'; +import { createBatches } from "../batch-workflows"; // ─── createBatches ──────────────────────────────────────────────────────────── -describe('createBatches', () => { - it('splits an array into batches of the specified size', () => { +describe("createBatches", () => { + it("splits an array into batches of the specified size", () => { const items = [1, 2, 3, 4, 5, 6, 7]; const batches = createBatches(items, 3); expect(batches).toEqual([[1, 2, 3], [4, 5, 6], [7]]); }); - it('uses default batch size of 250 when not specified', () => { + it("uses default batch size of 250 when not specified", () => { const items = Array.from({ length: 300 }, (_, i) => i); const batches = createBatches(items); expect(batches).toHaveLength(2); @@ -17,31 +17,31 @@ describe('createBatches', () => { expect(batches[1]).toHaveLength(50); }); - it('returns a single batch when items fit within batch size', () => { + it("returns a single batch when items fit within batch size", () => { const items = [1, 2, 3]; const batches = createBatches(items, 10); expect(batches).toHaveLength(1); expect(batches[0]).toEqual([1, 2, 3]); }); - it('returns an empty array when input is empty', () => { + it("returns an empty array when input is empty", () => { expect(createBatches([], 10)).toEqual([]); }); - it('returns one batch per item when batch size is 1', () => { - const items = ['a', 'b', 'c']; + it("returns one batch per item when batch size is 1", () => { + const items = ["a", "b", "c"]; const batches = createBatches(items, 1); - expect(batches).toEqual([['a'], ['b'], ['c']]); + expect(batches).toEqual([["a"], ["b"], ["c"]]); }); - it('works with a batch size equal to the array length', () => { + it("works with a batch size equal to the array length", () => { const items = [10, 20, 30]; const batches = createBatches(items, 3); expect(batches).toHaveLength(1); expect(batches[0]).toEqual([10, 20, 30]); }); - it('works with object arrays', () => { + it("works with object arrays", () => { const items = [{ id: 1 }, { id: 2 }, { id: 3 }]; const batches = createBatches(items, 2); expect(batches).toHaveLength(2); @@ -49,21 +49,21 @@ describe('createBatches', () => { expect(batches[1]).toEqual([{ id: 3 }]); }); - it('preserves item order', () => { + it("preserves item order", () => { const items = Array.from({ length: 10 }, (_, i) => i * 10); const batches = createBatches(items, 4); const flattened = batches.flat(); expect(flattened).toEqual(items); }); - it('does not mutate the original array', () => { + it("does not mutate the original array", () => { const items = [1, 2, 3, 4, 5]; const original = [...items]; createBatches(items, 2); expect(items).toEqual(original); }); - it('each batch is a new array (not a reference to input)', () => { + it("each batch is a new array (not a reference to input)", () => { const items = [1, 2, 3]; const batches = createBatches(items, 3); batches[0].push(999); diff --git a/src/core/tests/content.test.ts b/src/core/tests/content.test.ts index ab7bbe2..ff734e5 100644 --- a/src/core/tests/content.test.ts +++ b/src/core/tests/content.test.ts @@ -1,52 +1,52 @@ -import { content } from '../content'; +import { content } from "../content"; // content constructor requires mgmtApi.Options and a MultiBar, neither of which we want // to actually instantiate here. We create a minimal stub to reach camelize(). function makeContent() { return new content( - {} as any, // options stub - {} as any, // multibar stub - 'test-guid', - 'en-us' + {} as any, // options stub + {} as any, // multibar stub + "test-guid", + "en-us" ); } // ─── camelize ───────────────────────────────────────────────────────────────── -describe('content.camelize', () => { - it('lowercases the first character of a PascalCase string', () => { - expect(makeContent().camelize('BlogPost')).toBe('blogPost'); +describe("content.camelize", () => { + it("lowercases the first character of a PascalCase string", () => { + expect(makeContent().camelize("BlogPost")).toBe("blogPost"); }); - it('lowercases first character and preserves remaining PascalCase', () => { - expect(makeContent().camelize('PostList')).toBe('postList'); + it("lowercases first character and preserves remaining PascalCase", () => { + expect(makeContent().camelize("PostList")).toBe("postList"); }); - it('handles a single word with all lowercase', () => { - expect(makeContent().camelize('blog')).toBe('blog'); + it("handles a single word with all lowercase", () => { + expect(makeContent().camelize("blog")).toBe("blog"); }); - it('handles a single word starting with uppercase', () => { - expect(makeContent().camelize('Blog')).toBe('blog'); + it("handles a single word starting with uppercase", () => { + expect(makeContent().camelize("Blog")).toBe("blog"); }); - it('removes underscores between words', () => { - expect(makeContent().camelize('hello_world')).toBe('helloworld'); + it("removes underscores between words", () => { + expect(makeContent().camelize("hello_world")).toBe("helloworld"); }); - it('removes spaces between words', () => { - expect(makeContent().camelize('hello world')).toBe('helloworld'); + it("removes spaces between words", () => { + expect(makeContent().camelize("hello world")).toBe("helloworld"); }); - it('handles empty string', () => { - expect(makeContent().camelize('')).toBe(''); + it("handles empty string", () => { + expect(makeContent().camelize("")).toBe(""); }); - it('handles already-camelCase input', () => { - expect(makeContent().camelize('myModel')).toBe('myModel'); + it("handles already-camelCase input", () => { + expect(makeContent().camelize("myModel")).toBe("myModel"); }); - it('handles a multi-word PascalCase string', () => { - expect(makeContent().camelize('HeroBanner')).toBe('heroBanner'); + it("handles a multi-word PascalCase string", () => { + expect(makeContent().camelize("HeroBanner")).toBe("heroBanner"); }); }); diff --git a/src/core/tests/fileOperations.test.ts b/src/core/tests/fileOperations.test.ts index 1e5ded3..961bf90 100644 --- a/src/core/tests/fileOperations.test.ts +++ b/src/core/tests/fileOperations.test.ts @@ -1,13 +1,13 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { fileOperations } from '../fileOperations'; -import { resetState, setState } from '../state'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { fileOperations } from "../fileOperations"; +import { resetState, setState } from "../state"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-cli-tests-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-cli-tests-")); }); afterAll(() => { @@ -17,9 +17,9 @@ afterAll(() => { beforeEach(() => { resetState(); setState({ rootPath: tmpDir }); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -28,282 +28,280 @@ afterEach(() => { // ─── Constructor / getters ──────────────────────────────────────────────────── -describe('constructor and path getters', () => { - it('instancePath includes guid and locale in normal mode', () => { - const ops = new fileOperations('my-guid', 'en-us'); - expect(ops.instancePath).toBe(path.join(tmpDir, 'my-guid', 'en-us')); +describe("constructor and path getters", () => { + it("instancePath includes guid and locale in normal mode", () => { + const ops = new fileOperations("my-guid", "en-us"); + expect(ops.instancePath).toBe(path.join(tmpDir, "my-guid", "en-us")); }); - it('instancePath is guid-level when no locale is provided', () => { - const ops = new fileOperations('my-guid'); - expect(ops.instancePath).toBe(path.join(tmpDir, 'my-guid')); + it("instancePath is guid-level when no locale is provided", () => { + const ops = new fileOperations("my-guid"); + expect(ops.instancePath).toBe(path.join(tmpDir, "my-guid")); }); - it('mappingsPath is under root/guid/mappings', () => { - const ops = new fileOperations('my-guid', 'en-us'); - expect(ops.mappingsPath).toBe(path.join(tmpDir, 'my-guid', 'mappings')); + it("mappingsPath is under root/guid/mappings", () => { + const ops = new fileOperations("my-guid", "en-us"); + expect(ops.mappingsPath).toBe(path.join(tmpDir, "my-guid", "mappings")); }); - it('exposes guid and locale getters', () => { - const ops = new fileOperations('my-guid', 'en-us'); - expect(ops.guid).toBe('my-guid'); - expect(ops.locale).toBe('en-us'); + it("exposes guid and locale getters", () => { + const ops = new fileOperations("my-guid", "en-us"); + expect(ops.guid).toBe("my-guid"); + expect(ops.locale).toBe("en-us"); }); - it('isLegacyMode is false by default', () => { - const ops = new fileOperations('my-guid', 'en-us'); + it("isLegacyMode is false by default", () => { + const ops = new fileOperations("my-guid", "en-us"); expect(ops.isLegacyMode).toBe(false); }); }); // ─── Path utility methods ───────────────────────────────────────────────────── -describe('getFilePath / getDataFilePath', () => { - it('returns basePath when both args are omitted', () => { - const ops = new fileOperations('my-guid', 'en-us'); +describe("getFilePath / getDataFilePath", () => { + it("returns basePath when both args are omitted", () => { + const ops = new fileOperations("my-guid", "en-us"); expect(ops.getFilePath()).toBe(ops.instancePath); }); - it('appends folderName when only folder is given', () => { - const ops = new fileOperations('my-guid', 'en-us'); - expect(ops.getFilePath('models')).toBe(path.join(ops.instancePath, 'models')); + it("appends folderName when only folder is given", () => { + const ops = new fileOperations("my-guid", "en-us"); + expect(ops.getFilePath("models")).toBe(path.join(ops.instancePath, "models")); }); - it('appends both folder and file', () => { - const ops = new fileOperations('my-guid', 'en-us'); - expect(ops.getFilePath('models', '123.json')).toBe(path.join(ops.instancePath, 'models', '123.json')); + it("appends both folder and file", () => { + const ops = new fileOperations("my-guid", "en-us"); + expect(ops.getFilePath("models", "123.json")).toBe(path.join(ops.instancePath, "models", "123.json")); }); - it('appends only file when folder is omitted', () => { - const ops = new fileOperations('my-guid', 'en-us'); - expect(ops.getFilePath(undefined, 'myfile.json')).toBe(path.join(ops.instancePath, 'myfile.json')); + it("appends only file when folder is omitted", () => { + const ops = new fileOperations("my-guid", "en-us"); + expect(ops.getFilePath(undefined, "myfile.json")).toBe(path.join(ops.instancePath, "myfile.json")); }); }); -describe('getMappingFilePath', () => { - it('builds the central mapping path', () => { - const ops = new fileOperations('src-guid', 'en-us'); - const result = ops.getMappingFilePath('src-guid', 'tgt-guid', 'en-us'); - expect(result).toBe(path.join(tmpDir, 'mappings', 'src-guid-tgt-guid', 'en-us')); +describe("getMappingFilePath", () => { + it("builds the central mapping path", () => { + const ops = new fileOperations("src-guid", "en-us"); + const result = ops.getMappingFilePath("src-guid", "tgt-guid", "en-us"); + expect(result).toBe(path.join(tmpDir, "mappings", "src-guid-tgt-guid", "en-us")); }); - it('uses empty locale segment when locale is null', () => { - const ops = new fileOperations('src-guid'); - const result = ops.getMappingFilePath('src-guid', 'tgt-guid', null); - expect(result).toBe(path.join(tmpDir, 'mappings', 'src-guid-tgt-guid', '')); + it("uses empty locale segment when locale is null", () => { + const ops = new fileOperations("src-guid"); + const result = ops.getMappingFilePath("src-guid", "tgt-guid", null); + expect(result).toBe(path.join(tmpDir, "mappings", "src-guid-tgt-guid", "")); }); }); -describe('getNestedSitemapPath', () => { - it('returns correct path', () => { - const ops = new fileOperations('my-guid', 'en-us'); - expect(ops.getNestedSitemapPath()).toBe(path.join(ops.instancePath, 'nestedsitemap', 'website.json')); +describe("getNestedSitemapPath", () => { + it("returns correct path", () => { + const ops = new fileOperations("my-guid", "en-us"); + expect(ops.getNestedSitemapPath()).toBe(path.join(ops.instancePath, "nestedsitemap", "website.json")); }); }); // ─── checkFileExists / checkBaseFolderExists ────────────────────────────────── -describe('checkFileExists', () => { - it('returns true for an existing file', () => { - const filePath = path.join(tmpDir, 'existing.txt'); - fs.writeFileSync(filePath, 'hello'); - const ops = new fileOperations('my-guid', 'en-us'); +describe("checkFileExists", () => { + it("returns true for an existing file", () => { + const filePath = path.join(tmpDir, "existing.txt"); + fs.writeFileSync(filePath, "hello"); + const ops = new fileOperations("my-guid", "en-us"); expect(ops.checkFileExists(filePath)).toBe(true); }); - it('returns false for a non-existent file', () => { - const ops = new fileOperations('my-guid', 'en-us'); - expect(ops.checkFileExists(path.join(tmpDir, 'no-such-file.txt'))).toBe(false); + it("returns false for a non-existent file", () => { + const ops = new fileOperations("my-guid", "en-us"); + expect(ops.checkFileExists(path.join(tmpDir, "no-such-file.txt"))).toBe(false); }); }); -describe('checkBaseFolderExists', () => { - it('returns true for a folder that exists', () => { - const ops = new fileOperations('my-guid', 'en-us'); +describe("checkBaseFolderExists", () => { + it("returns true for a folder that exists", () => { + const ops = new fileOperations("my-guid", "en-us"); expect(ops.checkBaseFolderExists(tmpDir)).toBe(true); }); - it('returns false for a folder that does not exist', () => { - const ops = new fileOperations('my-guid', 'en-us'); - expect(ops.checkBaseFolderExists(path.join(tmpDir, 'nope'))).toBe(false); + it("returns false for a folder that does not exist", () => { + const ops = new fileOperations("my-guid", "en-us"); + expect(ops.checkBaseFolderExists(path.join(tmpDir, "nope"))).toBe(false); }); }); // ─── exportFiles ────────────────────────────────────────────────────────────── -describe('exportFiles', () => { - it('creates directory and writes a JSON file', () => { - const ops = new fileOperations('my-guid', 'en-us'); - const folder = 'models'; - const payload = { id: 1, name: 'TestModel' }; +describe("exportFiles", () => { + it("creates directory and writes a JSON file", () => { + const ops = new fileOperations("my-guid", "en-us"); + const folder = "models"; + const payload = { id: 1, name: "TestModel" }; ops.exportFiles(folder, 42, payload); - const expectedPath = path.join(ops.instancePath, folder, '42.json'); + const expectedPath = path.join(ops.instancePath, folder, "42.json"); expect(fs.existsSync(expectedPath)).toBe(true); - const content = JSON.parse(fs.readFileSync(expectedPath, 'utf8')); + const content = JSON.parse(fs.readFileSync(expectedPath, "utf8")); expect(content).toEqual(payload); }); - it('uses baseFolder override when provided', () => { - const ops = new fileOperations('my-guid', 'en-us'); - const baseFolder = path.join(tmpDir, 'custom-base'); - const payload = { hello: 'world' }; + it("uses baseFolder override when provided", () => { + const ops = new fileOperations("my-guid", "en-us"); + const baseFolder = path.join(tmpDir, "custom-base"); + const payload = { hello: "world" }; - ops.exportFiles('subfolder', 'testfile', payload, baseFolder); + ops.exportFiles("subfolder", "testfile", payload, baseFolder); - const expectedPath = path.join(baseFolder, 'subfolder', 'testfile.json'); + const expectedPath = path.join(baseFolder, "subfolder", "testfile.json"); expect(fs.existsSync(expectedPath)).toBe(true); }); - it('strips non-serializable HTTPS agent properties', () => { - const ops = new fileOperations('my-guid', 'en-us'); + it("strips non-serializable HTTPS agent properties", () => { + const ops = new fileOperations("my-guid", "en-us"); const payload = { - name: 'safe', + name: "safe", agent: { _events: {}, sockets: {} }, }; - ops.exportFiles('safe-test', 'item', payload); + ops.exportFiles("safe-test", "item", payload); - const written = JSON.parse( - fs.readFileSync(path.join(ops.instancePath, 'safe-test', 'item.json'), 'utf8') - ); - expect(written.name).toBe('safe'); + const written = JSON.parse(fs.readFileSync(path.join(ops.instancePath, "safe-test", "item.json"), "utf8")); + expect(written.name).toBe("safe"); expect(written.agent).toBeUndefined(); }); }); // ─── readJsonFileAbsolute ────────────────────────────────────────────────────── -describe('readJsonFileAbsolute', () => { - it('reads and parses a valid JSON file', () => { - const filePath = path.join(tmpDir, 'test-data.json'); - fs.writeFileSync(filePath, JSON.stringify({ key: 'value' })); - const ops = new fileOperations('my-guid', 'en-us'); - expect(ops.readJsonFileAbsolute(filePath)).toEqual({ key: 'value' }); +describe("readJsonFileAbsolute", () => { + it("reads and parses a valid JSON file", () => { + const filePath = path.join(tmpDir, "test-data.json"); + fs.writeFileSync(filePath, JSON.stringify({ key: "value" })); + const ops = new fileOperations("my-guid", "en-us"); + expect(ops.readJsonFileAbsolute(filePath)).toEqual({ key: "value" }); }); - it('returns null for a missing file', () => { - const ops = new fileOperations('my-guid', 'en-us'); - expect(ops.readJsonFileAbsolute(path.join(tmpDir, 'missing.json'))).toBeNull(); + it("returns null for a missing file", () => { + const ops = new fileOperations("my-guid", "en-us"); + expect(ops.readJsonFileAbsolute(path.join(tmpDir, "missing.json"))).toBeNull(); }); - it('returns null for malformed JSON', () => { - const filePath = path.join(tmpDir, 'bad.json'); - fs.writeFileSync(filePath, 'not json'); - const ops = new fileOperations('my-guid', 'en-us'); + it("returns null for malformed JSON", () => { + const filePath = path.join(tmpDir, "bad.json"); + fs.writeFileSync(filePath, "not json"); + const ops = new fileOperations("my-guid", "en-us"); expect(ops.readJsonFileAbsolute(filePath)).toBeNull(); }); }); // ─── readJsonFilesFromFolder ─────────────────────────────────────────────────── -describe('readJsonFilesFromFolder', () => { - it('reads all JSON files from a folder', () => { - const folder = path.join(tmpDir, 'json-folder'); +describe("readJsonFilesFromFolder", () => { + it("reads all JSON files from a folder", () => { + const folder = path.join(tmpDir, "json-folder"); fs.mkdirSync(folder, { recursive: true }); - fs.writeFileSync(path.join(folder, '1.json'), JSON.stringify({ id: 1 })); - fs.writeFileSync(path.join(folder, '2.json'), JSON.stringify({ id: 2 })); - fs.writeFileSync(path.join(folder, 'other.txt'), 'ignore me'); + fs.writeFileSync(path.join(folder, "1.json"), JSON.stringify({ id: 1 })); + fs.writeFileSync(path.join(folder, "2.json"), JSON.stringify({ id: 2 })); + fs.writeFileSync(path.join(folder, "other.txt"), "ignore me"); // We need basePath to match folder, so use a guid-level fileOperations // and pass the full absolute folder to getDataFolderPath via relative sub-path // Easiest: instantiate with guid=folder parent, locale='', then call the method // with a sub-path. Instead, let's just write to the instancePath. - const guidDir = path.join(tmpDir, 'rf-guid', 'en-us'); - fs.mkdirSync(path.join(guidDir, 'items'), { recursive: true }); - fs.writeFileSync(path.join(guidDir, 'items', 'a.json'), JSON.stringify({ id: 'a' })); - fs.writeFileSync(path.join(guidDir, 'items', 'b.json'), JSON.stringify({ id: 'b' })); + const guidDir = path.join(tmpDir, "rf-guid", "en-us"); + fs.mkdirSync(path.join(guidDir, "items"), { recursive: true }); + fs.writeFileSync(path.join(guidDir, "items", "a.json"), JSON.stringify({ id: "a" })); + fs.writeFileSync(path.join(guidDir, "items", "b.json"), JSON.stringify({ id: "b" })); - const ops = new fileOperations('rf-guid', 'en-us'); - const results = ops.readJsonFilesFromFolder('items'); + const ops = new fileOperations("rf-guid", "en-us"); + const results = ops.readJsonFilesFromFolder("items"); expect(results).toHaveLength(2); - expect(results.map((r: any) => r.id).sort()).toEqual(['a', 'b']); + expect(results.map((r: any) => r.id).sort()).toEqual(["a", "b"]); }); - it('returns empty array when folder does not exist', () => { - const ops = new fileOperations('no-guid', 'en-us'); - expect(ops.readJsonFilesFromFolder('nonexistent')).toEqual([]); + it("returns empty array when folder does not exist", () => { + const ops = new fileOperations("no-guid", "en-us"); + expect(ops.readJsonFilesFromFolder("nonexistent")).toEqual([]); }); }); // ─── listFilesInFolder ──────────────────────────────────────────────────────── -describe('listFilesInFolder', () => { - it('lists all files in a folder', () => { - const guidDir = path.join(tmpDir, 'list-guid', 'en-us', 'list-test'); +describe("listFilesInFolder", () => { + it("lists all files in a folder", () => { + const guidDir = path.join(tmpDir, "list-guid", "en-us", "list-test"); fs.mkdirSync(guidDir, { recursive: true }); - fs.writeFileSync(path.join(guidDir, 'a.json'), '{}'); - fs.writeFileSync(path.join(guidDir, 'b.json'), '{}'); + fs.writeFileSync(path.join(guidDir, "a.json"), "{}"); + fs.writeFileSync(path.join(guidDir, "b.json"), "{}"); - const ops = new fileOperations('list-guid', 'en-us'); - const files = ops.listFilesInFolder('list-test'); - expect(files.sort()).toEqual(['a.json', 'b.json']); + const ops = new fileOperations("list-guid", "en-us"); + const files = ops.listFilesInFolder("list-test"); + expect(files.sort()).toEqual(["a.json", "b.json"]); }); - it('filters by extension when provided', () => { - const guidDir = path.join(tmpDir, 'ext-guid', 'en-us', 'ext-test'); + it("filters by extension when provided", () => { + const guidDir = path.join(tmpDir, "ext-guid", "en-us", "ext-test"); fs.mkdirSync(guidDir, { recursive: true }); - fs.writeFileSync(path.join(guidDir, 'a.json'), '{}'); - fs.writeFileSync(path.join(guidDir, 'b.txt'), 'text'); + fs.writeFileSync(path.join(guidDir, "a.json"), "{}"); + fs.writeFileSync(path.join(guidDir, "b.txt"), "text"); - const ops = new fileOperations('ext-guid', 'en-us'); - const files = ops.listFilesInFolder('ext-test', '.json'); - expect(files).toEqual(['a.json']); + const ops = new fileOperations("ext-guid", "en-us"); + const files = ops.listFilesInFolder("ext-test", ".json"); + expect(files).toEqual(["a.json"]); }); - it('returns empty array for missing folder', () => { - const ops = new fileOperations('no-guid', 'en-us'); - expect(ops.listFilesInFolder('nope')).toEqual([]); + it("returns empty array for missing folder", () => { + const ops = new fileOperations("no-guid", "en-us"); + expect(ops.listFilesInFolder("nope")).toEqual([]); }); }); // ─── saveMappingFile / getMappingFile ───────────────────────────────────────── -describe('saveMappingFile / getMappingFile', () => { - it('saves and reads back mapping data', () => { - const ops = new fileOperations('s-guid', 'en-us'); +describe("saveMappingFile / getMappingFile", () => { + it("saves and reads back mapping data", () => { + const ops = new fileOperations("s-guid", "en-us"); const mappingData = [{ sourceID: 1, targetID: 100 }]; - ops.saveMappingFile(mappingData, 'content', 's-guid', 't-guid', 'en-us'); + ops.saveMappingFile(mappingData, "content", "s-guid", "t-guid", "en-us"); - const result = ops.getMappingFile('content', 's-guid', 't-guid', 'en-us'); + const result = ops.getMappingFile("content", "s-guid", "t-guid", "en-us"); expect(result).toEqual(mappingData); }); - it('returns empty array when mapping folder does not exist', () => { - const ops = new fileOperations('s-guid', 'en-us'); - const result = ops.getMappingFile('content', 'nope1', 'nope2', 'en-us'); + it("returns empty array when mapping folder does not exist", () => { + const ops = new fileOperations("s-guid", "en-us"); + const result = ops.getMappingFile("content", "nope1", "nope2", "en-us"); expect(result).toEqual([]); }); }); // ─── fileExists / cliFolderExists ───────────────────────────────────────────── -describe('fileExists', () => { - it('returns true for an existing file', () => { - const filePath = path.join(tmpDir, 'fe-test.txt'); - fs.writeFileSync(filePath, 'hi'); - const ops = new fileOperations('g', 'en-us'); +describe("fileExists", () => { + it("returns true for an existing file", () => { + const filePath = path.join(tmpDir, "fe-test.txt"); + fs.writeFileSync(filePath, "hi"); + const ops = new fileOperations("g", "en-us"); expect(ops.fileExists(filePath)).toBe(true); }); - it('returns false for a missing file', () => { - const ops = new fileOperations('g', 'en-us'); - expect(ops.fileExists(path.join(tmpDir, 'nope.txt'))).toBe(false); + it("returns false for a missing file", () => { + const ops = new fileOperations("g", "en-us"); + expect(ops.fileExists(path.join(tmpDir, "nope.txt"))).toBe(false); }); }); -describe('cliFolderExists', () => { - it('returns true when instancePath exists', () => { - const guidDir = path.join(tmpDir, 'cf-guid', 'en-us'); +describe("cliFolderExists", () => { + it("returns true when instancePath exists", () => { + const guidDir = path.join(tmpDir, "cf-guid", "en-us"); fs.mkdirSync(guidDir, { recursive: true }); - const ops = new fileOperations('cf-guid', 'en-us'); + const ops = new fileOperations("cf-guid", "en-us"); expect(ops.cliFolderExists()).toBe(true); }); - it('returns false when instancePath does not exist', () => { - const ops = new fileOperations('no-cf-guid', 'en-us'); + it("returns false when instancePath does not exist", () => { + const ops = new fileOperations("no-cf-guid", "en-us"); expect(ops.cliFolderExists()).toBe(false); }); }); diff --git a/src/core/tests/logs.test.ts b/src/core/tests/logs.test.ts index 4ee1135..6bcdff4 100644 --- a/src/core/tests/logs.test.ts +++ b/src/core/tests/logs.test.ts @@ -1,11 +1,11 @@ -import { Logs, LogLevel } from '../logs'; -import { resetState } from '../state'; +import { Logs, LogLevel } from "../logs"; +import { resetState } from "../state"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); }); afterEach(() => { @@ -14,119 +14,119 @@ afterEach(() => { // ─── Constructor ────────────────────────────────────────────────────────────── -describe('Logs constructor', () => { - it('creates an instance with an operation type', () => { - const logs = new Logs('pull'); +describe("Logs constructor", () => { + it("creates an instance with an operation type", () => { + const logs = new Logs("pull"); expect(logs).toBeInstanceOf(Logs); }); - it('starts with zero log entries', () => { - const logs = new Logs('push'); + it("starts with zero log entries", () => { + const logs = new Logs("push"); expect(logs.getLogCount()).toBe(0); }); - it('accepts optional entityType and guid', () => { - const logs = new Logs('sync', 'content', 'my-guid'); - expect(logs.getGuid()).toBe('my-guid'); + it("accepts optional entityType and guid", () => { + const logs = new Logs("sync", "content", "my-guid"); + expect(logs.getGuid()).toBe("my-guid"); }); }); // ─── guid management ───────────────────────────────────────────────────────── -describe('setGuid / getGuid', () => { - it('getGuid returns undefined when not set', () => { - const logs = new Logs('pull'); +describe("setGuid / getGuid", () => { + it("getGuid returns undefined when not set", () => { + const logs = new Logs("pull"); expect(logs.getGuid()).toBeUndefined(); }); - it('setGuid then getGuid returns the set value', () => { - const logs = new Logs('pull'); - logs.setGuid('test-guid-123'); - expect(logs.getGuid()).toBe('test-guid-123'); + it("setGuid then getGuid returns the set value", () => { + const logs = new Logs("pull"); + logs.setGuid("test-guid-123"); + expect(logs.getGuid()).toBe("test-guid-123"); }); }); // ─── configure ──────────────────────────────────────────────────────────────── -describe('configure', () => { - it('can disable console logging', () => { - const logs = new Logs('pull'); +describe("configure", () => { + it("can disable console logging", () => { + const logs = new Logs("pull"); logs.configure({ logToConsole: false }); - logs.info('should not print'); + logs.info("should not print"); expect(console.log).not.toHaveBeenCalled(); }); - it('can disable file logging', () => { - const logs = new Logs('pull'); + it("can disable file logging", () => { + const logs = new Logs("pull"); logs.configure({ logToFile: false }); // saveLogs with logToFile: false returns null const result = logs.saveLogs(); expect(result).toBeNull(); }); - it('can disable colors', () => { - const logs = new Logs('pull'); + it("can disable colors", () => { + const logs = new Logs("pull"); logs.configure({ showColors: false }); // Should not throw - logs.info('no colors'); + logs.info("no colors"); expect(logs.getLogCount()).toBe(1); }); }); // ─── log / info / error / warning / debug ───────────────────────────────────── -describe('logging methods', () => { - it('log() increments count', () => { - const logs = new Logs('pull'); - logs.log('INFO', 'test message'); +describe("logging methods", () => { + it("log() increments count", () => { + const logs = new Logs("pull"); + logs.log("INFO", "test message"); expect(logs.getLogCount()).toBe(1); }); - it('info() increments count', () => { - const logs = new Logs('pull'); - logs.info('info message'); + it("info() increments count", () => { + const logs = new Logs("pull"); + logs.info("info message"); expect(logs.getLogCount()).toBe(1); }); - it('error() increments count', () => { - const logs = new Logs('pull'); - logs.error('error message'); + it("error() increments count", () => { + const logs = new Logs("pull"); + logs.error("error message"); expect(logs.getLogCount()).toBe(1); }); - it('warning() increments count', () => { - const logs = new Logs('pull'); - logs.warning('warning message'); + it("warning() increments count", () => { + const logs = new Logs("pull"); + logs.warning("warning message"); expect(logs.getLogCount()).toBe(1); }); - it('debug() increments count', () => { - const logs = new Logs('pull'); - logs.debug('debug message'); + it("debug() increments count", () => { + const logs = new Logs("pull"); + logs.debug("debug message"); expect(logs.getLogCount()).toBe(1); }); - it('multiple calls accumulate', () => { - const logs = new Logs('pull'); - logs.info('a'); - logs.info('b'); - logs.info('c'); + it("multiple calls accumulate", () => { + const logs = new Logs("pull"); + logs.info("a"); + logs.info("b"); + logs.info("c"); expect(logs.getLogCount()).toBe(3); }); - it('log() outputs to console when logToConsole is true', () => { - const logs = new Logs('pull'); - logs.log('INFO', 'hello'); + it("log() outputs to console when logToConsole is true", () => { + const logs = new Logs("pull"); + logs.log("INFO", "hello"); expect(console.log).toHaveBeenCalled(); }); }); // ─── fileOnly ──────────────────────────────────────────────────────────────── -describe('fileOnly', () => { - it('increments log count but does not write to console', () => { - const logs = new Logs('pull'); - logs.fileOnly('secret log'); +describe("fileOnly", () => { + it("increments log count but does not write to console", () => { + const logs = new Logs("pull"); + logs.fileOnly("secret log"); expect(logs.getLogCount()).toBe(1); expect(console.log).not.toHaveBeenCalled(); }); @@ -134,11 +134,11 @@ describe('fileOnly', () => { // ─── clearLogs ──────────────────────────────────────────────────────────────── -describe('clearLogs', () => { - it('resets count to zero', () => { - const logs = new Logs('pull'); - logs.info('a'); - logs.info('b'); +describe("clearLogs", () => { + it("resets count to zero", () => { + const logs = new Logs("pull"); + logs.info("a"); + logs.info("b"); logs.clearLogs(); expect(logs.getLogCount()).toBe(0); }); @@ -146,18 +146,18 @@ describe('clearLogs', () => { // ─── saveLogs ───────────────────────────────────────────────────────────────── -describe('saveLogs', () => { - it('returns null and clears logs when logToFile is false', () => { - const logs = new Logs('pull'); +describe("saveLogs", () => { + it("returns null and clears logs when logToFile is false", () => { + const logs = new Logs("pull"); logs.configure({ logToFile: false }); - logs.info('something'); + logs.info("something"); const result = logs.saveLogs(); expect(result).toBeNull(); expect(logs.getLogCount()).toBe(0); }); - it('returns null when there are no logs', () => { - const logs = new Logs('pull'); + it("returns null when there are no logs", () => { + const logs = new Logs("pull"); const result = logs.saveLogs(); expect(result).toBeNull(); }); @@ -165,30 +165,30 @@ describe('saveLogs', () => { // ─── summary / changeDetectionSummary ───────────────────────────────────────── -describe('summary', () => { - it('does not throw', () => { - const logs = new Logs('push'); - expect(() => logs.summary('push', 5, 1, 2)).not.toThrow(); +describe("summary", () => { + it("does not throw", () => { + const logs = new Logs("push"); + expect(() => logs.summary("push", 5, 1, 2)).not.toThrow(); }); }); -describe('changeDetectionSummary', () => { - it('does not throw and increments log count', () => { - const logs = new Logs('pull'); - expect(() => logs.changeDetectionSummary('content', 10, 3)).not.toThrow(); +describe("changeDetectionSummary", () => { + it("does not throw and increments log count", () => { + const logs = new Logs("pull"); + expect(() => logs.changeDetectionSummary("content", 10, 3)).not.toThrow(); expect(logs.getLogCount()).toBeGreaterThan(0); }); }); // ─── logDataElement ─────────────────────────────────────────────────────────── -describe('logDataElement', () => { - it('does not throw for various statuses', () => { - const logs = new Logs('push'); - const statuses = ['success', 'failed', 'skipped', 'conflict', 'pending', 'in_progress', 'info'] as const; +describe("logDataElement", () => { + it("does not throw for various statuses", () => { + const logs = new Logs("push"); + const statuses = ["success", "failed", "skipped", "conflict", "pending", "in_progress", "info"] as const; for (const status of statuses) { expect(() => - logs.logDataElement('content', 'uploaded', status, 'TestItem', 'some-guid', 'details', 'en-us') + logs.logDataElement("content", "uploaded", status, "TestItem", "some-guid", "details", "en-us") ).not.toThrow(); } }); @@ -196,70 +196,70 @@ describe('logDataElement', () => { // ─── Entity logging namespaces ───────────────────────────────────────────────── -describe('entity log namespaces', () => { - it('asset.downloaded does not throw', () => { - const logs = new Logs('pull', undefined, 'guid1'); - expect(() => logs.asset.downloaded({ fileName: 'photo.jpg', mediaID: 1 })).not.toThrow(); +describe("entity log namespaces", () => { + it("asset.downloaded does not throw", () => { + const logs = new Logs("pull", undefined, "guid1"); + expect(() => logs.asset.downloaded({ fileName: "photo.jpg", mediaID: 1 })).not.toThrow(); }); - it('asset.skipped does not throw', () => { - const logs = new Logs('pull', undefined, 'guid1'); - expect(() => logs.asset.skipped({ fileName: 'photo.jpg' })).not.toThrow(); + it("asset.skipped does not throw", () => { + const logs = new Logs("pull", undefined, "guid1"); + expect(() => logs.asset.skipped({ fileName: "photo.jpg" })).not.toThrow(); }); - it('model.created does not throw', () => { - const logs = new Logs('push', undefined, 'guid1'); - expect(() => logs.model.created({ referenceName: 'MyModel', id: 5 })).not.toThrow(); + it("model.created does not throw", () => { + const logs = new Logs("push", undefined, "guid1"); + expect(() => logs.model.created({ referenceName: "MyModel", id: 5 })).not.toThrow(); }); - it('model.skipped does not throw', () => { - const logs = new Logs('push'); - expect(() => logs.model.skipped({ referenceName: 'MyModel' })).not.toThrow(); + it("model.skipped does not throw", () => { + const logs = new Logs("push"); + expect(() => logs.model.skipped({ referenceName: "MyModel" })).not.toThrow(); }); - it('content.created does not throw', () => { - const logs = new Logs('push'); - expect(() => logs.content.created({ properties: { referenceName: 'blog' }, contentID: 1 })).not.toThrow(); + it("content.created does not throw", () => { + const logs = new Logs("push"); + expect(() => logs.content.created({ properties: { referenceName: "blog" }, contentID: 1 })).not.toThrow(); }); - it('content.error does not throw', () => { - const logs = new Logs('push'); + it("content.error does not throw", () => { + const logs = new Logs("push"); expect(() => - logs.content.error({ properties: { referenceName: 'blog' }, contentID: 1 }, new Error('fail'), 'en-us') + logs.content.error({ properties: { referenceName: "blog" }, contentID: 1 }, new Error("fail"), "en-us") ).not.toThrow(); }); - it('page.downloaded does not throw', () => { - const logs = new Logs('pull'); - expect(() => logs.page.downloaded({ name: 'Home', pageID: 1 })).not.toThrow(); + it("page.downloaded does not throw", () => { + const logs = new Logs("pull"); + expect(() => logs.page.downloaded({ name: "Home", pageID: 1 })).not.toThrow(); }); - it('container.created does not throw', () => { - const logs = new Logs('push'); - expect(() => logs.container.created({ referenceName: 'BlogPosts', contentViewID: 10 })).not.toThrow(); + it("container.created does not throw", () => { + const logs = new Logs("push"); + expect(() => logs.container.created({ referenceName: "BlogPosts", contentViewID: 10 })).not.toThrow(); }); - it('template.created does not throw', () => { - const logs = new Logs('push'); - expect(() => logs.template.created({ pageTemplateName: 'Default', pageTemplateID: 1 })).not.toThrow(); + it("template.created does not throw", () => { + const logs = new Logs("push"); + expect(() => logs.template.created({ pageTemplateName: "Default", pageTemplateID: 1 })).not.toThrow(); }); - it('gallery.created does not throw', () => { - const logs = new Logs('push'); - expect(() => logs.gallery.created({ name: 'My Gallery', id: 1 })).not.toThrow(); + it("gallery.created does not throw", () => { + const logs = new Logs("push"); + expect(() => logs.gallery.created({ name: "My Gallery", id: 1 })).not.toThrow(); }); - it('sitemap.downloaded does not throw', () => { - const logs = new Logs('pull'); - expect(() => logs.sitemap.downloaded({ name: 'website' })).not.toThrow(); + it("sitemap.downloaded does not throw", () => { + const logs = new Logs("pull"); + expect(() => logs.sitemap.downloaded({ name: "website" })).not.toThrow(); }); }); // ─── timer helpers ───────────────────────────────────────────────────────────── -describe('timer helpers', () => { - it('startTimer and endTimer do not throw', () => { - const logs = new Logs('pull'); +describe("timer helpers", () => { + it("startTimer and endTimer do not throw", () => { + const logs = new Logs("pull"); expect(() => { logs.startTimer(); logs.endTimer(); diff --git a/src/core/tests/publish.test.ts b/src/core/tests/publish.test.ts index e307151..9d38bca 100644 --- a/src/core/tests/publish.test.ts +++ b/src/core/tests/publish.test.ts @@ -1,11 +1,11 @@ -import { PublishService, PublishResult } from '../publish'; -import { resetState, setState, state } from '../state'; +import { PublishService, PublishResult } from "../publish"; +import { resetState, setState, state } from "../state"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -15,45 +15,45 @@ afterEach(() => { }); function setupStateWithTarget() { - setState({ targetGuid: 'test-target-guid-u', token: 'test-token-value' }); + setState({ targetGuid: "test-target-guid-u", token: "test-token-value" }); } // ─── Constructor ────────────────────────────────────────────────────────────── -describe('PublishService constructor', () => { - it('creates an instance when targetGuid and token are set', () => { +describe("PublishService constructor", () => { + it("creates an instance when targetGuid and token are set", () => { setupStateWithTarget(); expect(() => new PublishService()).not.toThrow(); }); - it('creates an instance with verbose option', () => { + it("creates an instance with verbose option", () => { setupStateWithTarget(); expect(() => new PublishService({ verbose: true })).not.toThrow(); }); - it('throws when targetGuid is empty array', () => { - setState({ token: 'test-token-value' }); - expect(() => new PublishService()).toThrow('PublishService requires targetGuid to be set in state'); + it("throws when targetGuid is empty array", () => { + setState({ token: "test-token-value" }); + expect(() => new PublishService()).toThrow("PublishService requires targetGuid to be set in state"); }); }); // ─── publishContentBatch ────────────────────────────────────────────────────── -describe('PublishService.publishContentBatch', () => { - it('returns empty successful and failed arrays when given an empty ID list', async () => { +describe("PublishService.publishContentBatch", () => { + it("returns empty successful and failed arrays when given an empty ID list", async () => { setupStateWithTarget(); const service = new PublishService(); - const result = await service.publishContentBatch([], 'en-us'); + const result = await service.publishContentBatch([], "en-us"); expect(result.successful).toEqual([]); expect(result.failed).toEqual([]); }); - it('returns the expected result shape', async () => { + it("returns the expected result shape", async () => { setupStateWithTarget(); const service = new PublishService(); - const result = await service.publishContentBatch([], 'en-us'); - expect(result).toHaveProperty('successful'); - expect(result).toHaveProperty('failed'); + const result = await service.publishContentBatch([], "en-us"); + expect(result).toHaveProperty("successful"); + expect(result).toHaveProperty("failed"); expect(Array.isArray(result.successful)).toBe(true); expect(Array.isArray(result.failed)).toBe(true); }); diff --git a/src/core/tests/pull.test.ts b/src/core/tests/pull.test.ts index 1c6ee0b..66b810e 100644 --- a/src/core/tests/pull.test.ts +++ b/src/core/tests/pull.test.ts @@ -1,11 +1,11 @@ -import { Pull } from '../pull'; -import { resetState, setState } from '../state'; +import { Pull } from "../pull"; +import { resetState, setState } from "../state"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -14,33 +14,33 @@ afterEach(() => { // ─── Constructor ────────────────────────────────────────────────────────────── -describe('Pull constructor', () => { - it('creates an instance without throwing', () => { +describe("Pull constructor", () => { + it("creates an instance without throwing", () => { expect(() => new Pull()).not.toThrow(); }); }); // ─── pullInstances – guard clauses ──────────────────────────────────────────── -describe('Pull.pullInstances', () => { - it('throws when no source GUIDs are set and update=true (default)', async () => { +describe("Pull.pullInstances", () => { + it("throws when no source GUIDs are set and update=true (default)", async () => { // Default state: update=true, sourceGuid=[], so allGuids stays empty setState({ update: true }); const pull = new Pull(); - await expect(pull.pullInstances(false)).rejects.toThrow('No GUIDs specified'); + await expect(pull.pullInstances(false)).rejects.toThrow("No GUIDs specified"); }); - it('throws when called from push with update=false and no targetGuid', async () => { + it("throws when called from push with update=false and no targetGuid", async () => { setState({ update: false }); // fromPush=true, update=false → allGuids = targetGuid = [] const pull = new Pull(); - await expect(pull.pullInstances(true)).rejects.toThrow('No GUIDs specified'); + await expect(pull.pullInstances(true)).rejects.toThrow("No GUIDs specified"); }); - it('throws when called from push with update=true and no source or target guids', async () => { + it("throws when called from push with update=true and no source or target guids", async () => { setState({ update: true }); // fromPush=true, update=true → allGuids = sourceGuid + targetGuid = [] const pull = new Pull(); - await expect(pull.pullInstances(true)).rejects.toThrow('No GUIDs specified'); + await expect(pull.pullInstances(true)).rejects.toThrow("No GUIDs specified"); }); }); diff --git a/src/core/tests/push.test.ts b/src/core/tests/push.test.ts index cfd774f..824945a 100644 --- a/src/core/tests/push.test.ts +++ b/src/core/tests/push.test.ts @@ -1,11 +1,11 @@ -import { Push } from '../push'; -import { resetState, setState } from '../state'; +import { Push } from "../push"; +import { resetState, setState } from "../state"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -14,27 +14,27 @@ afterEach(() => { // ─── Constructor ────────────────────────────────────────────────────────────── -describe('Push constructor', () => { - it('creates an instance without throwing', () => { +describe("Push constructor", () => { + it("creates an instance without throwing", () => { expect(() => new Push()).not.toThrow(); }); }); // ─── pushInstances – guard clauses ─────────────────────────────────────────── -describe('Push.pushInstances', () => { - it('throws when neither sourceGuid nor targetGuid are set', async () => { +describe("Push.pushInstances", () => { + it("throws when neither sourceGuid nor targetGuid are set", async () => { const push = new Push(); - await expect(push.pushInstances()).rejects.toThrow('No GUIDs specified'); + await expect(push.pushInstances()).rejects.toThrow("No GUIDs specified"); }); - it('resolves (passes the GUID guard) when sourceGuid and targetGuid are both set', async () => { - setState({ sourceGuid: 'source-guid-u', targetGuid: 'target-guid-u' }); + it("resolves (passes the GUID guard) when sourceGuid and targetGuid are both set", async () => { + setState({ sourceGuid: "source-guid-u", targetGuid: "target-guid-u" }); const push = new Push(); // Should not throw "No GUIDs specified" — it may resolve or fail later for other reasons const result = await push.pushInstances().catch((err: Error) => err); if (result instanceof Error) { - expect(result.message).not.toContain('No GUIDs specified'); + expect(result.message).not.toContain("No GUIDs specified"); } else { expect(result).toBeDefined(); } diff --git a/src/core/tests/state.test.ts b/src/core/tests/state.test.ts index f9c4177..fd0c5a7 100644 --- a/src/core/tests/state.test.ts +++ b/src/core/tests/state.test.ts @@ -16,7 +16,7 @@ import { initializeLogger, initializeGuidLogger, getLoggerForGuid, -} from '../state'; +} from "../state"; beforeEach(() => { resetState(); @@ -24,129 +24,129 @@ beforeEach(() => { // ─── setState ──────────────────────────────────────────────────────────────── -describe('setState – GUID parsing', () => { - it('sets a single sourceGuid', () => { - setState({ sourceGuid: 'abc123u' }); - expect(getState().sourceGuid).toEqual(['abc123u']); +describe("setState – GUID parsing", () => { + it("sets a single sourceGuid", () => { + setState({ sourceGuid: "abc123u" }); + expect(getState().sourceGuid).toEqual(["abc123u"]); }); - it('splits comma-separated sourceGuids into an array', () => { - setState({ sourceGuid: 'guid1u,guid2u, guid3u' }); - expect(getState().sourceGuid).toEqual(['guid1u', 'guid2u', 'guid3u']); + it("splits comma-separated sourceGuids into an array", () => { + setState({ sourceGuid: "guid1u,guid2u, guid3u" }); + expect(getState().sourceGuid).toEqual(["guid1u", "guid2u", "guid3u"]); }); - it('sets a single targetGuid', () => { - setState({ targetGuid: 'xyz789u' }); - expect(getState().targetGuid).toEqual(['xyz789u']); + it("sets a single targetGuid", () => { + setState({ targetGuid: "xyz789u" }); + expect(getState().targetGuid).toEqual(["xyz789u"]); }); - it('splits comma-separated targetGuids', () => { - setState({ targetGuid: 'a1u,b2u' }); - expect(getState().targetGuid).toEqual(['a1u', 'b2u']); + it("splits comma-separated targetGuids", () => { + setState({ targetGuid: "a1u,b2u" }); + expect(getState().targetGuid).toEqual(["a1u", "b2u"]); }); - it('ignores empty segments in comma-separated GUIDs', () => { - setState({ sourceGuid: 'a1u,,b2u,' }); - expect(getState().sourceGuid).toEqual(['a1u', 'b2u']); + it("ignores empty segments in comma-separated GUIDs", () => { + setState({ sourceGuid: "a1u,,b2u," }); + expect(getState().sourceGuid).toEqual(["a1u", "b2u"]); }); }); -describe('setState – locale parsing', () => { - it('sets a single locale', () => { - setState({ locale: 'en-us' }); - expect(getState().locale).toEqual(['en-us']); +describe("setState – locale parsing", () => { + it("sets a single locale", () => { + setState({ locale: "en-us" }); + expect(getState().locale).toEqual(["en-us"]); }); - it('splits comma-separated locales', () => { - setState({ locale: 'en-us,fr-ca' }); - expect(getState().locale).toEqual(['en-us', 'fr-ca']); + it("splits comma-separated locales", () => { + setState({ locale: "en-us,fr-ca" }); + expect(getState().locale).toEqual(["en-us", "fr-ca"]); }); - it('splits space-separated locales', () => { - setState({ locale: 'en-us fr-ca' }); - expect(getState().locale).toEqual(['en-us', 'fr-ca']); + it("splits space-separated locales", () => { + setState({ locale: "en-us fr-ca" }); + expect(getState().locale).toEqual(["en-us", "fr-ca"]); }); - it('sets empty array for blank locale string', () => { - setState({ locale: ' ' }); + it("sets empty array for blank locale string", () => { + setState({ locale: " " }); expect(getState().locale).toEqual([]); }); }); -describe('setState – explicit ID parsing', () => { - it('parses comma-separated contentIDs into numbers', () => { - setState({ contentIDs: '1,2,3' }); +describe("setState – explicit ID parsing", () => { + it("parses comma-separated contentIDs into numbers", () => { + setState({ contentIDs: "1,2,3" }); expect(getState().explicitContentIDs).toEqual([1, 2, 3]); }); - it('parses comma-separated pageIDs into numbers', () => { - setState({ pageIDs: '10, 20, 30' }); + it("parses comma-separated pageIDs into numbers", () => { + setState({ pageIDs: "10, 20, 30" }); expect(getState().explicitPageIDs).toEqual([10, 20, 30]); }); - it('filters out NaN and non-positive IDs', () => { - setState({ contentIDs: '1,abc,-5,0,99' }); + it("filters out NaN and non-positive IDs", () => { + setState({ contentIDs: "1,abc,-5,0,99" }); expect(getState().explicitContentIDs).toEqual([1, 99]); }); - it('accepts direct array assignment for explicitContentIDs', () => { + it("accepts direct array assignment for explicitContentIDs", () => { setState({ explicitContentIDs: [5, 10, 15] }); expect(getState().explicitContentIDs).toEqual([5, 10, 15]); }); - it('accepts direct array assignment for explicitPageIDs', () => { + it("accepts direct array assignment for explicitPageIDs", () => { setState({ explicitPageIDs: [100, 200] }); expect(getState().explicitPageIDs).toEqual([100, 200]); }); }); -describe('setState – boolean and string flags', () => { - it('sets headless flag', () => { +describe("setState – boolean and string flags", () => { + it("sets headless flag", () => { setState({ headless: true }); expect(getState().headless).toBe(true); }); - it('sets verbose flag', () => { + it("sets verbose flag", () => { setState({ verbose: true }); expect(getState().verbose).toBe(true); }); - it('sets overwrite flag', () => { + it("sets overwrite flag", () => { setState({ overwrite: true }); expect(getState().overwrite).toBe(true); }); - it('sets force flag', () => { + it("sets force flag", () => { setState({ force: true }); expect(getState().force).toBe(true); }); - it('sets dryRun flag', () => { + it("sets dryRun flag", () => { setState({ dryRun: true }); expect(getState().dryRun).toBe(true); }); - it('sets autoPublish value', () => { - setState({ autoPublish: 'both' }); - expect(getState().autoPublish).toBe('both'); + it("sets autoPublish value", () => { + setState({ autoPublish: "both" }); + expect(getState().autoPublish).toBe("both"); }); - it('sets rootPath', () => { - setState({ rootPath: '/custom/path' }); - expect(getState().rootPath).toBe('/custom/path'); + it("sets rootPath", () => { + setState({ rootPath: "/custom/path" }); + expect(getState().rootPath).toBe("/custom/path"); }); - it('sets operationType', () => { - setState({ operationType: 'publish' }); - expect(getState().operationType).toBe('publish'); + it("sets operationType", () => { + setState({ operationType: "publish" }); + expect(getState().operationType).toBe("publish"); }); - it('sets token', () => { - setState({ token: 'my-pat-token' }); - expect(getState().token).toBe('my-pat-token'); + it("sets token", () => { + setState({ token: "my-pat-token" }); + expect(getState().token).toBe("my-pat-token"); }); - it('ignores undefined values (does not overwrite existing state)', () => { + it("ignores undefined values (does not overwrite existing state)", () => { setState({ headless: true }); setState({ verbose: true }); // headless should still be true expect(getState().headless).toBe(true); @@ -155,15 +155,15 @@ describe('setState – boolean and string flags', () => { // ─── resetState ────────────────────────────────────────────────────────────── -describe('resetState', () => { - it('clears sourceGuid and targetGuid', () => { - setState({ sourceGuid: 'abc', targetGuid: 'xyz' }); +describe("resetState", () => { + it("clears sourceGuid and targetGuid", () => { + setState({ sourceGuid: "abc", targetGuid: "xyz" }); resetState(); expect(getState().sourceGuid).toEqual([]); expect(getState().targetGuid).toEqual([]); }); - it('resets boolean flags to defaults', () => { + it("resets boolean flags to defaults", () => { setState({ headless: true, verbose: true, overwrite: true, force: true, dryRun: true }); resetState(); const s = getState(); @@ -174,81 +174,69 @@ describe('resetState', () => { expect(s.dryRun).toBe(false); }); - it('resets rootPath to agility-files', () => { - setState({ rootPath: '/custom' }); + it("resets rootPath to agility-files", () => { + setState({ rootPath: "/custom" }); resetState(); - expect(getState().rootPath).toBe('agility-files'); + expect(getState().rootPath).toBe("agility-files"); }); - it('resets token to null', () => { - setState({ token: 'abc' }); + it("resets token to null", () => { + setState({ token: "abc" }); resetState(); expect(getState().token).toBeNull(); }); - it('resets explicitContentIDs and explicitPageIDs to empty arrays', () => { - setState({ contentIDs: '1,2,3', pageIDs: '10' }); + it("resets explicitContentIDs and explicitPageIDs to empty arrays", () => { + setState({ contentIDs: "1,2,3", pageIDs: "10" }); resetState(); expect(getState().explicitContentIDs).toEqual([]); expect(getState().explicitPageIDs).toEqual([]); }); - it('resets autoPublish to empty string', () => { - setState({ autoPublish: 'both' }); + it("resets autoPublish to empty string", () => { + setState({ autoPublish: "both" }); resetState(); - expect(getState().autoPublish).toBe(''); + expect(getState().autoPublish).toBe(""); }); }); // ─── validateLocaleFormat ───────────────────────────────────────────────────── -describe('validateLocaleFormat', () => { - it.each([ - ['en-us'], - ['fr-ca'], - ['es-es'], - ['EN-US'], - ['Zh-CN'], - ])('accepts valid locale %s', (locale) => { +describe("validateLocaleFormat", () => { + it.each([["en-us"], ["fr-ca"], ["es-es"], ["EN-US"], ["Zh-CN"]])("accepts valid locale %s", (locale) => { expect(validateLocaleFormat(locale)).toBe(true); }); - it.each([ - ['english'], - ['en'], - ['en-USA'], - ['en_us'], - ['e-us'], - ['123-456'], - [''], - ['en-u'], - ])('rejects invalid locale %s', (locale) => { - expect(validateLocaleFormat(locale)).toBe(false); - }); + it.each([["english"], ["en"], ["en-USA"], ["en_us"], ["e-us"], ["123-456"], [""], ["en-u"]])( + "rejects invalid locale %s", + (locale) => { + expect(validateLocaleFormat(locale)).toBe(false); + } + ); }); // ─── validateLocales ────────────────────────────────────────────────────────── -describe('validateLocales', () => { - it('separates valid from invalid locales', () => { - const result = validateLocales(['en-us', 'invalid', 'fr-ca', 'bad']); - expect(result.valid).toEqual(['en-us', 'fr-ca']); - expect(result.invalid).toEqual(['invalid', 'bad']); +describe("validateLocales", () => { + it("separates valid from invalid locales", () => { + const result = validateLocales(["en-us", "invalid", "fr-ca", "bad"]); + expect(result.valid).toEqual(["en-us", "fr-ca"]); + expect(result.invalid).toEqual(["invalid", "bad"]); }); - it('returns all valid when all locales are correct', () => { - const result = validateLocales(['en-us', 'fr-ca']); - expect(result.valid).toEqual(['en-us', 'fr-ca']); + it("returns all valid when all locales are correct", () => { + const result = validateLocales(["en-us", "fr-ca"]); + expect(result.valid).toEqual(["en-us", "fr-ca"]); expect(result.invalid).toEqual([]); }); - it('returns all invalid when all locales are wrong', () => { - const result = validateLocales(['bad', 'nope']); + it("returns all invalid when all locales are wrong", () => { + const result = validateLocales(["bad", "nope"]); expect(result.valid).toEqual([]); - expect(result.invalid).toEqual(['bad', 'nope']); + expect(result.invalid).toEqual(["bad", "nope"]); }); - it('handles empty array', () => { + it("handles empty array", () => { const result = validateLocales([]); expect(result.valid).toEqual([]); expect(result.invalid).toEqual([]); @@ -257,22 +245,22 @@ describe('validateLocales', () => { // ─── getUIMode ──────────────────────────────────────────────────────────────── -describe('getUIMode', () => { - it('returns useHeadless=false, useVerbose=false by default', () => { +describe("getUIMode", () => { + it("returns useHeadless=false, useVerbose=false by default", () => { expect(getUIMode()).toEqual({ useHeadless: false, useVerbose: false }); }); - it('returns useHeadless=true when headless is set', () => { + it("returns useHeadless=true when headless is set", () => { setState({ headless: true }); expect(getUIMode()).toEqual({ useHeadless: true, useVerbose: false }); }); - it('returns useVerbose=true when verbose is set and not headless', () => { + it("returns useVerbose=true when verbose is set and not headless", () => { setState({ verbose: true }); expect(getUIMode()).toEqual({ useHeadless: false, useVerbose: true }); }); - it('headless takes priority over verbose', () => { + it("headless takes priority over verbose", () => { setState({ headless: true, verbose: true }); expect(getUIMode()).toEqual({ useHeadless: true, useVerbose: false }); }); @@ -280,87 +268,87 @@ describe('getUIMode', () => { // ─── getCmsAppUrl ───────────────────────────────────────────────────────────── -describe('getCmsAppUrl', () => { - it('returns prod URL by default', () => { - expect(getCmsAppUrl()).toBe('https://app.agilitycms.com'); +describe("getCmsAppUrl", () => { + it("returns prod URL by default", () => { + expect(getCmsAppUrl()).toBe("https://app.agilitycms.com"); }); - it('returns QA URL when dev=true', () => { + it("returns QA URL when dev=true", () => { setState({ dev: true }); - expect(getCmsAppUrl()).toBe('https://app-qa.publishwithagility.com'); + expect(getCmsAppUrl()).toBe("https://app-qa.publishwithagility.com"); }); - it('returns QA URL when local=true', () => { + it("returns QA URL when local=true", () => { setState({ local: true }); - expect(getCmsAppUrl()).toBe('https://app-qa.publishwithagility.com'); + expect(getCmsAppUrl()).toBe("https://app-qa.publishwithagility.com"); }); - it('returns QA URL when preprod=true', () => { + it("returns QA URL when preprod=true", () => { setState({ preprod: true }); - expect(getCmsAppUrl()).toBe('https://app-qa.publishwithagility.com'); + expect(getCmsAppUrl()).toBe("https://app-qa.publishwithagility.com"); }); }); // ─── getPageCmsLink / getContentCmsLink ────────────────────────────────────── -describe('getPageCmsLink', () => { - it('builds the correct prod page URL', () => { - const url = getPageCmsLink('my-guid', 'en-us', 42); - expect(url).toBe('https://app.agilitycms.com/instance/my-guid/en-us/pages/page-42'); +describe("getPageCmsLink", () => { + it("builds the correct prod page URL", () => { + const url = getPageCmsLink("my-guid", "en-us", 42); + expect(url).toBe("https://app.agilitycms.com/instance/my-guid/en-us/pages/page-42"); }); - it('builds the QA page URL in dev mode', () => { + it("builds the QA page URL in dev mode", () => { setState({ dev: true }); - const url = getPageCmsLink('my-guid', 'en-us', 42); - expect(url).toBe('https://app-qa.publishwithagility.com/instance/my-guid/en-us/pages/page-42'); + const url = getPageCmsLink("my-guid", "en-us", 42); + expect(url).toBe("https://app-qa.publishwithagility.com/instance/my-guid/en-us/pages/page-42"); }); }); -describe('getContentCmsLink', () => { - it('builds the correct prod content URL', () => { - const url = getContentCmsLink('my-guid', 'en-us', 99); - expect(url).toBe('https://app.agilitycms.com/instance/my-guid/en-us/content/item-0/listitem-99'); +describe("getContentCmsLink", () => { + it("builds the correct prod content URL", () => { + const url = getContentCmsLink("my-guid", "en-us", 99); + expect(url).toBe("https://app.agilitycms.com/instance/my-guid/en-us/content/item-0/listitem-99"); }); }); // ─── API keys ──────────────────────────────────────────────────────────────── -describe('getApiKeysForGuid / getAllApiKeys', () => { +describe("getApiKeysForGuid / getAllApiKeys", () => { beforeEach(() => { getState().apiKeys = [ - { guid: 'guid-a', previewKey: 'prev-a', fetchKey: 'fetch-a' }, - { guid: 'guid-b', previewKey: 'prev-b', fetchKey: 'fetch-b' }, + { guid: "guid-a", previewKey: "prev-a", fetchKey: "fetch-a" }, + { guid: "guid-b", previewKey: "prev-b", fetchKey: "fetch-b" }, ]; }); - it('returns keys for a known GUID', () => { - expect(getApiKeysForGuid('guid-a')).toEqual({ previewKey: 'prev-a', fetchKey: 'fetch-a' }); + it("returns keys for a known GUID", () => { + expect(getApiKeysForGuid("guid-a")).toEqual({ previewKey: "prev-a", fetchKey: "fetch-a" }); }); - it('returns null for an unknown GUID', () => { - expect(getApiKeysForGuid('unknown')).toBeNull(); + it("returns null for an unknown GUID", () => { + expect(getApiKeysForGuid("unknown")).toBeNull(); }); - it('getAllApiKeys returns all entries', () => { + it("getAllApiKeys returns all entries", () => { expect(getAllApiKeys()).toHaveLength(2); }); }); // ─── Failed content registry ───────────────────────────────────────────────── -describe('failed content registry', () => { - it('registers and retrieves a failed content item', () => { - registerFailedContent(123, 'my-ref', 'some error', 'en-us'); +describe("failed content registry", () => { + it("registers and retrieves a failed content item", () => { + registerFailedContent(123, "my-ref", "some error", "en-us"); const result = getFailedContent(123); - expect(result).toEqual({ referenceName: 'my-ref', error: 'some error', locale: 'en-us' }); + expect(result).toEqual({ referenceName: "my-ref", error: "some error", locale: "en-us" }); }); - it('returns undefined for unknown content ID', () => { + it("returns undefined for unknown content ID", () => { expect(getFailedContent(999)).toBeUndefined(); }); - it('clears the registry', () => { - registerFailedContent(1, 'ref', 'err', 'en-us'); + it("clears the registry", () => { + registerFailedContent(1, "ref", "err", "en-us"); clearFailedContentRegistry(); expect(getFailedContent(1)).toBeUndefined(); }); @@ -368,27 +356,27 @@ describe('failed content registry', () => { // ─── Logger factory functions ───────────────────────────────────────────────── -describe('initializeLogger', () => { - it('creates and stores a logger on state', () => { - jest.spyOn(console, 'log').mockImplementation(() => {}); - const logger = initializeLogger('pull'); +describe("initializeLogger", () => { + it("creates and stores a logger on state", () => { + jest.spyOn(console, "log").mockImplementation(() => {}); + const logger = initializeLogger("pull"); expect(logger).toBeDefined(); expect(getState().logger).toBe(logger); (console.log as jest.Mock).mockRestore(); }); }); -describe('initializeGuidLogger / getLoggerForGuid', () => { - it('creates a logger for a specific GUID and retrieves it', () => { - jest.spyOn(console, 'log').mockImplementation(() => {}); - const logger = initializeGuidLogger('test-guid', 'push', 'content'); - const retrieved = getLoggerForGuid('test-guid'); +describe("initializeGuidLogger / getLoggerForGuid", () => { + it("creates a logger for a specific GUID and retrieves it", () => { + jest.spyOn(console, "log").mockImplementation(() => {}); + const logger = initializeGuidLogger("test-guid", "push", "content"); + const retrieved = getLoggerForGuid("test-guid"); expect(retrieved).toBe(logger); - expect(retrieved?.getGuid()).toBe('test-guid'); + expect(retrieved?.getGuid()).toBe("test-guid"); (console.log as jest.Mock).mockRestore(); }); - it('returns null for unknown GUID', () => { - expect(getLoggerForGuid('does-not-exist')).toBeNull(); + it("returns null for unknown GUID", () => { + expect(getLoggerForGuid("does-not-exist")).toBeNull(); }); }); diff --git a/src/core/tests/system-args.test.ts b/src/core/tests/system-args.test.ts index c04ffd0..0b9c169 100644 --- a/src/core/tests/system-args.test.ts +++ b/src/core/tests/system-args.test.ts @@ -1,14 +1,36 @@ -import { systemArgs } from '../system-args'; +import { systemArgs } from "../system-args"; // ─── Structure ───────────────────────────────────────────────────────────────── -describe('systemArgs – required keys exist', () => { +describe("systemArgs – required keys exist", () => { const expectedKeys = [ - 'token', 'dev', 'local', 'preprod', 'headless', 'verbose', - 'rootPath', 'legacyFolders', 'locale', 'channel', 'preview', - 'elements', 'insecure', 'baseUrl', 'models', 'modelsWithDeps', - 'test', 'dryRun', 'contentIDs', 'pageIDs', 'sourceGuid', 'targetGuid', - 'overwrite', 'force', 'update', 'reset', 'autoPublish', + "token", + "dev", + "local", + "preprod", + "headless", + "verbose", + "rootPath", + "legacyFolders", + "locale", + "channel", + "preview", + "elements", + "insecure", + "baseUrl", + "models", + "modelsWithDeps", + "test", + "dryRun", + "contentIDs", + "pageIDs", + "sourceGuid", + "targetGuid", + "overwrite", + "force", + "update", + "reset", + "autoPublish", ]; it.each(expectedKeys)('has key "%s"', (key) => { @@ -16,62 +38,86 @@ describe('systemArgs – required keys exist', () => { }); }); -describe('systemArgs – types', () => { +describe("systemArgs – types", () => { it('boolean args declare type "boolean"', () => { - const boolArgs = ['dev', 'local', 'preprod', 'headless', 'verbose', - 'legacyFolders', 'preview', 'insecure', 'test', 'dryRun', - 'overwrite', 'force', 'update', 'reset']; + const boolArgs = [ + "dev", + "local", + "preprod", + "headless", + "verbose", + "legacyFolders", + "preview", + "insecure", + "test", + "dryRun", + "overwrite", + "force", + "update", + "reset", + ]; for (const key of boolArgs) { - expect((systemArgs as any)[key].type).toBe('boolean'); + expect((systemArgs as any)[key].type).toBe("boolean"); } }); it('string args declare type "string"', () => { - const strArgs = ['token', 'rootPath', 'locale', 'channel', 'elements', - 'baseUrl', 'models', 'modelsWithDeps', 'contentIDs', - 'pageIDs', 'sourceGuid', 'targetGuid']; + const strArgs = [ + "token", + "rootPath", + "locale", + "channel", + "elements", + "baseUrl", + "models", + "modelsWithDeps", + "contentIDs", + "pageIDs", + "sourceGuid", + "targetGuid", + ]; for (const key of strArgs) { - expect((systemArgs as any)[key].type).toBe('string'); + expect((systemArgs as any)[key].type).toBe("string"); } }); }); -describe('systemArgs – defaults', () => { +describe("systemArgs – defaults", () => { it('rootPath defaults to "agility-files"', () => { - expect(systemArgs.rootPath.default).toBe('agility-files'); + expect(systemArgs.rootPath.default).toBe("agility-files"); }); it('channel defaults to "website"', () => { - expect(systemArgs.channel.default).toBe('website'); + expect(systemArgs.channel.default).toBe("website"); }); - it('preview defaults to true', () => { + it("preview defaults to true", () => { expect(systemArgs.preview.default).toBe(true); }); - it('headless defaults to false', () => { + it("headless defaults to false", () => { expect(systemArgs.headless.default).toBe(false); }); - it('overwrite defaults to false', () => { + it("overwrite defaults to false", () => { expect(systemArgs.overwrite.default).toBe(false); }); - it('force defaults to false', () => { + it("force defaults to false", () => { expect(systemArgs.force.default).toBe(false); }); - it('test defaults to false', () => { + it("test defaults to false", () => { expect(systemArgs.test.default).toBe(false); }); - it('dryRun defaults to false', () => { + it("dryRun defaults to false", () => { expect(systemArgs.dryRun.default).toBe(false); }); - it('elements includes all expected element types', () => { + it("elements includes all expected element types", () => { const defaultElements = systemArgs.elements.default as string; - const expected = ['Models', 'Galleries', 'Assets', 'Containers', 'Content', 'Templates', 'Pages', 'Sitemaps']; + const expected = ["Models", "Galleries", "Assets", "Containers", "Content", "Templates", "Pages", "Sitemaps"]; for (const element of expected) { expect(defaultElements).toContain(element); } @@ -80,68 +126,68 @@ describe('systemArgs – defaults', () => { // ─── autoPublish coerce function ───────────────────────────────────────────── -describe('systemArgs.autoPublish coerce', () => { +describe("systemArgs.autoPublish coerce", () => { const coerce = systemArgs.autoPublish.coerce as (v: string | boolean) => string; it('converts boolean true → "both"', () => { - expect(coerce(true)).toBe('both'); + expect(coerce(true)).toBe("both"); }); it('converts empty string → "both"', () => { - expect(coerce('')).toBe('both'); + expect(coerce("")).toBe("both"); }); it('converts boolean false → ""', () => { - expect(coerce(false)).toBe(''); + expect(coerce(false)).toBe(""); }); it('passes through "content"', () => { - expect(coerce('content')).toBe('content'); + expect(coerce("content")).toBe("content"); }); it('passes through "pages"', () => { - expect(coerce('pages')).toBe('pages'); + expect(coerce("pages")).toBe("pages"); }); it('passes through "both"', () => { - expect(coerce('both')).toBe('both'); + expect(coerce("both")).toBe("both"); }); - it('is case-insensitive for valid values', () => { - expect(coerce('CONTENT')).toBe('content'); - expect(coerce('Pages')).toBe('pages'); - expect(coerce('BOTH')).toBe('both'); + it("is case-insensitive for valid values", () => { + expect(coerce("CONTENT")).toBe("content"); + expect(coerce("Pages")).toBe("pages"); + expect(coerce("BOTH")).toBe("both"); }); it('defaults to "both" for unrecognized values', () => { - expect(coerce('unknown-value')).toBe('both'); + expect(coerce("unknown-value")).toBe("both"); }); }); // ─── aliases ───────────────────────────────────────────────────────────────── -describe('systemArgs – aliases', () => { +describe("systemArgs – aliases", () => { it('locale has "locales" alias', () => { - expect((systemArgs.locale as any).alias).toContain('locales'); + expect((systemArgs.locale as any).alias).toContain("locales"); }); it('dryRun has "dry-run" alias', () => { - expect((systemArgs.dryRun as any).alias).toContain('dry-run'); + expect((systemArgs.dryRun as any).alias).toContain("dry-run"); }); it('autoPublish has "auto-publish" alias', () => { - expect((systemArgs.autoPublish as any).alias).toContain('auto-publish'); + expect((systemArgs.autoPublish as any).alias).toContain("auto-publish"); }); it('models has "model" alias', () => { - expect((systemArgs.models as any).alias).toContain('model'); + expect((systemArgs.models as any).alias).toContain("model"); }); it('sourceGuid has "source-guid" alias', () => { - expect((systemArgs.sourceGuid as any).alias).toContain('source-guid'); + expect((systemArgs.sourceGuid as any).alias).toContain("source-guid"); }); it('targetGuid has "target-guid" alias', () => { - expect((systemArgs.targetGuid as any).alias).toContain('target-guid'); + expect((systemArgs.targetGuid as any).alias).toContain("target-guid"); }); }); diff --git a/src/index.ts b/src/index.ts index 1d19e85..4c41fdd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,15 @@ #!/usr/bin/env node // Enable TypeScript path mapping at runtime -const { register } = require('tsconfig-paths'); +const { register } = require("tsconfig-paths"); register({ baseUrl: __dirname, paths: { - 'lib/*': ['lib/*'], - 'core/*': ['core/*'], - 'core': ['core'], - 'types/*': ['types/*'] - } + "lib/*": ["lib/*"], + "core/*": ["core/*"], + core: ["core"], + "types/*": ["types/*"], + }, }); import * as yargs from "yargs"; @@ -19,7 +19,16 @@ import inquirer from "inquirer"; import searchList from "inquirer-search-list"; inquirer.registerPrompt("search-list", searchList); -import { Auth, state, setState, resetState, primeFromEnv, systemArgs, normalizeProcessArgs, normalizeArgv } from "./core"; +import { + Auth, + state, + setState, + resetState, + primeFromEnv, + systemArgs, + normalizeProcessArgs, + normalizeArgv, +} from "./core"; import { Pull } from "./core/pull"; import { Push } from "./core/push"; import { WorkflowOperation } from "./lib/workflows"; @@ -42,7 +51,9 @@ yargs.command({ console.log(colors.white(" pull - Pull your Agility instance locally")); console.log(colors.white(" push - Push your instance to a target instance")); console.log(colors.white(" sync - Sync your instance (alias for push with updates enabled)")); - console.log(colors.white(" workflowOperation - Perform workflow operations (publish, unpublish, approve, decline)")); + console.log( + colors.white(" workflowOperation - Perform workflow operations (publish, unpublish, approve, decline)") + ); console.log(colors.white("\nFor more information, use: --help")); console.log(""); }, @@ -64,7 +75,7 @@ yargs.command({ // Prime state from .env file before applying command line args const envPriming = primeFromEnv(); if (envPriming.hasEnvFile && envPriming.primedValues.length > 0) { - console.log(colors.cyan(`📄 Found .env file, primed: ${envPriming.primedValues.join(', ')}`)); + console.log(colors.cyan(`📄 Found .env file, primed: ${envPriming.primedValues.join(", ")}`)); } setState(argv); @@ -74,7 +85,11 @@ yargs.command({ console.log(colors.red("You are not authorized to login.")); return; } else { - console.log(colors.green("You are now logged in, you can now use the CLI commands such as 'pull', 'push', 'sync', 'genenv', etc.")); + console.log( + colors.green( + "You are now logged in, you can now use the CLI commands such as 'pull', 'push', 'sync', 'genenv', etc." + ) + ); process.exit(0); } }, @@ -85,7 +100,7 @@ yargs.command({ describe: "Log out of Agility.", builder: { // System args (commonly repeated across commands) - ...systemArgs + ...systemArgs, }, handler: async function (argv) { resetState(); // Clear any previous command state @@ -96,7 +111,7 @@ yargs.command({ // Prime state from .env file before applying command line args const envPriming = primeFromEnv(); if (envPriming.hasEnvFile && envPriming.primedValues.length > 0) { - console.log(colors.cyan(`📄 Found .env file, primed: ${envPriming.primedValues.join(', ')}`)); + console.log(colors.cyan(`📄 Found .env file, primed: ${envPriming.primedValues.join(", ")}`)); } setState(argv); @@ -105,13 +120,12 @@ yargs.command({ }, }); - yargs.command({ command: "pull", describe: "Pull your Agility instance locally.", builder: { // System args (commonly repeated across commands) - ...systemArgs + ...systemArgs, }, handler: async function (argv) { resetState(); // Clear any previous command state @@ -122,13 +136,13 @@ yargs.command({ // Prime state from .env file before applying command line args const envPriming = primeFromEnv(); if (envPriming.hasEnvFile && envPriming.primedValues.length > 0) { - console.log(colors.cyan(`📄 Found .env file, primed: ${envPriming.primedValues.join(', ')}`)); + console.log(colors.cyan(`📄 Found .env file, primed: ${envPriming.primedValues.join(", ")}`)); } setState(argv); state.update = true; // Ensure updates are enabled for pull state.isPull = true; - + auth = new Auth(); const isAuthorized = await auth.init(); if (!isAuthorized) { @@ -136,18 +150,16 @@ yargs.command({ } // Validate pull command requirements - const isValidCommand = await auth.validateCommand('pull'); + const isValidCommand = await auth.validateCommand("pull"); if (!isValidCommand) { return; } const pull = new Pull(); await pull.pullInstances(); - }, }); - // New 2-Pass Sync Command using the enhanced dependency system yargs.command({ command: "push", @@ -162,10 +174,9 @@ yargs.command({ }, // System args (commonly repeated across commands) - ...systemArgs + ...systemArgs, }, handler: async function (argv) { - const invokedAs = Array.isArray(argv._) && argv._.length > 0 ? String(argv._[0]) : ""; const isSync = invokedAs === "sync"; @@ -177,7 +188,7 @@ yargs.command({ // Prime state from .env file before applying command line args const envPriming = primeFromEnv(); if (envPriming.hasEnvFile && envPriming.primedValues.length > 0) { - console.log(colors.cyan(`📄 Found .env file, primed: ${envPriming.primedValues.join(', ')}`)); + console.log(colors.cyan(`📄 Found .env file, primed: ${envPriming.primedValues.join(", ")}`)); } setState(argv); @@ -197,22 +208,22 @@ yargs.command({ } // Validate sync command requirements - const isValidCommand = await auth.validateCommand('push'); + const isValidCommand = await auth.validateCommand("push"); if (!isValidCommand) { return; } const push = new Push(); await push.pushInstances(); - - } -}) + }, +}); // Workflow operation command - performs workflow operations on content/pages from existing mappings yargs.command({ command: "workflows", aliases: ["workflow"], - describe: "Perform workflow operations (publish, unpublish, approve, decline, requestApproval) on content and pages from existing mappings.", + describe: + "Perform workflow operations (publish, unpublish, approve, decline, requestApproval) on content and pages from existing mappings.", builder: { sourceGuid: { describe: "Source instance GUID (from the original sync).", @@ -229,11 +240,12 @@ yargs.command({ type: "boolean", default: false, }, - // Workflow operation type for batch workflow operations + // Workflow operation type for batch workflow operations operationType: { - describe: "Workflow operation to perform: publish, unpublish, approve, decline, or requestApproval. Used with workflowOperation command.", + describe: + "Workflow operation to perform: publish, unpublish, approve, decline, or requestApproval. Used with workflowOperation command.", type: "string" as const, - alias: ["operation-type", "operationType", "OperationType", "OPERATION_TYPE", "op","type"], + alias: ["operation-type", "operationType", "OperationType", "OPERATION_TYPE", "op", "type"], choices: ["publish", "unpublish", "approve", "decline", "requestApproval"], // default: "publish", coerce: (value: string) => { @@ -261,10 +273,10 @@ yargs.command({ default: return "publish"; } - } + }, }, // System args (commonly repeated across commands) - ...systemArgs + ...systemArgs, }, handler: async function (argv) { resetState(); // Clear any previous command state @@ -275,7 +287,7 @@ yargs.command({ // Prime state from .env file before applying command line args const envPriming = primeFromEnv(); if (envPriming.hasEnvFile && envPriming.primedValues.length > 0) { - console.log(colors.cyan(`📄 Found .env file, primed: ${envPriming.primedValues.join(', ')}`)); + console.log(colors.cyan(`📄 Found .env file, primed: ${envPriming.primedValues.join(", ")}`)); } setState(argv); @@ -294,7 +306,7 @@ yargs.command({ } // Validate command requirements - const isValidCommand = await auth.validateCommand('push'); + const isValidCommand = await auth.validateCommand("push"); if (!isValidCommand) { return; } @@ -305,12 +317,11 @@ yargs.command({ if (!result.success) { process.exit(1); } - } -}) + }, +}); // Normalize process.argv to handle rich text editor character conversions // (e.g., em dashes, curly quotes from Word/Notepad) normalizeProcessArgs(); yargs.parse(); - diff --git a/src/lib/assets/asset-reference-extractor.ts b/src/lib/assets/asset-reference-extractor.ts index 3b59ce3..865e1a7 100644 --- a/src/lib/assets/asset-reference-extractor.ts +++ b/src/lib/assets/asset-reference-extractor.ts @@ -1,156 +1,152 @@ /** * Asset Reference Extractor Service - * + * * Handles extraction of asset references from content fields and display * of asset dependencies in the sync analysis output. */ -import ansiColors from 'ansi-colors'; -import { - SourceEntities, - SyncAnalysisContext, - AssetReference, - ReferenceExtractionService -} from '../../types/syncAnalysis'; +import ansiColors from "ansi-colors"; +import { + SourceEntities, + SyncAnalysisContext, + AssetReference, + ReferenceExtractionService, +} from "../../types/syncAnalysis"; export class AssetReferenceExtractor implements ReferenceExtractionService { - private context?: SyncAnalysisContext; + private context?: SyncAnalysisContext; - /** - * Initialize the service with context - */ - initialize(context: SyncAnalysisContext): void { - this.context = context; - } + /** + * Initialize the service with context + */ + initialize(context: SyncAnalysisContext): void { + this.context = context; + } - /** - * Extract asset references from content fields - */ - extractReferences(fields: any): AssetReference[] { - return this.extractAssetReferences(fields); - } + /** + * Extract asset references from content fields + */ + extractReferences(fields: any): AssetReference[] { + return this.extractAssetReferences(fields); + } - /** - * Extract asset references from content fields - */ - extractAssetReferences(fields: any): AssetReference[] { - const references: AssetReference[] = []; - - if (!fields || typeof fields !== 'object') { - return references; - } - - // Helper to check if a string is an asset URL - // Matches any subdomain of aglty.io or agilitycms.com (e.g., cdn-usa2.aglty.io, cdn-eu.aglty.io, etc.) - const isAssetUrl = (url: string): boolean => { - if (typeof url !== 'string') return false; - // Check for Agility CMS asset URL patterns - match any subdomain - // Examples: cdn-usa2.aglty.io, cdn-eu.aglty.io, cdn.aglty.io, origin.aglty.io, etc. - return url.includes('.aglty.io') || url.includes('.agilitycms.com'); - }; - - const scanForAssets = (obj: any, path: string) => { - // Handle primitive values (strings, numbers, etc.) - if (obj === null || obj === undefined) return; - - // Check for asset URL references in strings - if (typeof obj === 'string' && isAssetUrl(obj)) { - references.push({ - url: obj, - fieldPath: path - }); - return; // Don't recurse into strings - } - - // Only process objects and arrays - if (typeof obj !== 'object') return; - - if (Array.isArray(obj)) { - obj.forEach((item, index) => { - scanForAssets(item, `${path}[${index}]`); - }); - } else { - // Check common asset fields in objects (url, originUrl, edgeUrl) - const urlFields = ['url', 'originUrl', 'edgeUrl']; - for (const fieldName of urlFields) { - if (obj[fieldName] && typeof obj[fieldName] === 'string' && isAssetUrl(obj[fieldName])) { - references.push({ - url: obj[fieldName], - fieldPath: `${path}.${fieldName}` - }); - } - } - - // Recursively scan nested objects - for (const [key, value] of Object.entries(obj)) { - scanForAssets(value, path ? `${path}.${key}` : key); - } - } - }; - - for (const [fieldName, fieldValue] of Object.entries(fields)) { - scanForAssets(fieldValue, fieldName); - } - - return references; + /** + * Extract asset references from content fields + */ + extractAssetReferences(fields: any): AssetReference[] { + const references: AssetReference[] = []; + + if (!fields || typeof fields !== "object") { + return references; } - /** - * Show content asset dependencies with proper formatting - */ - showContentAssetDependencies(content: any, sourceEntities: SourceEntities, indent: string): void { - if (!content.fields) return; - - const assetRefs = this.extractAssetReferences(content.fields); - assetRefs.forEach((assetRef: AssetReference) => { - const asset = sourceEntities.assets?.find((a: any) => - a.originUrl === assetRef.url || - a.url === assetRef.url || - a.edgeUrl === assetRef.url - ); - if (asset) { - console.log(`${indent}├─ ${ansiColors.yellow(`Asset:${asset.fileName || assetRef.url}`)}`); - // Check gallery dependency if asset has one - if (asset.mediaGroupingID) { - const gallery = sourceEntities.galleries?.find((g: any) => g.mediaGroupingID === asset.mediaGroupingID); - if (gallery) { - console.log(`${indent}│ ├─ ${ansiColors.magenta(`Gallery:${gallery.name || gallery.mediaGroupingID}`)}`); - } - } - } else { - console.log(`${indent}├─ ${ansiColors.red(`Asset:${assetRef.url} - MISSING IN SOURCE DATA`)}`); - } + // Helper to check if a string is an asset URL + // Matches any subdomain of aglty.io or agilitycms.com (e.g., cdn-usa2.aglty.io, cdn-eu.aglty.io, etc.) + const isAssetUrl = (url: string): boolean => { + if (typeof url !== "string") return false; + // Check for Agility CMS asset URL patterns - match any subdomain + // Examples: cdn-usa2.aglty.io, cdn-eu.aglty.io, cdn.aglty.io, origin.aglty.io, etc. + return url.includes(".aglty.io") || url.includes(".agilitycms.com"); + }; + + const scanForAssets = (obj: any, path: string) => { + // Handle primitive values (strings, numbers, etc.) + if (obj === null || obj === undefined) return; + + // Check for asset URL references in strings + if (typeof obj === "string" && isAssetUrl(obj)) { + references.push({ + url: obj, + fieldPath: path, }); - } + return; // Don't recurse into strings + } + + // Only process objects and arrays + if (typeof obj !== "object") return; - /** - * Find missing assets for content - */ - findMissingAssetsForContent(content: any, sourceEntities: SourceEntities): string[] { - const missing: string[] = []; - - if (!content.fields) return missing; - - const assetRefs = this.extractAssetReferences(content.fields); - assetRefs.forEach((assetRef: AssetReference) => { - const asset = sourceEntities.assets?.find((a: any) => - a.originUrl === assetRef.url || - a.url === assetRef.url || - a.edgeUrl === assetRef.url - ); - if (!asset) { - missing.push(`Asset:${assetRef.url}`); - } else { - // Check gallery dependency if asset has one - if (asset.mediaGroupingID) { - const gallery = sourceEntities.galleries?.find((g: any) => g.mediaGroupingID === asset.mediaGroupingID); - if (!gallery) { - missing.push(`Gallery:${asset.mediaGroupingID}`); - } - } - } + if (Array.isArray(obj)) { + obj.forEach((item, index) => { + scanForAssets(item, `${path}[${index}]`); }); + } else { + // Check common asset fields in objects (url, originUrl, edgeUrl) + const urlFields = ["url", "originUrl", "edgeUrl"]; + for (const fieldName of urlFields) { + if (obj[fieldName] && typeof obj[fieldName] === "string" && isAssetUrl(obj[fieldName])) { + references.push({ + url: obj[fieldName], + fieldPath: `${path}.${fieldName}`, + }); + } + } - return missing; + // Recursively scan nested objects + for (const [key, value] of Object.entries(obj)) { + scanForAssets(value, path ? `${path}.${key}` : key); + } + } + }; + + for (const [fieldName, fieldValue] of Object.entries(fields)) { + scanForAssets(fieldValue, fieldName); } -} \ No newline at end of file + + return references; + } + + /** + * Show content asset dependencies with proper formatting + */ + showContentAssetDependencies(content: any, sourceEntities: SourceEntities, indent: string): void { + if (!content.fields) return; + + const assetRefs = this.extractAssetReferences(content.fields); + assetRefs.forEach((assetRef: AssetReference) => { + const asset = sourceEntities.assets?.find( + (a: any) => a.originUrl === assetRef.url || a.url === assetRef.url || a.edgeUrl === assetRef.url + ); + if (asset) { + console.log(`${indent}├─ ${ansiColors.yellow(`Asset:${asset.fileName || assetRef.url}`)}`); + // Check gallery dependency if asset has one + if (asset.mediaGroupingID) { + const gallery = sourceEntities.galleries?.find((g: any) => g.mediaGroupingID === asset.mediaGroupingID); + if (gallery) { + console.log(`${indent}│ ├─ ${ansiColors.magenta(`Gallery:${gallery.name || gallery.mediaGroupingID}`)}`); + } + } + } else { + console.log(`${indent}├─ ${ansiColors.red(`Asset:${assetRef.url} - MISSING IN SOURCE DATA`)}`); + } + }); + } + + /** + * Find missing assets for content + */ + findMissingAssetsForContent(content: any, sourceEntities: SourceEntities): string[] { + const missing: string[] = []; + + if (!content.fields) return missing; + + const assetRefs = this.extractAssetReferences(content.fields); + assetRefs.forEach((assetRef: AssetReference) => { + const asset = sourceEntities.assets?.find( + (a: any) => a.originUrl === assetRef.url || a.url === assetRef.url || a.edgeUrl === assetRef.url + ); + if (!asset) { + missing.push(`Asset:${assetRef.url}`); + } else { + // Check gallery dependency if asset has one + if (asset.mediaGroupingID) { + const gallery = sourceEntities.galleries?.find((g: any) => g.mediaGroupingID === asset.mediaGroupingID); + if (!gallery) { + missing.push(`Gallery:${asset.mediaGroupingID}`); + } + } + } + }); + + return missing; + } +} diff --git a/src/lib/assets/asset-utils.ts b/src/lib/assets/asset-utils.ts index f24b304..6ae7fe0 100644 --- a/src/lib/assets/asset-utils.ts +++ b/src/lib/assets/asset-utils.ts @@ -1,4 +1,4 @@ -import * as path from 'path'; +import * as path from "path"; // Helper to get base file path (relative to assets folder) // Handles different URL structures: @@ -6,62 +6,65 @@ import * as path from 'path'; // 2. /instance-name/folder/file.jpg -> folder/file.jpg // 3. /instance-name/file.jpg -> file.jpg export function getAssetFilePath(originUrl: string): string { - try { - if (!originUrl) { - console.warn('[Asset Utils] Empty originUrl provided to getAssetFilePath'); - return 'unknown-asset'; - } - - let pathname: string; - try { - // Try parsing as a full URL first, encoding any spaces/special chars that make URL() throw - const safeUrl = originUrl.replace(/\s+/g, '%20'); - const url = new URL(safeUrl); - // Decode back so the returned path uses the original filename (with spaces) - pathname = decodeURIComponent(url.pathname); - } catch (e) { - // If not a full URL, assume it's a path like /instance-name/folder/file.jpg - if (typeof originUrl === 'string' && originUrl.startsWith('/')) { - pathname = originUrl.split('?')[0]; // Use the path directly, remove query params - } else { - console.error(`[Asset Utils] Cannot parse originUrl: ${originUrl}. It is not a full URL and does not start with /.`); - return 'error-parsing-asset-path'; - } - } - - const assetsMarker = '/assets/'; - const assetsIndex = pathname.indexOf(assetsMarker); + try { + if (!originUrl) { + console.warn("[Asset Utils] Empty originUrl provided to getAssetFilePath"); + return "unknown-asset"; + } - let relativePath: string; + let pathname: string; + try { + // Try parsing as a full URL first, encoding any spaces/special chars that make URL() throw + const safeUrl = originUrl.replace(/\s+/g, "%20"); + const url = new URL(safeUrl); + // Decode back so the returned path uses the original filename (with spaces) + pathname = decodeURIComponent(url.pathname); + } catch (e) { + // If not a full URL, assume it's a path like /instance-name/folder/file.jpg + if (typeof originUrl === "string" && originUrl.startsWith("/")) { + pathname = originUrl.split("?")[0]; // Use the path directly, remove query params + } else { + console.error( + `[Asset Utils] Cannot parse originUrl: ${originUrl}. It is not a full URL and does not start with /.` + ); + return "error-parsing-asset-path"; + } + } - if (assetsIndex !== -1) { - // Case 1: Found "/assets/", extract path after it - relativePath = pathname.substring(assetsIndex + assetsMarker.length); - } else if (pathname.startsWith('/')) { - // Case 2 & 3: Path starts with '/', assume /instance-name/... structure - const pathParts = pathname.split('/').filter(part => part !== ''); // Split and remove empty parts - if (pathParts.length > 1) { - // Remove the first part (instance-name or guid) and join the rest - // This assumes the first part is a segment NOT part of the asset's actual path in the container - relativePath = pathParts.slice(1).join('/'); - } else if (pathParts.length === 1) { - // Only one part after splitting, likely just the filename at the root level of the implicit container - relativePath = pathParts[0]; - } else { - console.warn(`[Asset Utils] Could not determine relative path from pathname: ${pathname}`); - relativePath = 'unknown-asset'; - } - } else { - // This case should ideally not be reached if the initial try/catch for URL parsing and path check works - console.warn(`[Asset Utils] Unexpected pathname format (not starting with '/' after URL parse failed): ${pathname}. Using it directly.`); - relativePath = pathname; // Fallback - } + const assetsMarker = "/assets/"; + const assetsIndex = pathname.indexOf(assetsMarker); - // Decode URI components and remove potential leading/trailing slashes - return decodeURIComponent(relativePath.replace(/^\/+|\/+$/g, '')); + let relativePath: string; - } catch (e: any) { - console.error(`[Asset Utils] Error parsing originUrl: ${originUrl}`, e); - return 'error-parsing-asset-path'; + if (assetsIndex !== -1) { + // Case 1: Found "/assets/", extract path after it + relativePath = pathname.substring(assetsIndex + assetsMarker.length); + } else if (pathname.startsWith("/")) { + // Case 2 & 3: Path starts with '/', assume /instance-name/... structure + const pathParts = pathname.split("/").filter((part) => part !== ""); // Split and remove empty parts + if (pathParts.length > 1) { + // Remove the first part (instance-name or guid) and join the rest + // This assumes the first part is a segment NOT part of the asset's actual path in the container + relativePath = pathParts.slice(1).join("/"); + } else if (pathParts.length === 1) { + // Only one part after splitting, likely just the filename at the root level of the implicit container + relativePath = pathParts[0]; + } else { + console.warn(`[Asset Utils] Could not determine relative path from pathname: ${pathname}`); + relativePath = "unknown-asset"; + } + } else { + // This case should ideally not be reached if the initial try/catch for URL parsing and path check works + console.warn( + `[Asset Utils] Unexpected pathname format (not starting with '/' after URL parse failed): ${pathname}. Using it directly.` + ); + relativePath = pathname; // Fallback } -} \ No newline at end of file + + // Decode URI components and remove potential leading/trailing slashes + return decodeURIComponent(relativePath.replace(/^\/+|\/+$/g, "")); + } catch (e: any) { + console.error(`[Asset Utils] Error parsing originUrl: ${originUrl}`, e); + return "error-parsing-asset-path"; + } +} diff --git a/src/lib/assets/index.ts b/src/lib/assets/index.ts index cb4f5d2..66ef732 100644 --- a/src/lib/assets/index.ts +++ b/src/lib/assets/index.ts @@ -1,2 +1,2 @@ -export * from './asset-utils'; -export * from './asset-reference-extractor'; +export * from "./asset-utils"; +export * from "./asset-reference-extractor"; diff --git a/src/lib/assets/tests/asset-reference-extractor.test.ts b/src/lib/assets/tests/asset-reference-extractor.test.ts index d8ea1ca..d234795 100644 --- a/src/lib/assets/tests/asset-reference-extractor.test.ts +++ b/src/lib/assets/tests/asset-reference-extractor.test.ts @@ -1,12 +1,12 @@ -import { resetState } from 'core/state'; -import { AssetReferenceExtractor } from 'lib/assets/asset-reference-extractor'; -import { AssetReference, SourceEntities, SyncAnalysisContext } from 'types/syncAnalysis'; +import { resetState } from "core/state"; +import { AssetReferenceExtractor } from "lib/assets/asset-reference-extractor"; +import { AssetReference, SourceEntities, SyncAnalysisContext } from "types/syncAnalysis"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -14,10 +14,10 @@ afterEach(() => { }); const makeContext = (overrides: Partial = {}): SyncAnalysisContext => ({ - sourceGuid: 'test-guid-u', - locale: 'en-us', + sourceGuid: "test-guid-u", + locale: "en-us", isPreview: false, - rootPath: 'agility-files', + rootPath: "agility-files", debug: false, elements: [], ...overrides, @@ -25,175 +25,170 @@ const makeContext = (overrides: Partial = {}): SyncAnalysis // ─── extractAssetReferences / extractReferences ─────────────────────────────── -describe('AssetReferenceExtractor.extractAssetReferences', () => { +describe("AssetReferenceExtractor.extractAssetReferences", () => { let extractor: AssetReferenceExtractor; beforeEach(() => { extractor = new AssetReferenceExtractor(); }); - describe('null / non-object inputs', () => { + describe("null / non-object inputs", () => { it.each([ - ['null', null], - ['undefined', undefined], - ['a number', 42], - ['a string', 'just a string'], - ['a boolean', true], - ])('returns [] for %s', (_label, input) => { + ["null", null], + ["undefined", undefined], + ["a number", 42], + ["a string", "just a string"], + ["a boolean", true], + ])("returns [] for %s", (_label, input) => { expect(extractor.extractAssetReferences(input)).toEqual([]); }); }); - describe('top-level string fields', () => { - it('finds an aglty.io URL in a top-level string field', () => { + describe("top-level string fields", () => { + it("finds an aglty.io URL in a top-level string field", () => { const refs = extractor.extractAssetReferences({ - image: 'https://cdn.aglty.io/guid/assets/photo.jpg', + image: "https://cdn.aglty.io/guid/assets/photo.jpg", }); expect(refs).toHaveLength(1); expect(refs[0]).toEqual({ - url: 'https://cdn.aglty.io/guid/assets/photo.jpg', - fieldPath: 'image', + url: "https://cdn.aglty.io/guid/assets/photo.jpg", + fieldPath: "image", }); }); - it('finds an agilitycms.com URL in a top-level string field', () => { + it("finds an agilitycms.com URL in a top-level string field", () => { const refs = extractor.extractAssetReferences({ - logo: 'https://cdn.agilitycms.com/guid/assets/logo.png', + logo: "https://cdn.agilitycms.com/guid/assets/logo.png", }); expect(refs).toHaveLength(1); - expect(refs[0].url).toBe('https://cdn.agilitycms.com/guid/assets/logo.png'); - expect(refs[0].fieldPath).toBe('logo'); + expect(refs[0].url).toBe("https://cdn.agilitycms.com/guid/assets/logo.png"); + expect(refs[0].fieldPath).toBe("logo"); }); - it('ignores top-level string fields that are not asset URLs', () => { + it("ignores top-level string fields that are not asset URLs", () => { const refs = extractor.extractAssetReferences({ - title: 'Hello world', - href: 'https://example.com/page', + title: "Hello world", + href: "https://example.com/page", }); expect(refs).toHaveLength(0); }); - it('collects multiple asset URLs from separate fields', () => { + it("collects multiple asset URLs from separate fields", () => { const refs = extractor.extractAssetReferences({ - hero: 'https://cdn.aglty.io/guid/assets/hero.jpg', - thumb: 'https://cdn-eu.aglty.io/guid/assets/thumb.jpg', + hero: "https://cdn.aglty.io/guid/assets/hero.jpg", + thumb: "https://cdn-eu.aglty.io/guid/assets/thumb.jpg", }); expect(refs).toHaveLength(2); - const paths = refs.map(r => r.fieldPath); - expect(paths).toContain('hero'); - expect(paths).toContain('thumb'); + const paths = refs.map((r) => r.fieldPath); + expect(paths).toContain("hero"); + expect(paths).toContain("thumb"); }); }); - describe('nested objects with url / originUrl / edgeUrl properties', () => { - it('picks up the url property of an object field', () => { + describe("nested objects with url / originUrl / edgeUrl properties", () => { + it("picks up the url property of an object field", () => { const refs = extractor.extractAssetReferences({ - attachment: { url: 'https://cdn.aglty.io/guid/assets/doc.pdf', size: 1024 }, + attachment: { url: "https://cdn.aglty.io/guid/assets/doc.pdf", size: 1024 }, }); - const assetRef = refs.find(r => r.url === 'https://cdn.aglty.io/guid/assets/doc.pdf'); + const assetRef = refs.find((r) => r.url === "https://cdn.aglty.io/guid/assets/doc.pdf"); expect(assetRef).toBeDefined(); - expect(assetRef!.fieldPath).toBe('attachment.url'); + expect(assetRef!.fieldPath).toBe("attachment.url"); }); - it('picks up the originUrl property of an object field', () => { + it("picks up the originUrl property of an object field", () => { const refs = extractor.extractAssetReferences({ - file: { originUrl: 'https://origin.aglty.io/guid/assets/file.zip' }, + file: { originUrl: "https://origin.aglty.io/guid/assets/file.zip" }, }); - const assetRef = refs.find(r => r.url === 'https://origin.aglty.io/guid/assets/file.zip'); + const assetRef = refs.find((r) => r.url === "https://origin.aglty.io/guid/assets/file.zip"); expect(assetRef).toBeDefined(); - expect(assetRef!.fieldPath).toBe('file.originUrl'); + expect(assetRef!.fieldPath).toBe("file.originUrl"); }); - it('picks up the edgeUrl property of an object field', () => { + it("picks up the edgeUrl property of an object field", () => { const refs = extractor.extractAssetReferences({ - media: { edgeUrl: 'https://cdn-usa2.aglty.io/guid/assets/vid.mp4' }, + media: { edgeUrl: "https://cdn-usa2.aglty.io/guid/assets/vid.mp4" }, }); - const assetRef = refs.find(r => r.url === 'https://cdn-usa2.aglty.io/guid/assets/vid.mp4'); + const assetRef = refs.find((r) => r.url === "https://cdn-usa2.aglty.io/guid/assets/vid.mp4"); expect(assetRef).toBeDefined(); - expect(assetRef!.fieldPath).toBe('media.edgeUrl'); + expect(assetRef!.fieldPath).toBe("media.edgeUrl"); }); - it('does not duplicate when scanning url-named properties that are already non-asset strings', () => { + it("does not duplicate when scanning url-named properties that are already non-asset strings", () => { const refs = extractor.extractAssetReferences({ - link: { url: 'https://example.com/not-an-asset' }, + link: { url: "https://example.com/not-an-asset" }, }); expect(refs).toHaveLength(0); }); }); - describe('array fields', () => { - it('finds asset URLs inside an array of strings', () => { + describe("array fields", () => { + it("finds asset URLs inside an array of strings", () => { const refs = extractor.extractAssetReferences({ - gallery: [ - 'https://cdn.aglty.io/guid/assets/img1.jpg', - 'https://cdn.aglty.io/guid/assets/img2.jpg', - ], + gallery: ["https://cdn.aglty.io/guid/assets/img1.jpg", "https://cdn.aglty.io/guid/assets/img2.jpg"], }); expect(refs).toHaveLength(2); - expect(refs[0].fieldPath).toBe('gallery[0]'); - expect(refs[1].fieldPath).toBe('gallery[1]'); + expect(refs[0].fieldPath).toBe("gallery[0]"); + expect(refs[1].fieldPath).toBe("gallery[1]"); }); - it('finds asset URLs inside an array of objects (url property)', () => { + it("finds asset URLs inside an array of objects (url property)", () => { const refs = extractor.extractAssetReferences({ items: [ - { url: 'https://cdn.aglty.io/guid/assets/a.jpg', label: 'A' }, - { url: 'https://cdn.aglty.io/guid/assets/b.jpg', label: 'B' }, + { url: "https://cdn.aglty.io/guid/assets/a.jpg", label: "A" }, + { url: "https://cdn.aglty.io/guid/assets/b.jpg", label: "B" }, ], }); - const urls = refs.map(r => r.url); - expect(urls).toContain('https://cdn.aglty.io/guid/assets/a.jpg'); - expect(urls).toContain('https://cdn.aglty.io/guid/assets/b.jpg'); + const urls = refs.map((r) => r.url); + expect(urls).toContain("https://cdn.aglty.io/guid/assets/a.jpg"); + expect(urls).toContain("https://cdn.aglty.io/guid/assets/b.jpg"); }); - it('skips non-asset array items', () => { + it("skips non-asset array items", () => { const refs = extractor.extractAssetReferences({ - tags: ['news', 'tech', 'design'], + tags: ["news", "tech", "design"], }); expect(refs).toHaveLength(0); }); }); - describe('deeply nested structures', () => { - it('recurses into nested objects to find asset URLs', () => { + describe("deeply nested structures", () => { + it("recurses into nested objects to find asset URLs", () => { const refs = extractor.extractAssetReferences({ section: { hero: { - background: 'https://cdn.aglty.io/guid/assets/bg.jpg', + background: "https://cdn.aglty.io/guid/assets/bg.jpg", }, }, }); expect(refs).toHaveLength(1); - expect(refs[0].url).toBe('https://cdn.aglty.io/guid/assets/bg.jpg'); + expect(refs[0].url).toBe("https://cdn.aglty.io/guid/assets/bg.jpg"); }); }); }); // ─── extractReferences (public alias) ──────────────────────────────────────── -describe('AssetReferenceExtractor.extractReferences', () => { - it('delegates to extractAssetReferences and returns the same result', () => { +describe("AssetReferenceExtractor.extractReferences", () => { + it("delegates to extractAssetReferences and returns the same result", () => { const extractor = new AssetReferenceExtractor(); - const fields = { image: 'https://cdn.aglty.io/guid/assets/pic.jpg' }; - expect(extractor.extractReferences(fields)).toEqual( - extractor.extractAssetReferences(fields) - ); + const fields = { image: "https://cdn.aglty.io/guid/assets/pic.jpg" }; + expect(extractor.extractReferences(fields)).toEqual(extractor.extractAssetReferences(fields)); }); }); // ─── initialize ─────────────────────────────────────────────────────────────── -describe('AssetReferenceExtractor.initialize', () => { - it('stores context without throwing', () => { +describe("AssetReferenceExtractor.initialize", () => { + it("stores context without throwing", () => { const extractor = new AssetReferenceExtractor(); expect(() => extractor.initialize(makeContext())).not.toThrow(); }); - it('continues to extract references correctly after initialization', () => { + it("continues to extract references correctly after initialization", () => { const extractor = new AssetReferenceExtractor(); extractor.initialize(makeContext()); const refs = extractor.extractAssetReferences({ - img: 'https://cdn.aglty.io/guid/assets/x.png', + img: "https://cdn.aglty.io/guid/assets/x.png", }); expect(refs).toHaveLength(1); }); @@ -201,148 +196,148 @@ describe('AssetReferenceExtractor.initialize', () => { // ─── showContentAssetDependencies ───────────────────────────────────────────── -describe('AssetReferenceExtractor.showContentAssetDependencies', () => { +describe("AssetReferenceExtractor.showContentAssetDependencies", () => { let extractor: AssetReferenceExtractor; let logSpy: jest.SpyInstance; beforeEach(() => { extractor = new AssetReferenceExtractor(); - logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); }); - it('logs nothing when content has no fields', () => { - extractor.showContentAssetDependencies({}, {}, ' '); + it("logs nothing when content has no fields", () => { + extractor.showContentAssetDependencies({}, {}, " "); expect(logSpy).not.toHaveBeenCalled(); }); - it('logs a line with the asset fileName when the asset is found in sourceEntities', () => { + it("logs a line with the asset fileName when the asset is found in sourceEntities", () => { const content = { - fields: { hero: 'https://cdn.aglty.io/guid/assets/banner.jpg' }, + fields: { hero: "https://cdn.aglty.io/guid/assets/banner.jpg" }, }; const sourceEntities: SourceEntities = { assets: [ { - url: 'https://cdn.aglty.io/guid/assets/banner.jpg', - fileName: 'banner.jpg', + url: "https://cdn.aglty.io/guid/assets/banner.jpg", + fileName: "banner.jpg", mediaGroupingID: null, }, ], }; - extractor.showContentAssetDependencies(content, sourceEntities, ' '); + extractor.showContentAssetDependencies(content, sourceEntities, " "); expect(logSpy).toHaveBeenCalledTimes(1); - expect(logSpy.mock.calls[0][0]).toContain('banner.jpg'); + expect(logSpy.mock.calls[0][0]).toContain("banner.jpg"); }); - it('logs a MISSING line when the asset is not found in sourceEntities', () => { + it("logs a MISSING line when the asset is not found in sourceEntities", () => { const content = { - fields: { hero: 'https://cdn.aglty.io/guid/assets/missing.jpg' }, + fields: { hero: "https://cdn.aglty.io/guid/assets/missing.jpg" }, }; - extractor.showContentAssetDependencies(content, { assets: [] }, ' '); + extractor.showContentAssetDependencies(content, { assets: [] }, " "); expect(logSpy).toHaveBeenCalledTimes(1); - expect(logSpy.mock.calls[0][0]).toContain('MISSING IN SOURCE DATA'); + expect(logSpy.mock.calls[0][0]).toContain("MISSING IN SOURCE DATA"); }); - it('logs a gallery line when the asset belongs to a gallery present in sourceEntities', () => { + it("logs a gallery line when the asset belongs to a gallery present in sourceEntities", () => { const content = { - fields: { hero: 'https://cdn.aglty.io/guid/assets/photo.jpg' }, + fields: { hero: "https://cdn.aglty.io/guid/assets/photo.jpg" }, }; const sourceEntities: SourceEntities = { assets: [ { - url: 'https://cdn.aglty.io/guid/assets/photo.jpg', - fileName: 'photo.jpg', + url: "https://cdn.aglty.io/guid/assets/photo.jpg", + fileName: "photo.jpg", mediaGroupingID: 99, }, ], - galleries: [{ mediaGroupingID: 99, name: 'My Gallery' }], + galleries: [{ mediaGroupingID: 99, name: "My Gallery" }], }; - extractor.showContentAssetDependencies(content, sourceEntities, ' '); + extractor.showContentAssetDependencies(content, sourceEntities, " "); expect(logSpy).toHaveBeenCalledTimes(2); const galleryLine = logSpy.mock.calls[1][0] as string; - expect(galleryLine).toContain('My Gallery'); + expect(galleryLine).toContain("My Gallery"); }); - it('does not log a gallery line when the gallery is absent from sourceEntities', () => { + it("does not log a gallery line when the gallery is absent from sourceEntities", () => { const content = { - fields: { hero: 'https://cdn.aglty.io/guid/assets/photo.jpg' }, + fields: { hero: "https://cdn.aglty.io/guid/assets/photo.jpg" }, }; const sourceEntities: SourceEntities = { assets: [ { - url: 'https://cdn.aglty.io/guid/assets/photo.jpg', - fileName: 'photo.jpg', + url: "https://cdn.aglty.io/guid/assets/photo.jpg", + fileName: "photo.jpg", mediaGroupingID: 99, }, ], galleries: [], }; - extractor.showContentAssetDependencies(content, sourceEntities, ' '); + extractor.showContentAssetDependencies(content, sourceEntities, " "); // Only the asset line; no gallery line expect(logSpy).toHaveBeenCalledTimes(1); }); - it('matches assets by originUrl', () => { + it("matches assets by originUrl", () => { const content = { - fields: { hero: 'https://cdn.aglty.io/guid/assets/photo.jpg' }, + fields: { hero: "https://cdn.aglty.io/guid/assets/photo.jpg" }, }; const sourceEntities: SourceEntities = { assets: [ { - originUrl: 'https://cdn.aglty.io/guid/assets/photo.jpg', - edgeUrl: 'https://edge.aglty.io/guid/assets/photo.jpg', - fileName: 'photo.jpg', + originUrl: "https://cdn.aglty.io/guid/assets/photo.jpg", + edgeUrl: "https://edge.aglty.io/guid/assets/photo.jpg", + fileName: "photo.jpg", }, ], }; - extractor.showContentAssetDependencies(content, sourceEntities, ''); + extractor.showContentAssetDependencies(content, sourceEntities, ""); expect(logSpy).toHaveBeenCalledTimes(1); - expect(logSpy.mock.calls[0][0]).toContain('photo.jpg'); - expect(logSpy.mock.calls[0][0]).not.toContain('MISSING'); + expect(logSpy.mock.calls[0][0]).toContain("photo.jpg"); + expect(logSpy.mock.calls[0][0]).not.toContain("MISSING"); }); }); // ─── findMissingAssetsForContent ────────────────────────────────────────────── -describe('AssetReferenceExtractor.findMissingAssetsForContent', () => { +describe("AssetReferenceExtractor.findMissingAssetsForContent", () => { let extractor: AssetReferenceExtractor; beforeEach(() => { extractor = new AssetReferenceExtractor(); }); - it('returns [] when content has no fields', () => { + it("returns [] when content has no fields", () => { expect(extractor.findMissingAssetsForContent({}, {})).toEqual([]); }); - it('returns [] when all referenced assets are present in sourceEntities', () => { + it("returns [] when all referenced assets are present in sourceEntities", () => { const content = { - fields: { hero: 'https://cdn.aglty.io/guid/assets/img.jpg' }, + fields: { hero: "https://cdn.aglty.io/guid/assets/img.jpg" }, }; const sourceEntities: SourceEntities = { - assets: [{ url: 'https://cdn.aglty.io/guid/assets/img.jpg', fileName: 'img.jpg' }], + assets: [{ url: "https://cdn.aglty.io/guid/assets/img.jpg", fileName: "img.jpg" }], }; expect(extractor.findMissingAssetsForContent(content, sourceEntities)).toEqual([]); }); - it('reports a missing asset URL when the asset is not in sourceEntities', () => { + it("reports a missing asset URL when the asset is not in sourceEntities", () => { const content = { - fields: { hero: 'https://cdn.aglty.io/guid/assets/gone.jpg' }, + fields: { hero: "https://cdn.aglty.io/guid/assets/gone.jpg" }, }; const missing = extractor.findMissingAssetsForContent(content, { assets: [] }); expect(missing).toHaveLength(1); - expect(missing[0]).toContain('Asset:'); - expect(missing[0]).toContain('gone.jpg'); + expect(missing[0]).toContain("Asset:"); + expect(missing[0]).toContain("gone.jpg"); }); - it('reports a missing gallery when the gallery is absent from sourceEntities', () => { + it("reports a missing gallery when the gallery is absent from sourceEntities", () => { const content = { - fields: { hero: 'https://cdn.aglty.io/guid/assets/photo.jpg' }, + fields: { hero: "https://cdn.aglty.io/guid/assets/photo.jpg" }, }; const sourceEntities: SourceEntities = { assets: [ { - url: 'https://cdn.aglty.io/guid/assets/photo.jpg', - fileName: 'photo.jpg', + url: "https://cdn.aglty.io/guid/assets/photo.jpg", + fileName: "photo.jpg", mediaGroupingID: 42, }, ], @@ -350,47 +345,47 @@ describe('AssetReferenceExtractor.findMissingAssetsForContent', () => { }; const missing = extractor.findMissingAssetsForContent(content, sourceEntities); expect(missing).toHaveLength(1); - expect(missing[0]).toContain('Gallery:42'); + expect(missing[0]).toContain("Gallery:42"); }); - it('reports nothing for gallery when the gallery is present', () => { + it("reports nothing for gallery when the gallery is present", () => { const content = { - fields: { hero: 'https://cdn.aglty.io/guid/assets/photo.jpg' }, + fields: { hero: "https://cdn.aglty.io/guid/assets/photo.jpg" }, }; const sourceEntities: SourceEntities = { assets: [ { - url: 'https://cdn.aglty.io/guid/assets/photo.jpg', - fileName: 'photo.jpg', + url: "https://cdn.aglty.io/guid/assets/photo.jpg", + fileName: "photo.jpg", mediaGroupingID: 42, }, ], - galleries: [{ mediaGroupingID: 42, name: 'Good Gallery' }], + galleries: [{ mediaGroupingID: 42, name: "Good Gallery" }], }; expect(extractor.findMissingAssetsForContent(content, sourceEntities)).toEqual([]); }); - it('reports multiple missing assets', () => { + it("reports multiple missing assets", () => { const content = { fields: { - hero: 'https://cdn.aglty.io/guid/assets/a.jpg', - thumb: 'https://cdn.aglty.io/guid/assets/b.jpg', + hero: "https://cdn.aglty.io/guid/assets/a.jpg", + thumb: "https://cdn.aglty.io/guid/assets/b.jpg", }, }; const missing = extractor.findMissingAssetsForContent(content, { assets: [] }); expect(missing).toHaveLength(2); }); - it('matches assets by originUrl and edgeUrl as well', () => { + it("matches assets by originUrl and edgeUrl as well", () => { const content = { - fields: { hero: 'https://cdn.aglty.io/guid/assets/photo.jpg' }, + fields: { hero: "https://cdn.aglty.io/guid/assets/photo.jpg" }, }; const sourceEntities: SourceEntities = { assets: [ { - originUrl: 'https://cdn.aglty.io/guid/assets/photo.jpg', - edgeUrl: 'https://edge.aglty.io/guid/assets/photo.jpg', - fileName: 'photo.jpg', + originUrl: "https://cdn.aglty.io/guid/assets/photo.jpg", + edgeUrl: "https://edge.aglty.io/guid/assets/photo.jpg", + fileName: "photo.jpg", }, ], }; diff --git a/src/lib/assets/tests/asset-utils.test.ts b/src/lib/assets/tests/asset-utils.test.ts index a61a529..f72da63 100644 --- a/src/lib/assets/tests/asset-utils.test.ts +++ b/src/lib/assets/tests/asset-utils.test.ts @@ -1,11 +1,11 @@ -import { resetState } from 'core/state'; -import { getAssetFilePath } from 'lib/assets/asset-utils'; +import { resetState } from "core/state"; +import { getAssetFilePath } from "lib/assets/asset-utils"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -14,107 +14,98 @@ afterEach(() => { // ─── getAssetFilePath ───────────────────────────────────────────────────────── -describe('getAssetFilePath', () => { - describe('full URLs with /assets/ segment', () => { - it('extracts the path after /assets/ from a cdn.agilitycms.com URL', () => { - const result = getAssetFilePath('https://cdn.agilitycms.com/guid/assets/folder/file.jpg'); - expect(result).toBe('folder/file.jpg'); +describe("getAssetFilePath", () => { + describe("full URLs with /assets/ segment", () => { + it("extracts the path after /assets/ from a cdn.agilitycms.com URL", () => { + const result = getAssetFilePath("https://cdn.agilitycms.com/guid/assets/folder/file.jpg"); + expect(result).toBe("folder/file.jpg"); }); - it('extracts the path after /assets/ from an aglty.io URL', () => { - const result = getAssetFilePath('https://cdn-usa2.aglty.io/guid/assets/images/hero.png'); - expect(result).toBe('images/hero.png'); + it("extracts the path after /assets/ from an aglty.io URL", () => { + const result = getAssetFilePath("https://cdn-usa2.aglty.io/guid/assets/images/hero.png"); + expect(result).toBe("images/hero.png"); }); - it('extracts a deeply nested path after /assets/', () => { - const result = getAssetFilePath('https://cdn.agilitycms.com/guid/assets/2024/01/docs/report.pdf'); - expect(result).toBe('2024/01/docs/report.pdf'); + it("extracts a deeply nested path after /assets/", () => { + const result = getAssetFilePath("https://cdn.agilitycms.com/guid/assets/2024/01/docs/report.pdf"); + expect(result).toBe("2024/01/docs/report.pdf"); }); - it('strips query parameters from URLs with /assets/', () => { - const result = getAssetFilePath('https://cdn.agilitycms.com/guid/assets/image.jpg?w=800&h=600'); - expect(result).toBe('image.jpg'); + it("strips query parameters from URLs with /assets/", () => { + const result = getAssetFilePath("https://cdn.agilitycms.com/guid/assets/image.jpg?w=800&h=600"); + expect(result).toBe("image.jpg"); }); - it('handles a filename directly under /assets/ (no subdirectory)', () => { - const result = getAssetFilePath('https://cdn.agilitycms.com/guid/assets/logo.svg'); - expect(result).toBe('logo.svg'); + it("handles a filename directly under /assets/ (no subdirectory)", () => { + const result = getAssetFilePath("https://cdn.agilitycms.com/guid/assets/logo.svg"); + expect(result).toBe("logo.svg"); }); }); - describe('path-style URLs (no scheme)', () => { - it('removes the first segment (instance name) and returns the rest', () => { - const result = getAssetFilePath('/instance-name/folder/file.jpg'); - expect(result).toBe('folder/file.jpg'); + describe("path-style URLs (no scheme)", () => { + it("removes the first segment (instance name) and returns the rest", () => { + const result = getAssetFilePath("/instance-name/folder/file.jpg"); + expect(result).toBe("folder/file.jpg"); }); - it('returns the filename when there is only one segment after the instance name', () => { - const result = getAssetFilePath('/instance-name/file.jpg'); - expect(result).toBe('file.jpg'); + it("returns the filename when there is only one segment after the instance name", () => { + const result = getAssetFilePath("/instance-name/file.jpg"); + expect(result).toBe("file.jpg"); }); - it('strips query parameters from path-style URLs', () => { - const result = getAssetFilePath('/instance-name/image.png?foo=bar'); - expect(result).toBe('image.png'); + it("strips query parameters from path-style URLs", () => { + const result = getAssetFilePath("/instance-name/image.png?foo=bar"); + expect(result).toBe("image.png"); }); - it('handles deeply nested path-style URLs', () => { - const result = getAssetFilePath('/my-instance/a/b/c/file.txt'); - expect(result).toBe('a/b/c/file.txt'); + it("handles deeply nested path-style URLs", () => { + const result = getAssetFilePath("/my-instance/a/b/c/file.txt"); + expect(result).toBe("a/b/c/file.txt"); }); }); - describe('URLs with spaces / encoded characters', () => { - it('decodes percent-encoded characters in full URLs', () => { - const result = getAssetFilePath('https://cdn.agilitycms.com/guid/assets/my%20file.jpg'); - expect(result).toBe('my file.jpg'); + describe("URLs with spaces / encoded characters", () => { + it("decodes percent-encoded characters in full URLs", () => { + const result = getAssetFilePath("https://cdn.agilitycms.com/guid/assets/my%20file.jpg"); + expect(result).toBe("my file.jpg"); }); - it('handles spaces in full URL by encoding them before parsing', () => { - const result = getAssetFilePath('https://cdn.agilitycms.com/guid/assets/my file.jpg'); - expect(result).toBe('my file.jpg'); + it("handles spaces in full URL by encoding them before parsing", () => { + const result = getAssetFilePath("https://cdn.agilitycms.com/guid/assets/my file.jpg"); + expect(result).toBe("my file.jpg"); }); }); - describe('edge / error cases', () => { + describe("edge / error cases", () => { it('returns "unknown-asset" for an empty string and logs a warning', () => { - const warnSpy = jest.spyOn(console, 'warn'); - const result = getAssetFilePath(''); - expect(result).toBe('unknown-asset'); + const warnSpy = jest.spyOn(console, "warn"); + const result = getAssetFilePath(""); + expect(result).toBe("unknown-asset"); expect(warnSpy).toHaveBeenCalled(); }); it('returns "error-parsing-asset-path" for a non-URL, non-path string and logs an error', () => { - const errorSpy = jest.spyOn(console, 'error'); - const result = getAssetFilePath('not-a-url-or-path'); - expect(result).toBe('error-parsing-asset-path'); + const errorSpy = jest.spyOn(console, "error"); + const result = getAssetFilePath("not-a-url-or-path"); + expect(result).toBe("error-parsing-asset-path"); expect(errorSpy).toHaveBeenCalled(); }); - it('handles a path that is just a single slash (empty segments)', () => { - const warnSpy = jest.spyOn(console, 'warn'); - const result = getAssetFilePath('/'); + it("handles a path that is just a single slash (empty segments)", () => { + const warnSpy = jest.spyOn(console, "warn"); + const result = getAssetFilePath("/"); // path.split('/').filter(x => x !== '') yields [] — falls into warn + 'unknown-asset' - expect(result).toBe('unknown-asset'); + expect(result).toBe("unknown-asset"); expect(warnSpy).toHaveBeenCalled(); }); }); - describe('table-driven: known URL patterns', () => { + describe("table-driven: known URL patterns", () => { it.each([ - [ - 'https://cdn-eu.aglty.io/abc123/assets/photos/cat.jpg', - 'photos/cat.jpg', - ], - [ - 'https://origin.aglty.io/abc123/assets/videos/intro.mp4', - 'videos/intro.mp4', - ], - [ - '/my-guid/subfolder/document.pdf', - 'subfolder/document.pdf', - ], - ])('getAssetFilePath(%s) === %s', (input, expected) => { + ["https://cdn-eu.aglty.io/abc123/assets/photos/cat.jpg", "photos/cat.jpg"], + ["https://origin.aglty.io/abc123/assets/videos/intro.mp4", "videos/intro.mp4"], + ["/my-guid/subfolder/document.pdf", "subfolder/document.pdf"], + ])("getAssetFilePath(%s) === %s", (input, expected) => { expect(getAssetFilePath(input)).toBe(expected); }); }); diff --git a/src/lib/content/content-classifier.ts b/src/lib/content/content-classifier.ts index fbb14b4..c728c62 100644 --- a/src/lib/content/content-classifier.ts +++ b/src/lib/content/content-classifier.ts @@ -1,283 +1,282 @@ -import * as mgmtApi from '@agility/management-sdk'; +import * as mgmtApi from "@agility/management-sdk"; /** * Content classification result */ export interface ContentClassification { - normalContentItems: mgmtApi.ContentItem[]; - linkedContentItems: mgmtApi.ContentItem[]; - classificationDetails: { - totalItems: number; - normalCount: number; - linkedCount: number; - analysisTime: number; - }; + normalContentItems: mgmtApi.ContentItem[]; + linkedContentItems: mgmtApi.ContentItem[]; + classificationDetails: { + totalItems: number; + normalCount: number; + linkedCount: number; + analysisTime: number; + }; } /** * Model field analysis cache */ interface ModelFieldAnalysis { - hasLinkedContentFields: boolean; - linkedContentFieldNames: string[]; - fieldTypeMap: Map; // fieldName -> fieldType - cachedAt: number; + hasLinkedContentFields: boolean; + linkedContentFieldNames: string[]; + fieldTypeMap: Map; // fieldName -> fieldType + cachedAt: number; } /** * Content Classifier - separates content into normal vs linked based on legacy pattern - * + * * Based on push_legacy.ts logic: * - Normal content: No Content fields with linked content references * - Linked content: Has Content fields with LinkeContentDropdownValueField, SortIDFieldName, contentid, etc. */ export class ContentClassifier { - private modelAnalysisCache = new Map(); - private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes - - /** - * Classify content items into normal vs linked categories - */ - async classifyContent( - contentItems: mgmtApi.ContentItem[], - models: mgmtApi.Model[] - ): Promise { - const startTime = Date.now(); - - const normalContentItems: mgmtApi.ContentItem[] = []; - const linkedContentItems: mgmtApi.ContentItem[] = []; - - // Build model lookup for efficient analysis - const modelLookup = new Map(); - models.forEach(model => { - modelLookup.set(model.referenceName, model); - }); - - // Classify each content item - for (const contentItem of contentItems) { - const definitionName = contentItem.properties?.definitionName; - if (!definitionName) { - // No model definition - treat as normal content - normalContentItems.push(contentItem); - continue; - } - - const model = modelLookup.get(definitionName); - if (!model) { - // Model not found - treat as normal content - normalContentItems.push(contentItem); - continue; - } - - // Analyze content item against model - const hasLinkedContentReferences = this.hasLinkedContentReferences(contentItem, model); - - if (hasLinkedContentReferences) { - linkedContentItems.push(contentItem); - } else { - normalContentItems.push(contentItem); - } - } - - const analysisTime = Date.now() - startTime; - - return { - normalContentItems, - linkedContentItems, - classificationDetails: { - totalItems: contentItems.length, - normalCount: normalContentItems.length, - linkedCount: linkedContentItems.length, - analysisTime - } - }; + private modelAnalysisCache = new Map(); + private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes + + /** + * Classify content items into normal vs linked categories + */ + async classifyContent(contentItems: mgmtApi.ContentItem[], models: mgmtApi.Model[]): Promise { + const startTime = Date.now(); + + const normalContentItems: mgmtApi.ContentItem[] = []; + const linkedContentItems: mgmtApi.ContentItem[] = []; + + // Build model lookup for efficient analysis + const modelLookup = new Map(); + models.forEach((model) => { + modelLookup.set(model.referenceName, model); + }); + + // Classify each content item + for (const contentItem of contentItems) { + const definitionName = contentItem.properties?.definitionName; + if (!definitionName) { + // No model definition - treat as normal content + normalContentItems.push(contentItem); + continue; + } + + const model = modelLookup.get(definitionName); + if (!model) { + // Model not found - treat as normal content + normalContentItems.push(contentItem); + continue; + } + + // Analyze content item against model + const hasLinkedContentReferences = this.hasLinkedContentReferences(contentItem, model); + + if (hasLinkedContentReferences) { + linkedContentItems.push(contentItem); + } else { + normalContentItems.push(contentItem); + } } - /** - * Check if content item has linked content references based on model fields - */ - private hasLinkedContentReferences(contentItem: mgmtApi.ContentItem, model: mgmtApi.Model): boolean { - // Get cached model analysis or create new one - const modelAnalysis = this.getModelAnalysis(model); - - // If model has no Content fields, it can't have linked content - if (!modelAnalysis.hasLinkedContentFields) { - return false; - } - - // Check content item fields for actual linked content references - return this.checkContentFieldsForLinkedReferences(contentItem, modelAnalysis); + const analysisTime = Date.now() - startTime; + + return { + normalContentItems, + linkedContentItems, + classificationDetails: { + totalItems: contentItems.length, + normalCount: normalContentItems.length, + linkedCount: linkedContentItems.length, + analysisTime, + }, + }; + } + + /** + * Check if content item has linked content references based on model fields + */ + private hasLinkedContentReferences(contentItem: mgmtApi.ContentItem, model: mgmtApi.Model): boolean { + // Get cached model analysis or create new one + const modelAnalysis = this.getModelAnalysis(model); + + // If model has no Content fields, it can't have linked content + if (!modelAnalysis.hasLinkedContentFields) { + return false; } - /** - * Get or create model field analysis with caching - */ - private getModelAnalysis(model: mgmtApi.Model): ModelFieldAnalysis { - const cacheKey = model.referenceName; - const cached = this.modelAnalysisCache.get(cacheKey); - - // Check cache validity - if (cached && (Date.now() - cached.cachedAt) < this.CACHE_TTL) { - return cached; - } - - // Analyze model fields - const analysis = this.analyzeModelFields(model); - this.modelAnalysisCache.set(cacheKey, analysis); - - return analysis; + // Check content item fields for actual linked content references + return this.checkContentFieldsForLinkedReferences(contentItem, modelAnalysis); + } + + /** + * Get or create model field analysis with caching + */ + private getModelAnalysis(model: mgmtApi.Model): ModelFieldAnalysis { + const cacheKey = model.referenceName; + const cached = this.modelAnalysisCache.get(cacheKey); + + // Check cache validity + if (cached && Date.now() - cached.cachedAt < this.CACHE_TTL) { + return cached; } - /** - * Analyze model fields to identify Content fields and their settings - */ - private analyzeModelFields(model: mgmtApi.Model): ModelFieldAnalysis { - const linkedContentFieldNames: string[] = []; - const fieldTypeMap = new Map(); - let hasLinkedContentFields = false; - - if (!model.fields) { - return { - hasLinkedContentFields: false, - linkedContentFieldNames: [], - fieldTypeMap, - cachedAt: Date.now() - }; - } - - model.fields.forEach(field => { - const fieldName = this.camelize(field.name); - fieldTypeMap.set(fieldName, field.type); - - // Check for Content fields (from legacy push_legacy.ts logic) - if (field.type === 'Content') { - hasLinkedContentFields = true; - linkedContentFieldNames.push(fieldName); - } - }); - - return { - hasLinkedContentFields, - linkedContentFieldNames, - fieldTypeMap, - cachedAt: Date.now() - }; + // Analyze model fields + const analysis = this.analyzeModelFields(model); + this.modelAnalysisCache.set(cacheKey, analysis); + + return analysis; + } + + /** + * Analyze model fields to identify Content fields and their settings + */ + private analyzeModelFields(model: mgmtApi.Model): ModelFieldAnalysis { + const linkedContentFieldNames: string[] = []; + const fieldTypeMap = new Map(); + let hasLinkedContentFields = false; + + if (!model.fields) { + return { + hasLinkedContentFields: false, + linkedContentFieldNames: [], + fieldTypeMap, + cachedAt: Date.now(), + }; } - /** - * Check content item fields for actual linked content references - */ - private checkContentFieldsForLinkedReferences( - contentItem: mgmtApi.ContentItem, - modelAnalysis: ModelFieldAnalysis - ): boolean { - if (!contentItem.fields) { - return false; - } - - // Check each Content field for linked content patterns - for (const fieldName of modelAnalysis.linkedContentFieldNames) { - const fieldValue = contentItem.fields[fieldName]; - - if (!fieldValue) { - continue; - } - - // Check for linked content patterns (from push_legacy.ts) - if (this.hasLinkedContentPatterns(fieldValue)) { - return true; - } - } - - // Also check for direct contentid/contentID references in any object field - return this.hasDirectContentReferences(contentItem.fields); + model.fields.forEach((field) => { + const fieldName = this.camelize(field.name); + fieldTypeMap.set(fieldName, field.type); + + // Check for Content fields (from legacy push_legacy.ts logic) + if (field.type === "Content") { + hasLinkedContentFields = true; + linkedContentFieldNames.push(fieldName); + } + }); + + return { + hasLinkedContentFields, + linkedContentFieldNames, + fieldTypeMap, + cachedAt: Date.now(), + }; + } + + /** + * Check content item fields for actual linked content references + */ + private checkContentFieldsForLinkedReferences( + contentItem: mgmtApi.ContentItem, + modelAnalysis: ModelFieldAnalysis + ): boolean { + if (!contentItem.fields) { + return false; } - /** - * Check field value for linked content patterns from legacy logic - */ - private hasLinkedContentPatterns(fieldValue: any): boolean { - if (typeof fieldValue !== 'object' || fieldValue === null) { - return false; - } - - // Pattern 1: contentid or contentID reference (legacy: fieldVal.contentid) - if ('contentid' in fieldValue || 'contentID' in fieldValue) { - return true; - } - - // Pattern 2: sortids array (legacy: fieldVal.sortids) - if ('sortids' in fieldValue) { - return true; - } - - // Pattern 3: referencename with content references (legacy: fieldVal.referencename) - if ('referencename' in fieldValue) { - return true; - } - - return false; + // Check each Content field for linked content patterns + for (const fieldName of modelAnalysis.linkedContentFieldNames) { + const fieldValue = contentItem.fields[fieldName]; + + if (!fieldValue) { + continue; + } + + // Check for linked content patterns (from push_legacy.ts) + if (this.hasLinkedContentPatterns(fieldValue)) { + return true; + } } - /** - * Check for direct content ID references in any field - */ - private hasDirectContentReferences(fields: any): boolean { - // Recursively scan for contentid/contentID patterns - return this.scanObjectForContentReferences(fields); + // Also check for direct contentid/contentID references in any object field + return this.hasDirectContentReferences(contentItem.fields); + } + + /** + * Check field value for linked content patterns from legacy logic + */ + private hasLinkedContentPatterns(fieldValue: any): boolean { + if (typeof fieldValue !== "object" || fieldValue === null) { + return false; } - /** - * Recursively scan object for content reference patterns - */ - private scanObjectForContentReferences(obj: any): boolean { - if (typeof obj !== 'object' || obj === null) { - return false; - } - - if (Array.isArray(obj)) { - return obj.some(item => this.scanObjectForContentReferences(item)); - } - - for (const [key, value] of Object.entries(obj)) { - // Direct content reference patterns - if ((key === 'contentid' || key === 'contentID') && typeof value === 'number') { - return true; - } - - // Recursive scan for nested objects - if (this.scanObjectForContentReferences(value)) { - return true; - } - } - - return false; + // Pattern 1: contentid or contentID reference (legacy: fieldVal.contentid) + if ("contentid" in fieldValue || "contentID" in fieldValue) { + return true; } - /** - * Convert field name to camelCase (from legacy logic) - */ - private camelize(str: string): string { - return str.replace(/(?:^\w|[A-Z]|\b\w)/g, function(word, index) { - return index === 0 ? word.toLowerCase() : word.toUpperCase(); - }).replace(/\s+/g, ''); + // Pattern 2: sortids array (legacy: fieldVal.sortids) + if ("sortids" in fieldValue) { + return true; } - /** - * Clear model analysis cache - */ - clearCache(): void { - this.modelAnalysisCache.clear(); + // Pattern 3: referencename with content references (legacy: fieldVal.referencename) + if ("referencename" in fieldValue) { + return true; } - /** - * Get classification statistics - */ - getClassificationStats(classification: ContentClassification): string { - const { classificationDetails } = classification; - const normalPercent = Math.round((classificationDetails.normalCount / classificationDetails.totalItems) * 100); - const linkedPercent = Math.round((classificationDetails.linkedCount / classificationDetails.totalItems) * 100); - - return `Content Classification: ${classificationDetails.normalCount} normal (${normalPercent}%) + ${classificationDetails.linkedCount} linked (${linkedPercent}%) = ${classificationDetails.totalItems} total (${classificationDetails.analysisTime}ms)`; + return false; + } + + /** + * Check for direct content ID references in any field + */ + private hasDirectContentReferences(fields: any): boolean { + // Recursively scan for contentid/contentID patterns + return this.scanObjectForContentReferences(fields); + } + + /** + * Recursively scan object for content reference patterns + */ + private scanObjectForContentReferences(obj: any): boolean { + if (typeof obj !== "object" || obj === null) { + return false; } -} \ No newline at end of file + + if (Array.isArray(obj)) { + return obj.some((item) => this.scanObjectForContentReferences(item)); + } + + for (const [key, value] of Object.entries(obj)) { + // Direct content reference patterns + if ((key === "contentid" || key === "contentID") && typeof value === "number") { + return true; + } + + // Recursive scan for nested objects + if (this.scanObjectForContentReferences(value)) { + return true; + } + } + + return false; + } + + /** + * Convert field name to camelCase (from legacy logic) + */ + private camelize(str: string): string { + return str + .replace(/(?:^\w|[A-Z]|\b\w)/g, function (word, index) { + return index === 0 ? word.toLowerCase() : word.toUpperCase(); + }) + .replace(/\s+/g, ""); + } + + /** + * Clear model analysis cache + */ + clearCache(): void { + this.modelAnalysisCache.clear(); + } + + /** + * Get classification statistics + */ + getClassificationStats(classification: ContentClassification): string { + const { classificationDetails } = classification; + const normalPercent = Math.round((classificationDetails.normalCount / classificationDetails.totalItems) * 100); + const linkedPercent = Math.round((classificationDetails.linkedCount / classificationDetails.totalItems) * 100); + + return `Content Classification: ${classificationDetails.normalCount} normal (${normalPercent}%) + ${classificationDetails.linkedCount} linked (${linkedPercent}%) = ${classificationDetails.totalItems} total (${classificationDetails.analysisTime}ms)`; + } +} diff --git a/src/lib/content/content-field-mapper.ts b/src/lib/content/content-field-mapper.ts index 454811e..a9f821a 100644 --- a/src/lib/content/content-field-mapper.ts +++ b/src/lib/content/content-field-mapper.ts @@ -1,5 +1,5 @@ import { AssetReferenceExtractor } from "../assets/asset-reference-extractor"; -import * as mgmtApi from '@agility/management-sdk'; +import * as mgmtApi from "@agility/management-sdk"; import { ContentItemMapper } from "lib/mappers/content-item-mapper"; import { AssetMapper } from "lib/mappers/asset-mapper"; @@ -28,11 +28,11 @@ export class ContentFieldMapper { } mapContentFields(fields: any, context?: ContentFieldMappingContext): ContentFieldMappingResult { - if (!fields || typeof fields !== 'object') { + if (!fields || typeof fields !== "object") { return { mappedFields: fields, validationWarnings: 0, - validationErrors: 0 + validationErrors: 0, }; } @@ -57,11 +57,15 @@ export class ContentFieldMapper { return { mappedFields, validationWarnings, - validationErrors + validationErrors, }; } - private mapSingleField(fieldName: string, fieldValue: any, context?: ContentFieldMappingContext): { + private mapSingleField( + fieldName: string, + fieldValue: any, + context?: ContentFieldMappingContext + ): { mappedValue: any; warnings: number; errors: number; @@ -96,14 +100,14 @@ export class ContentFieldMapper { errors += contentMappingResult.errors; } // Handle URL fields with potential asset references - else if (typeof fieldValue === 'string' && fieldValue.includes('cdn.aglty.io')) { + else if (typeof fieldValue === "string" && fieldValue.includes("cdn.aglty.io")) { const urlMappingResult = this.mapAssetUrlString(fieldValue, context); mappedValue = urlMappingResult.mappedValue; warnings += urlMappingResult.warnings; errors += urlMappingResult.errors; } // Handle nested objects recursively - else if (typeof fieldValue === 'object' && fieldValue !== null) { + else if (typeof fieldValue === "object" && fieldValue !== null) { if (Array.isArray(fieldValue)) { mappedValue = fieldValue.map((item, index) => { const itemResult = this.mapSingleField(`${fieldName}[${index}]`, item, context); @@ -126,33 +130,36 @@ export class ContentFieldMapper { } private isAssetAttachmentField(fieldValue: any): boolean { - if (!fieldValue || typeof fieldValue !== 'object') return false; + if (!fieldValue || typeof fieldValue !== "object") return false; // Check for asset attachment patterns if (Array.isArray(fieldValue)) { - return fieldValue.some(item => item && typeof item === 'object' && 'url' in item); + return fieldValue.some((item) => item && typeof item === "object" && "url" in item); } else { - return 'url' in fieldValue && typeof fieldValue.url === 'string'; + return "url" in fieldValue && typeof fieldValue.url === "string"; } } private isContentReferenceField(fieldValue: any): boolean { - if (!fieldValue || typeof fieldValue !== 'object') return false; + if (!fieldValue || typeof fieldValue !== "object") return false; // Check for content reference patterns - return 'contentid' in fieldValue || 'contentID' in fieldValue || 'sortids' in fieldValue; + return "contentid" in fieldValue || "contentID" in fieldValue || "sortids" in fieldValue; } private isListReferenceField(fieldValue: any): boolean { - if (!fieldValue || typeof fieldValue !== 'object') return false; + if (!fieldValue || typeof fieldValue !== "object") return false; // Check for list reference patterns (referencename with fulllist) - const hasReferencename = 'referencename' in fieldValue || 'referenceName' in fieldValue; + const hasReferencename = "referencename" in fieldValue || "referenceName" in fieldValue; const hasFulllist = fieldValue.fulllist === true || fieldValue.fullList === true; return hasReferencename && hasFulllist; } - private mapAssetAttachmentField(fieldValue: any, context?: ContentFieldMappingContext): { + private mapAssetAttachmentField( + fieldValue: any, + context?: ContentFieldMappingContext + ): { mappedValue: any; warnings: number; errors: number; @@ -166,8 +173,8 @@ export class ContentFieldMapper { if (Array.isArray(fieldValue)) { // AttachmentList - array of asset objects - const mappedArray = fieldValue.map(assetObj => { - if (assetObj && typeof assetObj === 'object' && assetObj.url) { + const mappedArray = fieldValue.map((assetObj) => { + if (assetObj && typeof assetObj === "object" && assetObj.url) { const mappedUrl = this.mapAssetUrl(assetObj.url, context); if (mappedUrl !== assetObj.url) { return { ...assetObj, url: mappedUrl }; @@ -188,7 +195,10 @@ export class ContentFieldMapper { } } - private mapContentReferenceField(fieldValue: any, context?: ContentFieldMappingContext): { + private mapContentReferenceField( + fieldValue: any, + context?: ContentFieldMappingContext + ): { mappedValue: any; warnings: number; errors: number; @@ -204,7 +214,7 @@ export class ContentFieldMapper { // Map contentid/contentID references if (fieldValue.contentid || fieldValue.contentID) { const sourceContentId = fieldValue.contentid || fieldValue.contentID; - const contentMapping = context.referenceMapper.getContentItemMappingByContentID(sourceContentId, 'source'); + const contentMapping = context.referenceMapper.getContentItemMappingByContentID(sourceContentId, "source"); if (contentMapping && (contentMapping as any).contentID) { if (fieldValue.contentid !== undefined) { mappedValue.contentid = (contentMapping as any).contentID; @@ -219,18 +229,24 @@ export class ContentFieldMapper { // Map sortids (comma-separated content IDs) if (fieldValue.sortids) { - const sourceIds = fieldValue.sortids.toString().split(',').map(id => parseInt(id.trim())); - const mappedIds = sourceIds.map(sourceId => { - const mapping = context.referenceMapper.getContentItemMappingByContentID(sourceId, 'source'); + const sourceIds = fieldValue.sortids + .toString() + .split(",") + .map((id) => parseInt(id.trim())); + const mappedIds = sourceIds.map((sourceId) => { + const mapping = context.referenceMapper.getContentItemMappingByContentID(sourceId, "source"); return mapping ? (mapping as any).contentID : sourceId; }); - mappedValue.sortids = mappedIds.join(','); + mappedValue.sortids = mappedIds.join(","); } return { mappedValue, warnings, errors }; } - private mapAssetUrlString(url: string, context?: ContentFieldMappingContext): { + private mapAssetUrlString( + url: string, + context?: ContentFieldMappingContext + ): { mappedValue: string; warnings: number; errors: number; @@ -239,12 +255,11 @@ export class ContentFieldMapper { return { mappedValue: mappedUrl, warnings: mappedUrl === url ? 1 : 0, // Warning if no mapping found - errors: 0 + errors: 0, }; } private mapAssetUrl(sourceUrl: string, context?: ContentFieldMappingContext): string { - // Try exact URL match first const assetMapping = context.assetMapper.getAssetMappingByMediaUrl(sourceUrl, "source"); if (assetMapping) { diff --git a/src/lib/content/content-field-validation.ts b/src/lib/content/content-field-validation.ts index da9a57b..80ba84e 100644 --- a/src/lib/content/content-field-validation.ts +++ b/src/lib/content/content-field-validation.ts @@ -1,383 +1,403 @@ /** * Content Field Validation Service - * + * * Validates and sanitizes content fields before mapping to ensure: * - Proper reference types and structures - * - Asset URL validity + * - Asset URL validity * - Content ID reference validation * - Field type compliance with Agility CMS expectations */ -import { LinkTypeDetector } from '../shared'; +import { LinkTypeDetector } from "../shared"; export interface FieldValidationResult { - isValid: boolean; - field: any; - warnings: string[]; - errors: string[]; - sanitizedField?: any; + isValid: boolean; + field: any; + warnings: string[]; + errors: string[]; + sanitizedField?: any; } export interface ContentValidationOptions { - sourceAssets?: any[]; - sourceContainers?: any[]; - modelDefinitions?: any[]; - strictMode?: boolean; // If true, invalid references cause errors; if false, warnings + sourceAssets?: any[]; + sourceContainers?: any[]; + modelDefinitions?: any[]; + strictMode?: boolean; // If true, invalid references cause errors; if false, warnings } export class ContentFieldValidator { - private linkTypeDetector: LinkTypeDetector; - - constructor() { - this.linkTypeDetector = new LinkTypeDetector(); + private linkTypeDetector: LinkTypeDetector; + + constructor() { + this.linkTypeDetector = new LinkTypeDetector(); + } + + /** + * Validate all fields in a content item + */ + public validateContentFields( + fields: any, + options: ContentValidationOptions = {} + ): { + isValid: boolean; + validatedFields: any; + totalWarnings: number; + totalErrors: number; + fieldResults: Map; + } { + if (!fields || typeof fields !== "object") { + return { + isValid: true, + validatedFields: fields, + totalWarnings: 0, + totalErrors: 0, + fieldResults: new Map(), + }; } - /** - * Validate all fields in a content item - */ - public validateContentFields(fields: any, options: ContentValidationOptions = {}): { - isValid: boolean; - validatedFields: any; - totalWarnings: number; - totalErrors: number; - fieldResults: Map; - } { - if (!fields || typeof fields !== 'object') { - return { - isValid: true, - validatedFields: fields, - totalWarnings: 0, - totalErrors: 0, - fieldResults: new Map() - }; - } + const fieldResults = new Map(); + const validatedFields: any = {}; + let totalWarnings = 0; + let totalErrors = 0; + let overallValid = true; - const fieldResults = new Map(); - const validatedFields: any = {}; - let totalWarnings = 0; - let totalErrors = 0; - let overallValid = true; - - for (const [fieldKey, fieldValue] of Object.entries(fields)) { - const result = this.validateSingleField(fieldKey, fieldValue, options); - fieldResults.set(fieldKey, result); - - validatedFields[fieldKey] = result.sanitizedField ?? result.field; - totalWarnings += result.warnings.length; - totalErrors += result.errors.length; - - if (!result.isValid) { - overallValid = false; - } - } + for (const [fieldKey, fieldValue] of Object.entries(fields)) { + const result = this.validateSingleField(fieldKey, fieldValue, options); + fieldResults.set(fieldKey, result); - return { - isValid: overallValid, - validatedFields, - totalWarnings, - totalErrors, - fieldResults - }; + validatedFields[fieldKey] = result.sanitizedField ?? result.field; + totalWarnings += result.warnings.length; + totalErrors += result.errors.length; + + if (!result.isValid) { + overallValid = false; + } } - /** - * Validate a single field with type-specific rules - */ - private validateSingleField(fieldKey: string, fieldValue: any, options: ContentValidationOptions): FieldValidationResult { - const result: FieldValidationResult = { - isValid: true, - field: fieldValue, - warnings: [], - errors: [] - }; - - // Handle null/undefined - always valid - if (fieldValue === null || fieldValue === undefined) { - return result; - } + return { + isValid: overallValid, + validatedFields, + totalWarnings, + totalErrors, + fieldResults, + }; + } + + /** + * Validate a single field with type-specific rules + */ + private validateSingleField( + fieldKey: string, + fieldValue: any, + options: ContentValidationOptions + ): FieldValidationResult { + const result: FieldValidationResult = { + isValid: true, + field: fieldValue, + warnings: [], + errors: [], + }; + + // Handle null/undefined - always valid + if (fieldValue === null || fieldValue === undefined) { + return result; + } - // Validate object fields (content references, nested structures) - if (typeof fieldValue === 'object' && fieldValue !== null) { - return this.validateObjectField(fieldKey, fieldValue, options); - } + // Validate object fields (content references, nested structures) + if (typeof fieldValue === "object" && fieldValue !== null) { + return this.validateObjectField(fieldKey, fieldValue, options); + } - // Validate string fields (asset URLs, text content) - if (typeof fieldValue === 'string') { - return this.validateStringField(fieldKey, fieldValue, options); - } + // Validate string fields (asset URLs, text content) + if (typeof fieldValue === "string") { + return this.validateStringField(fieldKey, fieldValue, options); + } - // Validate numeric fields - if (typeof fieldValue === 'number') { - return this.validateNumericField(fieldKey, fieldValue, options); - } + // Validate numeric fields + if (typeof fieldValue === "number") { + return this.validateNumericField(fieldKey, fieldValue, options); + } - // Primitive fields (boolean, etc.) are always valid - return result; + // Primitive fields (boolean, etc.) are always valid + return result; + } + + /** + * Validate object fields with content references + */ + private validateObjectField( + fieldKey: string, + fieldValue: any, + options: ContentValidationOptions + ): FieldValidationResult { + const result: FieldValidationResult = { + isValid: true, + field: fieldValue, + warnings: [], + errors: [], + }; + + // Validate contentid/contentID references + if ("contentid" in fieldValue || "contentID" in fieldValue) { + const contentId = fieldValue.contentid || fieldValue.contentID; + if (typeof contentId !== "number" || contentId <= 0) { + result.errors.push(`Invalid content ID: ${contentId} in field ${fieldKey}`); + result.isValid = false; + } } - /** - * Validate object fields with content references - */ - private validateObjectField(fieldKey: string, fieldValue: any, options: ContentValidationOptions): FieldValidationResult { - const result: FieldValidationResult = { - isValid: true, - field: fieldValue, - warnings: [], - errors: [] - }; - - // Validate contentid/contentID references - if ('contentid' in fieldValue || 'contentID' in fieldValue) { - const contentId = fieldValue.contentid || fieldValue.contentID; - if (typeof contentId !== 'number' || contentId <= 0) { - result.errors.push(`Invalid content ID: ${contentId} in field ${fieldKey}`); - result.isValid = false; - } - } + // Validate LinkedContentDropdown pattern + if (fieldValue.referencename && fieldValue.sortids) { + const sortIds = fieldValue.sortids.toString(); - // Validate LinkedContentDropdown pattern - if (fieldValue.referencename && fieldValue.sortids) { - const sortIds = fieldValue.sortids.toString(); - - // Validate sortids format (comma-separated numbers) - const ids = sortIds.split(',').map(id => id.trim()); - const invalidIds = ids.filter(id => isNaN(parseInt(id)) || parseInt(id) <= 0); - - if (invalidIds.length > 0) { - result.errors.push(`Invalid sort IDs in field ${fieldKey}: ${invalidIds.join(', ')}`); - result.isValid = false; - } - - // Validate reference name if containers are available - if (options.sourceContainers) { - const containerExists = options.sourceContainers.some(c => - c.referenceName === fieldValue.referencename - ); - if (!containerExists) { - result.warnings.push(`Container reference ${fieldValue.referencename} not found in field ${fieldKey}`); - } - } - } + // Validate sortids format (comma-separated numbers) + const ids = sortIds.split(",").map((id) => id.trim()); + const invalidIds = ids.filter((id) => isNaN(parseInt(id)) || parseInt(id) <= 0); - // Validate gallery references - if (fieldValue.mediaGroupingID) { - const galleryId = fieldValue.mediaGroupingID; - if (typeof galleryId !== 'number' || galleryId <= 0) { - result.errors.push(`Invalid gallery ID: ${galleryId} in field ${fieldKey}`); - result.isValid = false; - } - } + if (invalidIds.length > 0) { + result.errors.push(`Invalid sort IDs in field ${fieldKey}: ${invalidIds.join(", ")}`); + result.isValid = false; + } - // Recursive validation for nested objects/arrays - if (Array.isArray(fieldValue)) { - fieldValue.forEach((item, index) => { - if (typeof item === 'object' && item !== null) { - const nestedResult = this.validateObjectField(`${fieldKey}[${index}]`, item, options); - result.warnings.push(...nestedResult.warnings); - result.errors.push(...nestedResult.errors); - if (!nestedResult.isValid) { - result.isValid = false; - } - } - }); + // Validate reference name if containers are available + if (options.sourceContainers) { + const containerExists = options.sourceContainers.some((c) => c.referenceName === fieldValue.referencename); + if (!containerExists) { + result.warnings.push(`Container reference ${fieldValue.referencename} not found in field ${fieldKey}`); } + } + } - return result; + // Validate gallery references + if (fieldValue.mediaGroupingID) { + const galleryId = fieldValue.mediaGroupingID; + if (typeof galleryId !== "number" || galleryId <= 0) { + result.errors.push(`Invalid gallery ID: ${galleryId} in field ${fieldKey}`); + result.isValid = false; + } } - /** - * Validate string fields - */ - private validateStringField(fieldKey: string, fieldValue: string, options: ContentValidationOptions): FieldValidationResult { - const result: FieldValidationResult = { - isValid: true, - field: fieldValue, - warnings: [], - errors: [] - }; - - // Validate asset URLs - if (fieldValue.includes('cdn.aglty.io')) { - if (!this.isValidAssetUrl(fieldValue)) { - result.errors.push(`Invalid asset URL format in field ${fieldKey}: ${fieldValue}`); - result.isValid = false; - } else if (options.sourceAssets) { - // Check if asset exists in source data - const assetExists = options.sourceAssets.some(asset => - asset.originUrl === fieldValue || - asset.url === fieldValue || - asset.edgeUrl === fieldValue - ); - if (!assetExists) { - result.warnings.push(`Asset URL not found in source data for field ${fieldKey}: ${fieldValue}`); - } - } + // Recursive validation for nested objects/arrays + if (Array.isArray(fieldValue)) { + fieldValue.forEach((item, index) => { + if (typeof item === "object" && item !== null) { + const nestedResult = this.validateObjectField(`${fieldKey}[${index}]`, item, options); + result.warnings.push(...nestedResult.warnings); + result.errors.push(...nestedResult.errors); + if (!nestedResult.isValid) { + result.isValid = false; + } } + }); + } - // Validate content ID strings (CategoryID, ValueField patterns) - if (this.isContentIdField(fieldKey, fieldValue)) { - const contentIds = fieldValue.includes(',') ? - fieldValue.split(',').map(id => id.trim()) : - [fieldValue.trim()]; - - const invalidIds = contentIds.filter(id => isNaN(parseInt(id)) || parseInt(id) <= 0); - if (invalidIds.length > 0) { - result.errors.push(`Invalid content IDs in field ${fieldKey}: ${invalidIds.join(', ')}`); - result.isValid = false; - } + return result; + } + + /** + * Validate string fields + */ + private validateStringField( + fieldKey: string, + fieldValue: string, + options: ContentValidationOptions + ): FieldValidationResult { + const result: FieldValidationResult = { + isValid: true, + field: fieldValue, + warnings: [], + errors: [], + }; + + // Validate asset URLs + if (fieldValue.includes("cdn.aglty.io")) { + if (!this.isValidAssetUrl(fieldValue)) { + result.errors.push(`Invalid asset URL format in field ${fieldKey}: ${fieldValue}`); + result.isValid = false; + } else if (options.sourceAssets) { + // Check if asset exists in source data + const assetExists = options.sourceAssets.some( + (asset) => asset.originUrl === fieldValue || asset.url === fieldValue || asset.edgeUrl === fieldValue + ); + if (!assetExists) { + result.warnings.push(`Asset URL not found in source data for field ${fieldKey}: ${fieldValue}`); } + } + } - // Validate against maximum field length - if (fieldValue.length > 10000) { // Agility CMS typical max field length - result.warnings.push(`Field ${fieldKey} exceeds recommended length (${fieldValue.length} chars)`); - } + // Validate content ID strings (CategoryID, ValueField patterns) + if (this.isContentIdField(fieldKey, fieldValue)) { + const contentIds = fieldValue.includes(",") ? fieldValue.split(",").map((id) => id.trim()) : [fieldValue.trim()]; - return result; + const invalidIds = contentIds.filter((id) => isNaN(parseInt(id)) || parseInt(id) <= 0); + if (invalidIds.length > 0) { + result.errors.push(`Invalid content IDs in field ${fieldKey}: ${invalidIds.join(", ")}`); + result.isValid = false; + } } - /** - * Validate numeric fields - */ - private validateNumericField(fieldKey: string, fieldValue: number, options: ContentValidationOptions): FieldValidationResult { - const result: FieldValidationResult = { - isValid: true, - field: fieldValue, - warnings: [], - errors: [] - }; - - // Validate range for ID fields - if (fieldKey.toLowerCase().includes('id') || fieldKey.toLowerCase().includes('contentid')) { - if (fieldValue <= 0) { - result.errors.push(`Invalid ID value in field ${fieldKey}: ${fieldValue}`); - result.isValid = false; - } - } - - return result; + // Validate against maximum field length + if (fieldValue.length > 10000) { + // Agility CMS typical max field length + result.warnings.push(`Field ${fieldKey} exceeds recommended length (${fieldValue.length} chars)`); } - /** - * Check if string field contains content ID references - */ - private isContentIdField(fieldKey: string, fieldValue: string): boolean { - const lowercaseKey = fieldKey.toLowerCase(); - return (lowercaseKey.includes('categoryid') || - lowercaseKey.includes('valuefield') || - lowercaseKey.includes('tags') || - lowercaseKey.includes('links')) && - /^\d+(,\d+)*$/.test(fieldValue.trim()); + return result; + } + + /** + * Validate numeric fields + */ + private validateNumericField( + fieldKey: string, + fieldValue: number, + options: ContentValidationOptions + ): FieldValidationResult { + const result: FieldValidationResult = { + isValid: true, + field: fieldValue, + warnings: [], + errors: [], + }; + + // Validate range for ID fields + if (fieldKey.toLowerCase().includes("id") || fieldKey.toLowerCase().includes("contentid")) { + if (fieldValue <= 0) { + result.errors.push(`Invalid ID value in field ${fieldKey}: ${fieldValue}`); + result.isValid = false; + } } - /** - * Validate asset URL format - */ - private isValidAssetUrl(url: string): boolean { - try { - const urlObj = new URL(url); - return urlObj.hostname.includes('cdn.aglty.io') && urlObj.pathname.length > 1; - } catch { - return false; - } + return result; + } + + /** + * Check if string field contains content ID references + */ + private isContentIdField(fieldKey: string, fieldValue: string): boolean { + const lowercaseKey = fieldKey.toLowerCase(); + return ( + (lowercaseKey.includes("categoryid") || + lowercaseKey.includes("valuefield") || + lowercaseKey.includes("tags") || + lowercaseKey.includes("links")) && + /^\d+(,\d+)*$/.test(fieldValue.trim()) + ); + } + + /** + * Validate asset URL format + */ + private isValidAssetUrl(url: string): boolean { + try { + const urlObj = new URL(url); + return urlObj.hostname.includes("cdn.aglty.io") && urlObj.pathname.length > 1; + } catch { + return false; + } + } + + /** + * Sanitize field value to ensure compatibility + */ + public sanitizeField(fieldKey: string, fieldValue: any): any { + if (fieldValue === null || fieldValue === undefined) { + return fieldValue; } - /** - * Sanitize field value to ensure compatibility - */ - public sanitizeField(fieldKey: string, fieldValue: any): any { - if (fieldValue === null || fieldValue === undefined) { - return fieldValue; - } + // Sanitize string fields + if (typeof fieldValue === "string") { + // Trim whitespace + let sanitized = fieldValue.trim(); - // Sanitize string fields - if (typeof fieldValue === 'string') { - // Trim whitespace - let sanitized = fieldValue.trim(); - - // Remove null characters - sanitized = sanitized.replace(/\0/g, ''); - - // Ensure proper encoding for special characters - try { - sanitized = decodeURIComponent(encodeURIComponent(sanitized)); - } catch { - // If encoding fails, return original - return fieldValue; - } - - return sanitized; - } + // Remove null characters + sanitized = sanitized.replace(/\0/g, ""); - // Sanitize numeric fields - if (typeof fieldValue === 'number') { - // Ensure finite numbers - if (!Number.isFinite(fieldValue)) { - return 0; - } - return fieldValue; - } + // Ensure proper encoding for special characters + try { + sanitized = decodeURIComponent(encodeURIComponent(sanitized)); + } catch { + // If encoding fails, return original + return fieldValue; + } - // Sanitize object fields recursively - if (typeof fieldValue === 'object' && fieldValue !== null) { - if (Array.isArray(fieldValue)) { - return fieldValue.map((item, index) => this.sanitizeField(`${fieldKey}[${index}]`, item)); - } else { - const sanitized: any = {}; - for (const [key, value] of Object.entries(fieldValue)) { - sanitized[key] = this.sanitizeField(`${fieldKey}.${key}`, value); - } - return sanitized; - } - } + return sanitized; + } - return fieldValue; + // Sanitize numeric fields + if (typeof fieldValue === "number") { + // Ensure finite numbers + if (!Number.isFinite(fieldValue)) { + return 0; + } + return fieldValue; } - /** - * Get validation summary for reporting - */ - public getValidationSummary(fieldResults: Map): { - totalFields: number; - validFields: number; - fieldsWithWarnings: number; - fieldsWithErrors: number; - criticalFields: string[]; - } { - const summary = { - totalFields: fieldResults.size, - validFields: 0, - fieldsWithWarnings: 0, - fieldsWithErrors: 0, - criticalFields: [] as string[] - }; - - fieldResults.forEach((result, fieldKey) => { - if (result.isValid) { - summary.validFields++; - } - if (result.warnings.length > 0) { - summary.fieldsWithWarnings++; - } - if (result.errors.length > 0) { - summary.fieldsWithErrors++; - summary.criticalFields.push(fieldKey); - } - }); - - return summary; + + // Sanitize object fields recursively + if (typeof fieldValue === "object" && fieldValue !== null) { + if (Array.isArray(fieldValue)) { + return fieldValue.map((item, index) => this.sanitizeField(`${fieldKey}[${index}]`, item)); + } else { + const sanitized: any = {}; + for (const [key, value] of Object.entries(fieldValue)) { + sanitized[key] = this.sanitizeField(`${fieldKey}.${key}`, value); + } + return sanitized; + } } + + return fieldValue; + } + /** + * Get validation summary for reporting + */ + public getValidationSummary(fieldResults: Map): { + totalFields: number; + validFields: number; + fieldsWithWarnings: number; + fieldsWithErrors: number; + criticalFields: string[]; + } { + const summary = { + totalFields: fieldResults.size, + validFields: 0, + fieldsWithWarnings: 0, + fieldsWithErrors: 0, + criticalFields: [] as string[], + }; + + fieldResults.forEach((result, fieldKey) => { + if (result.isValid) { + summary.validFields++; + } + if (result.warnings.length > 0) { + summary.fieldsWithWarnings++; + } + if (result.errors.length > 0) { + summary.fieldsWithErrors++; + summary.criticalFields.push(fieldKey); + } + }); + + return summary; + } } /** * Factory function for easy usage */ export function createContentFieldValidator(): ContentFieldValidator { - return new ContentFieldValidator(); + return new ContentFieldValidator(); } /** * Quick validation function for single fields */ -export function validateField(fieldKey: string, fieldValue: any, options: ContentValidationOptions = {}): FieldValidationResult { - const validator = new ContentFieldValidator(); - return validator['validateSingleField'](fieldKey, fieldValue, options); -} +export function validateField( + fieldKey: string, + fieldValue: any, + options: ContentValidationOptions = {} +): FieldValidationResult { + const validator = new ContentFieldValidator(); + return validator["validateSingleField"](fieldKey, fieldValue, options); +} diff --git a/src/lib/content/index.ts b/src/lib/content/index.ts index fc96d83..bcd93f8 100644 --- a/src/lib/content/index.ts +++ b/src/lib/content/index.ts @@ -1,3 +1,3 @@ -export * from './content-classifier'; -export * from './content-field-mapper'; -export * from './content-field-validation'; +export * from "./content-classifier"; +export * from "./content-field-mapper"; +export * from "./content-field-validation"; diff --git a/src/lib/content/tests/content-classifier.test.ts b/src/lib/content/tests/content-classifier.test.ts index df3b587..32d4aba 100644 --- a/src/lib/content/tests/content-classifier.test.ts +++ b/src/lib/content/tests/content-classifier.test.ts @@ -1,11 +1,11 @@ -import { resetState } from 'core/state'; -import { ContentClassifier } from 'lib/content/content-classifier'; +import { resetState } from "core/state"; +import { ContentClassifier } from "lib/content/content-classifier"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -28,105 +28,105 @@ function makeContentItem(definitionName: string, fields: any = {}): any { // ─── classifyContent ────────────────────────────────────────────────────────── -describe('ContentClassifier.classifyContent', () => { +describe("ContentClassifier.classifyContent", () => { let classifier: ContentClassifier; beforeEach(() => { classifier = new ContentClassifier(); }); - describe('empty / edge-case inputs', () => { - it('returns empty arrays when no content items are provided', async () => { + describe("empty / edge-case inputs", () => { + it("returns empty arrays when no content items are provided", async () => { const result = await classifier.classifyContent([], []); expect(result.normalContentItems).toHaveLength(0); expect(result.linkedContentItems).toHaveLength(0); expect(result.classificationDetails.totalItems).toBe(0); }); - it('treats an item with no definitionName as normal content', async () => { + it("treats an item with no definitionName as normal content", async () => { const item: any = { contentID: 1, properties: {}, fields: {} }; const result = await classifier.classifyContent([item], []); expect(result.normalContentItems).toHaveLength(1); expect(result.linkedContentItems).toHaveLength(0); }); - it('treats an item whose model is not found in the models list as normal content', async () => { - const item = makeContentItem('UnknownModel', {}); + it("treats an item whose model is not found in the models list as normal content", async () => { + const item = makeContentItem("UnknownModel", {}); const result = await classifier.classifyContent([item], []); expect(result.normalContentItems).toHaveLength(1); expect(result.linkedContentItems).toHaveLength(0); }); }); - describe('normal content (no linked references)', () => { - it('classifies an item with no Content-type fields as normal', async () => { - const model = makeModel('BlogPost', [ - { name: 'Title', type: 'Text' }, - { name: 'Body', type: 'HTML' }, + describe("normal content (no linked references)", () => { + it("classifies an item with no Content-type fields as normal", async () => { + const model = makeModel("BlogPost", [ + { name: "Title", type: "Text" }, + { name: "Body", type: "HTML" }, ]); - const item = makeContentItem('BlogPost', { title: 'Hello', body: '

World

' }); + const item = makeContentItem("BlogPost", { title: "Hello", body: "

World

" }); const result = await classifier.classifyContent([item], [model]); expect(result.normalContentItems).toHaveLength(1); expect(result.linkedContentItems).toHaveLength(0); }); - it('classifies an item with a Content field but no actual linked-content values as normal', async () => { - const model = makeModel('Article', [ - { name: 'Author', type: 'Content' }, - { name: 'Title', type: 'Text' }, + it("classifies an item with a Content field but no actual linked-content values as normal", async () => { + const model = makeModel("Article", [ + { name: "Author", type: "Content" }, + { name: "Title", type: "Text" }, ]); - const item = makeContentItem('Article', { author: null, title: 'My Article' }); + const item = makeContentItem("Article", { author: null, title: "My Article" }); const result = await classifier.classifyContent([item], [model]); expect(result.normalContentItems).toHaveLength(1); expect(result.linkedContentItems).toHaveLength(0); }); - it('classifies an item whose fields object is absent as normal', async () => { - const model = makeModel('Simple', [{ name: 'Name', type: 'Content' }]); - const item: any = { contentID: 5, properties: { definitionName: 'Simple' } }; + it("classifies an item whose fields object is absent as normal", async () => { + const model = makeModel("Simple", [{ name: "Name", type: "Content" }]); + const item: any = { contentID: 5, properties: { definitionName: "Simple" } }; const result = await classifier.classifyContent([item], [model]); expect(result.normalContentItems).toHaveLength(1); expect(result.linkedContentItems).toHaveLength(0); }); }); - describe('linked content (has linked references)', () => { - it('classifies an item with a contentid pattern in a Content field as linked', async () => { - const model = makeModel('Page', [{ name: 'RelatedPost', type: 'Content' }]); - const item = makeContentItem('Page', { relatedPost: { contentid: 42 } }); + describe("linked content (has linked references)", () => { + it("classifies an item with a contentid pattern in a Content field as linked", async () => { + const model = makeModel("Page", [{ name: "RelatedPost", type: "Content" }]); + const item = makeContentItem("Page", { relatedPost: { contentid: 42 } }); const result = await classifier.classifyContent([item], [model]); expect(result.linkedContentItems).toHaveLength(1); expect(result.normalContentItems).toHaveLength(0); }); - it('classifies an item with a contentID pattern in a Content field as linked', async () => { - const model = makeModel('Page', [{ name: 'RelatedPost', type: 'Content' }]); - const item = makeContentItem('Page', { relatedPost: { contentID: 99 } }); + it("classifies an item with a contentID pattern in a Content field as linked", async () => { + const model = makeModel("Page", [{ name: "RelatedPost", type: "Content" }]); + const item = makeContentItem("Page", { relatedPost: { contentID: 99 } }); const result = await classifier.classifyContent([item], [model]); expect(result.linkedContentItems).toHaveLength(1); }); - it('classifies an item with a sortids pattern in a Content field as linked', async () => { - const model = makeModel('Category', [{ name: 'Items', type: 'Content' }]); - const item = makeContentItem('Category', { items: { sortids: '1,2,3' } }); + it("classifies an item with a sortids pattern in a Content field as linked", async () => { + const model = makeModel("Category", [{ name: "Items", type: "Content" }]); + const item = makeContentItem("Category", { items: { sortids: "1,2,3" } }); const result = await classifier.classifyContent([item], [model]); expect(result.linkedContentItems).toHaveLength(1); }); - it('classifies an item with a referencename pattern in a Content field as linked', async () => { - const model = makeModel('Product', [{ name: 'Tags', type: 'Content' }]); - const item = makeContentItem('Product', { tags: { referencename: 'tag-list' } }); + it("classifies an item with a referencename pattern in a Content field as linked", async () => { + const model = makeModel("Product", [{ name: "Tags", type: "Content" }]); + const item = makeContentItem("Product", { tags: { referencename: "tag-list" } }); const result = await classifier.classifyContent([item], [model]); expect(result.linkedContentItems).toHaveLength(1); }); - it('detects direct nested contentid references in a model that has a Content-type field', async () => { + it("detects direct nested contentid references in a model that has a Content-type field", async () => { // The direct-scan path is only reached when the model has at least one Content field - const model = makeModel('Widget', [ - { name: 'Data', type: 'Text' }, - { name: 'Placeholder', type: 'Content' }, + const model = makeModel("Widget", [ + { name: "Data", type: "Text" }, + { name: "Placeholder", type: "Content" }, ]); - const item = makeContentItem('Widget', { + const item = makeContentItem("Widget", { placeholder: null, data: { nested: { contentid: 7 } }, }); @@ -134,12 +134,10 @@ describe('ContentClassifier.classifyContent', () => { expect(result.linkedContentItems).toHaveLength(1); }); - it('detects direct nested contentID (numeric) references in a model with a Content-type field', async () => { + it("detects direct nested contentID (numeric) references in a model with a Content-type field", async () => { // The direct-scan path is only reached when the model has at least one Content field - const model = makeModel('Widget', [ - { name: 'Placeholder', type: 'Content' }, - ]); - const item = makeContentItem('Widget', { + const model = makeModel("Widget", [{ name: "Placeholder", type: "Content" }]); + const item = makeContentItem("Widget", { placeholder: null, extra: { contentID: 15 }, }); @@ -147,34 +145,34 @@ describe('ContentClassifier.classifyContent', () => { expect(result.linkedContentItems).toHaveLength(1); }); - it('does not flag a string contentID value as a linked reference', async () => { - const model = makeModel('Widget', [{ name: 'Data', type: 'Text' }]); - const item = makeContentItem('Widget', { - data: { contentID: 'not-a-number' }, + it("does not flag a string contentID value as a linked reference", async () => { + const model = makeModel("Widget", [{ name: "Data", type: "Text" }]); + const item = makeContentItem("Widget", { + data: { contentID: "not-a-number" }, }); const result = await classifier.classifyContent([item], [model]); expect(result.normalContentItems).toHaveLength(1); }); }); - describe('mixed batches', () => { - it('correctly partitions a mixed batch of normal and linked items', async () => { - const model = makeModel('Post', [{ name: 'Related', type: 'Content' }]); - const normalItem = makeContentItem('Post', { related: null, title: 'plain' }); - const linkedItem = makeContentItem('Post', { related: { contentid: 5 } }); + describe("mixed batches", () => { + it("correctly partitions a mixed batch of normal and linked items", async () => { + const model = makeModel("Post", [{ name: "Related", type: "Content" }]); + const normalItem = makeContentItem("Post", { related: null, title: "plain" }); + const linkedItem = makeContentItem("Post", { related: { contentid: 5 } }); const result = await classifier.classifyContent([normalItem, linkedItem], [model]); expect(result.normalContentItems).toHaveLength(1); expect(result.linkedContentItems).toHaveLength(1); }); }); - describe('classificationDetails accuracy', () => { - it('reports correct counts in classificationDetails', async () => { - const model = makeModel('Post', [{ name: 'Link', type: 'Content' }]); + describe("classificationDetails accuracy", () => { + it("reports correct counts in classificationDetails", async () => { + const model = makeModel("Post", [{ name: "Link", type: "Content" }]); const items = [ - makeContentItem('Post', { link: { contentid: 1 } }), - makeContentItem('Post', { link: null }), - makeContentItem('Post', { link: null }), + makeContentItem("Post", { link: { contentid: 1 } }), + makeContentItem("Post", { link: null }), + makeContentItem("Post", { link: null }), ]; const result = await classifier.classifyContent(items, [model]); expect(result.classificationDetails.totalItems).toBe(3); @@ -182,18 +180,16 @@ describe('ContentClassifier.classifyContent', () => { expect(result.classificationDetails.normalCount).toBe(2); }); - it('records a non-negative analysisTime', async () => { + it("records a non-negative analysisTime", async () => { const result = await classifier.classifyContent([], []); expect(result.classificationDetails.analysisTime).toBeGreaterThanOrEqual(0); }); }); - describe('model field caching', () => { - it('produces consistent results when the same model is used for multiple items', async () => { - const model = makeModel('Cached', [{ name: 'Ref', type: 'Content' }]); - const items = Array.from({ length: 3 }, (_, i) => - makeContentItem('Cached', { ref: { contentid: i + 1 } }) - ); + describe("model field caching", () => { + it("produces consistent results when the same model is used for multiple items", async () => { + const model = makeModel("Cached", [{ name: "Ref", type: "Content" }]); + const items = Array.from({ length: 3 }, (_, i) => makeContentItem("Cached", { ref: { contentid: i + 1 } })); const result = await classifier.classifyContent(items, [model]); expect(result.linkedContentItems).toHaveLength(3); }); @@ -202,43 +198,40 @@ describe('ContentClassifier.classifyContent', () => { // ─── clearCache ─────────────────────────────────────────────────────────────── -describe('ContentClassifier.clearCache', () => { - it('does not throw when the cache is empty', () => { +describe("ContentClassifier.clearCache", () => { + it("does not throw when the cache is empty", () => { const classifier = new ContentClassifier(); expect(() => classifier.clearCache()).not.toThrow(); }); - it('does not throw after classifying content (cache is populated)', async () => { + it("does not throw after classifying content (cache is populated)", async () => { const classifier = new ContentClassifier(); - const model = makeModel('M', [{ name: 'F', type: 'Text' }]); - await classifier.classifyContent([makeContentItem('M', {})], [model]); + const model = makeModel("M", [{ name: "F", type: "Text" }]); + await classifier.classifyContent([makeContentItem("M", {})], [model]); expect(() => classifier.clearCache()).not.toThrow(); }); }); // ─── getClassificationStats ─────────────────────────────────────────────────── -describe('ContentClassifier.getClassificationStats', () => { +describe("ContentClassifier.getClassificationStats", () => { let classifier: ContentClassifier; beforeEach(() => { classifier = new ContentClassifier(); }); - it('returns a string containing the normal and linked counts', async () => { - const model = makeModel('P', [{ name: 'Link', type: 'Content' }]); - const items = [ - makeContentItem('P', { link: { contentid: 1 } }), - makeContentItem('P', { link: null }), - ]; + it("returns a string containing the normal and linked counts", async () => { + const model = makeModel("P", [{ name: "Link", type: "Content" }]); + const items = [makeContentItem("P", { link: { contentid: 1 } }), makeContentItem("P", { link: null })]; const classification = await classifier.classifyContent(items, [model]); const stats = classifier.getClassificationStats(classification); - expect(stats).toContain('1 normal'); - expect(stats).toContain('1 linked'); - expect(stats).toContain('2 total'); + expect(stats).toContain("1 normal"); + expect(stats).toContain("1 linked"); + expect(stats).toContain("2 total"); }); - it('includes percentage values in the stats string', async () => { + it("includes percentage values in the stats string", async () => { const classification = { normalContentItems: [], linkedContentItems: [], @@ -250,7 +243,7 @@ describe('ContentClassifier.getClassificationStats', () => { }, }; const stats = classifier.getClassificationStats(classification); - expect(stats).toContain('75%'); - expect(stats).toContain('25%'); + expect(stats).toContain("75%"); + expect(stats).toContain("25%"); }); }); diff --git a/src/lib/content/tests/content-field-mapper.test.ts b/src/lib/content/tests/content-field-mapper.test.ts index 3ae3614..4851013 100644 --- a/src/lib/content/tests/content-field-mapper.test.ts +++ b/src/lib/content/tests/content-field-mapper.test.ts @@ -1,14 +1,14 @@ -import { resetState } from 'core/state'; -import { ContentFieldMapper, createContentFieldMapper } from 'lib/content/content-field-mapper'; +import { resetState } from "core/state"; +import { ContentFieldMapper, createContentFieldMapper } from "lib/content/content-field-mapper"; // Prevent ContentItemMapper and AssetMapper constructors from touching the filesystem -jest.mock('lib/mappers/content-item-mapper', () => ({ +jest.mock("lib/mappers/content-item-mapper", () => ({ ContentItemMapper: jest.fn().mockImplementation(() => ({ getContentItemMappingByContentID: jest.fn().mockReturnValue(null), })), })); -jest.mock('lib/mappers/asset-mapper', () => ({ +jest.mock("lib/mappers/asset-mapper", () => ({ AssetMapper: jest.fn().mockImplementation(() => ({ getAssetMappingByMediaUrl: jest.fn().mockReturnValue(null), remapUrlByContainer: jest.fn().mockReturnValue(null), @@ -16,15 +16,15 @@ jest.mock('lib/mappers/asset-mapper', () => ({ })); // Prevent AssetReferenceExtractor from causing side-effects -jest.mock('lib/assets/asset-reference-extractor', () => ({ +jest.mock("lib/assets/asset-reference-extractor", () => ({ AssetReferenceExtractor: jest.fn().mockImplementation(() => ({})), })); beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -54,28 +54,28 @@ function makeAssetMapper(overrides: any = {}): any { // ─── createContentFieldMapper ───────────────────────────────────────────────── -describe('createContentFieldMapper', () => { - it('returns a ContentFieldMapper instance', () => { +describe("createContentFieldMapper", () => { + it("returns a ContentFieldMapper instance", () => { expect(createContentFieldMapper()).toBeInstanceOf(ContentFieldMapper); }); }); // ─── mapContentFields — null / non-object inputs ────────────────────────────── -describe('ContentFieldMapper.mapContentFields', () => { +describe("ContentFieldMapper.mapContentFields", () => { let mapper: ContentFieldMapper; beforeEach(() => { mapper = makeMapper(); }); - describe('null / non-object inputs', () => { + describe("null / non-object inputs", () => { it.each([ - ['null', null], - ['undefined', undefined], - ['a number', 42], - ['a string', 'hello'], - ])('returns the input unchanged for %s with zero warnings/errors', (_label, input) => { + ["null", null], + ["undefined", undefined], + ["a number", 42], + ["a string", "hello"], + ])("returns the input unchanged for %s with zero warnings/errors", (_label, input) => { const result = mapper.mapContentFields(input as any); expect(result.mappedFields).toBe(input); expect(result.validationWarnings).toBe(0); @@ -83,107 +83,104 @@ describe('ContentFieldMapper.mapContentFields', () => { }); }); - describe('primitive field pass-through', () => { - it('passes through string, number and boolean fields unchanged when no context is given', () => { - const fields = { title: 'Hello', count: 5, active: true }; + describe("primitive field pass-through", () => { + it("passes through string, number and boolean fields unchanged when no context is given", () => { + const fields = { title: "Hello", count: 5, active: true }; const result = mapper.mapContentFields(fields); expect(result.mappedFields).toEqual(fields); expect(result.validationErrors).toBe(0); }); }); - describe('list reference fields', () => { - it('passes through a referencename+fulllist field unchanged', () => { + describe("list reference fields", () => { + it("passes through a referencename+fulllist field unchanged", () => { const fields = { - items: { referencename: 'my-list', fulllist: true }, + items: { referencename: "my-list", fulllist: true }, }; const result = mapper.mapContentFields(fields); expect(result.mappedFields.items).toEqual(fields.items); expect(result.validationErrors).toBe(0); }); - it('passes through a referenceName+fullList (camelCase) field unchanged', () => { + it("passes through a referenceName+fullList (camelCase) field unchanged", () => { const fields = { - items: { referenceName: 'other-list', fullList: true }, + items: { referenceName: "other-list", fullList: true }, }; const result = mapper.mapContentFields(fields); expect(result.mappedFields.items).toEqual(fields.items); }); }); - describe('asset attachment fields — no context', () => { - it('returns a warning when a single asset object has no referenceMapper context', () => { - const fields = { image: { url: 'https://cdn.aglty.io/guid/assets/photo.jpg', label: 'Hero' } }; + describe("asset attachment fields — no context", () => { + it("returns a warning when a single asset object has no referenceMapper context", () => { + const fields = { image: { url: "https://cdn.aglty.io/guid/assets/photo.jpg", label: "Hero" } }; const result = mapper.mapContentFields(fields); expect(result.validationWarnings).toBeGreaterThan(0); }); - it('returns a warning for an AttachmentList array when no referenceMapper context is provided', () => { + it("returns a warning for an AttachmentList array when no referenceMapper context is provided", () => { const fields = { - gallery: [ - { url: 'https://cdn.aglty.io/guid/assets/a.jpg' }, - { url: 'https://cdn.aglty.io/guid/assets/b.jpg' }, - ], + gallery: [{ url: "https://cdn.aglty.io/guid/assets/a.jpg" }, { url: "https://cdn.aglty.io/guid/assets/b.jpg" }], }; const result = mapper.mapContentFields(fields); expect(result.validationWarnings).toBeGreaterThan(0); }); }); - describe('asset attachment fields — with context', () => { - it('maps the URL using assetMapper when an exact URL match is found', () => { + describe("asset attachment fields — with context", () => { + it("maps the URL using assetMapper when an exact URL match is found", () => { const assetMapper = makeAssetMapper({ getAssetMappingByMediaUrl: jest.fn().mockReturnValue({ - sourceUrl: 'https://cdn.aglty.io/src/assets/photo.jpg', - targetUrl: 'https://cdn.aglty.io/tgt/assets/photo.jpg', + sourceUrl: "https://cdn.aglty.io/src/assets/photo.jpg", + targetUrl: "https://cdn.aglty.io/tgt/assets/photo.jpg", }), }); const context = { referenceMapper: makeReferenceMapper(), assetMapper, }; - const fields = { image: { url: 'https://cdn.aglty.io/src/assets/photo.jpg', label: 'Hero' } }; + const fields = { image: { url: "https://cdn.aglty.io/src/assets/photo.jpg", label: "Hero" } }; const result = mapper.mapContentFields(fields, context); - expect(result.mappedFields.image.url).toBe('https://cdn.aglty.io/tgt/assets/photo.jpg'); + expect(result.mappedFields.image.url).toBe("https://cdn.aglty.io/tgt/assets/photo.jpg"); expect(result.validationErrors).toBe(0); }); - it('leaves the URL unchanged when assetMapper returns null', () => { + it("leaves the URL unchanged when assetMapper returns null", () => { const context = { referenceMapper: makeReferenceMapper(), assetMapper: makeAssetMapper(), }; - const fields = { image: { url: 'https://cdn.aglty.io/guid/assets/photo.jpg' } }; + const fields = { image: { url: "https://cdn.aglty.io/guid/assets/photo.jpg" } }; const result = mapper.mapContentFields(fields, context); - expect(result.mappedFields.image.url).toBe('https://cdn.aglty.io/guid/assets/photo.jpg'); + expect(result.mappedFields.image.url).toBe("https://cdn.aglty.io/guid/assets/photo.jpg"); }); - it('maps URLs in an array of asset objects', () => { + it("maps URLs in an array of asset objects", () => { const assetMapper = makeAssetMapper({ getAssetMappingByMediaUrl: jest.fn().mockImplementation((url: string) => { - if (url === 'https://cdn.aglty.io/src/assets/a.jpg') { - return { sourceUrl: url, targetUrl: 'https://cdn.aglty.io/tgt/assets/a.jpg' }; + if (url === "https://cdn.aglty.io/src/assets/a.jpg") { + return { sourceUrl: url, targetUrl: "https://cdn.aglty.io/tgt/assets/a.jpg" }; } return null; }), }); const context = { referenceMapper: makeReferenceMapper(), assetMapper }; const fields = { - gallery: [{ url: 'https://cdn.aglty.io/src/assets/a.jpg' }], + gallery: [{ url: "https://cdn.aglty.io/src/assets/a.jpg" }], }; const result = mapper.mapContentFields(fields, context); - expect(result.mappedFields.gallery[0].url).toBe('https://cdn.aglty.io/tgt/assets/a.jpg'); + expect(result.mappedFields.gallery[0].url).toBe("https://cdn.aglty.io/tgt/assets/a.jpg"); }); }); - describe('content reference fields', () => { - it('adds a warning when a contentid field has no referenceMapper context', () => { + describe("content reference fields", () => { + it("adds a warning when a contentid field has no referenceMapper context", () => { const fields = { related: { contentid: 10 } }; const result = mapper.mapContentFields(fields); expect(result.validationWarnings).toBeGreaterThan(0); }); - it('maps contentid when referenceMapper finds the source content', () => { + it("maps contentid when referenceMapper finds the source content", () => { const referenceMapper = makeReferenceMapper({ getContentItemMappingByContentID: jest.fn().mockReturnValue({ contentID: 99 }), }); @@ -194,7 +191,7 @@ describe('ContentFieldMapper.mapContentFields', () => { expect(result.validationErrors).toBe(0); }); - it('maps contentID (capital D) when referenceMapper finds the source content', () => { + it("maps contentID (capital D) when referenceMapper finds the source content", () => { const referenceMapper = makeReferenceMapper({ getContentItemMappingByContentID: jest.fn().mockReturnValue({ contentID: 77 }), }); @@ -204,7 +201,7 @@ describe('ContentFieldMapper.mapContentFields', () => { expect(result.mappedFields.link.contentID).toBe(77); }); - it('increments warnings when contentid mapping is not found', () => { + it("increments warnings when contentid mapping is not found", () => { const context = { referenceMapper: makeReferenceMapper(), assetMapper: makeAssetMapper(), @@ -214,7 +211,7 @@ describe('ContentFieldMapper.mapContentFields', () => { expect(result.validationWarnings).toBeGreaterThan(0); }); - it('maps sortids by replacing each source ID with its target ID', () => { + it("maps sortids by replacing each source ID with its target ID", () => { const referenceMapper = makeReferenceMapper({ getContentItemMappingByContentID: jest.fn().mockImplementation((id: number) => { const map: Record = { 1: 101, 2: 102, 3: 103 }; @@ -222,78 +219,78 @@ describe('ContentFieldMapper.mapContentFields', () => { }), }); const context = { referenceMapper, assetMapper: makeAssetMapper() }; - const fields = { list: { sortids: '1,2,3' } }; + const fields = { list: { sortids: "1,2,3" } }; const result = mapper.mapContentFields(fields, context); - expect(result.mappedFields.list.sortids).toBe('101,102,103'); + expect(result.mappedFields.list.sortids).toBe("101,102,103"); }); - it('keeps original ID when sortid mapping is not found', () => { + it("keeps original ID when sortid mapping is not found", () => { const context = { referenceMapper: makeReferenceMapper(), assetMapper: makeAssetMapper(), }; - const fields = { list: { sortids: '5,6' } }; + const fields = { list: { sortids: "5,6" } }; const result = mapper.mapContentFields(fields, context); - expect(result.mappedFields.list.sortids).toBe('5,6'); + expect(result.mappedFields.list.sortids).toBe("5,6"); }); }); - describe('cdn URL string fields', () => { - it('increments validationErrors for a cdn.aglty.io string field when no context is given (mapAssetUrl throws)', () => { + describe("cdn URL string fields", () => { + it("increments validationErrors for a cdn.aglty.io string field when no context is given (mapAssetUrl throws)", () => { // mapAssetUrl unconditionally accesses context.assetMapper, so passing no context throws, // which the outer catch in mapContentFields turns into an error increment. - const fields = { heroUrl: 'https://cdn.aglty.io/guid/assets/img.jpg' }; + const fields = { heroUrl: "https://cdn.aglty.io/guid/assets/img.jpg" }; const result = mapper.mapContentFields(fields); expect(result.validationErrors).toBeGreaterThan(0); }); - it('maps a top-level cdn.aglty.io string field using assetMapper container remapping', () => { + it("maps a top-level cdn.aglty.io string field using assetMapper container remapping", () => { const assetMapper = makeAssetMapper({ - getAssetMappingByMediaUrl: jest.fn().mockReturnValue({ sourceUrl: 'mismatch', targetUrl: 'x' }), - remapUrlByContainer: jest.fn().mockReturnValue('https://cdn.aglty.io/tgt/assets/img.jpg'), + getAssetMappingByMediaUrl: jest.fn().mockReturnValue({ sourceUrl: "mismatch", targetUrl: "x" }), + remapUrlByContainer: jest.fn().mockReturnValue("https://cdn.aglty.io/tgt/assets/img.jpg"), }); const context = { referenceMapper: makeReferenceMapper(), assetMapper }; - const fields = { heroUrl: 'https://cdn.aglty.io/src/assets/img.jpg' }; + const fields = { heroUrl: "https://cdn.aglty.io/src/assets/img.jpg" }; const result = mapper.mapContentFields(fields, context); - expect(result.mappedFields.heroUrl).toBe('https://cdn.aglty.io/tgt/assets/img.jpg'); + expect(result.mappedFields.heroUrl).toBe("https://cdn.aglty.io/tgt/assets/img.jpg"); }); }); - describe('nested object fields', () => { - it('recursively processes nested plain objects', () => { + describe("nested object fields", () => { + it("recursively processes nested plain objects", () => { const fields = { section: { - title: 'Section Title', + title: "Section Title", count: 3, }, }; const result = mapper.mapContentFields(fields); - expect(result.mappedFields.section.title).toBe('Section Title'); + expect(result.mappedFields.section.title).toBe("Section Title"); expect(result.mappedFields.section.count).toBe(3); expect(result.validationErrors).toBe(0); }); - it('recursively processes nested arrays of primitives', () => { - const fields = { tags: ['a', 'b', 'c'] }; + it("recursively processes nested arrays of primitives", () => { + const fields = { tags: ["a", "b", "c"] }; const result = mapper.mapContentFields(fields); - expect(result.mappedFields.tags).toEqual(['a', 'b', 'c']); + expect(result.mappedFields.tags).toEqual(["a", "b", "c"]); }); }); - describe('error handling', () => { - it('increments validationErrors and keeps original value when a field mapping throws', () => { + describe("error handling", () => { + it("increments validationErrors and keeps original value when a field mapping throws", () => { const badMapper = makeMapper(); // Make the internal mapSingleField throw by feeding a context whose assetMapper throws const context = { referenceMapper: makeReferenceMapper(), assetMapper: { getAssetMappingByMediaUrl: jest.fn().mockImplementation(() => { - throw new Error('boom'); + throw new Error("boom"); }), remapUrlByContainer: jest.fn(), } as any, }; - const fields = { heroUrl: 'https://cdn.aglty.io/guid/assets/img.jpg' }; + const fields = { heroUrl: "https://cdn.aglty.io/guid/assets/img.jpg" }; const result = badMapper.mapContentFields(fields, context); expect(result.validationErrors).toBeGreaterThan(0); expect(result.mappedFields.heroUrl).toBe(fields.heroUrl); diff --git a/src/lib/content/tests/content-field-validation.test.ts b/src/lib/content/tests/content-field-validation.test.ts index 26075b9..0253774 100644 --- a/src/lib/content/tests/content-field-validation.test.ts +++ b/src/lib/content/tests/content-field-validation.test.ts @@ -1,16 +1,16 @@ -import { resetState } from 'core/state'; +import { resetState } from "core/state"; import { ContentFieldValidator, createContentFieldValidator, validateField, FieldValidationResult, -} from 'lib/content/content-field-validation'; +} from "lib/content/content-field-validation"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -19,21 +19,21 @@ afterEach(() => { // ─── createContentFieldValidator / validateField factories ──────────────────── -describe('createContentFieldValidator', () => { - it('returns a ContentFieldValidator instance', () => { +describe("createContentFieldValidator", () => { + it("returns a ContentFieldValidator instance", () => { expect(createContentFieldValidator()).toBeInstanceOf(ContentFieldValidator); }); }); -describe('validateField (standalone helper)', () => { - it('returns isValid:true for a plain string', () => { - const result = validateField('title', 'Hello'); +describe("validateField (standalone helper)", () => { + it("returns isValid:true for a plain string", () => { + const result = validateField("title", "Hello"); expect(result.isValid).toBe(true); expect(result.errors).toHaveLength(0); }); - it('returns isValid:false for a non-positive contentid', () => { - const result = validateField('ref', { contentid: -1 }); + it("returns isValid:false for a non-positive contentid", () => { + const result = validateField("ref", { contentid: -1 }); expect(result.isValid).toBe(false); expect(result.errors.length).toBeGreaterThan(0); }); @@ -41,20 +41,20 @@ describe('validateField (standalone helper)', () => { // ─── validateContentFields ──────────────────────────────────────────────────── -describe('ContentFieldValidator.validateContentFields', () => { +describe("ContentFieldValidator.validateContentFields", () => { let validator: ContentFieldValidator; beforeEach(() => { validator = new ContentFieldValidator(); }); - describe('null / non-object inputs', () => { + describe("null / non-object inputs", () => { it.each([ - ['null', null], - ['undefined', undefined], - ['a number', 42], - ['a string', 'text'], - ])('returns isValid:true with zero counts for %s', (_label, input) => { + ["null", null], + ["undefined", undefined], + ["a number", 42], + ["a string", "text"], + ])("returns isValid:true with zero counts for %s", (_label, input) => { const result = validator.validateContentFields(input as any); expect(result.isValid).toBe(true); expect(result.totalWarnings).toBe(0); @@ -63,215 +63,206 @@ describe('ContentFieldValidator.validateContentFields', () => { }); }); - describe('primitive fields', () => { - it('validates a plain string field as valid', () => { - const result = validator.validateContentFields({ title: 'My Title' }); + describe("primitive fields", () => { + it("validates a plain string field as valid", () => { + const result = validator.validateContentFields({ title: "My Title" }); expect(result.isValid).toBe(true); expect(result.totalErrors).toBe(0); - expect(result.validatedFields.title).toBe('My Title'); + expect(result.validatedFields.title).toBe("My Title"); }); - it('validates a number field without an id-like name as valid', () => { + it("validates a number field without an id-like name as valid", () => { const result = validator.validateContentFields({ count: 5 }); expect(result.isValid).toBe(true); }); - it('validates a boolean field as valid', () => { + it("validates a boolean field as valid", () => { const result = validator.validateContentFields({ active: true }); expect(result.isValid).toBe(true); expect(result.totalErrors).toBe(0); }); - it('validates null and undefined field values as valid', () => { + it("validates null and undefined field values as valid", () => { const result = validator.validateContentFields({ a: null, b: undefined }); expect(result.isValid).toBe(true); }); }); - describe('numeric id fields', () => { - it('returns an error for a non-positive ID field', () => { + describe("numeric id fields", () => { + it("returns an error for a non-positive ID field", () => { const result = validator.validateContentFields({ categoryId: -1 }); expect(result.isValid).toBe(false); expect(result.totalErrors).toBeGreaterThan(0); }); - it('returns an error for a zero ID field', () => { + it("returns an error for a zero ID field", () => { const result = validator.validateContentFields({ contentid: 0 }); expect(result.isValid).toBe(false); }); - it('passes a positive numeric id field', () => { + it("passes a positive numeric id field", () => { const result = validator.validateContentFields({ categoryId: 10 }); expect(result.isValid).toBe(true); expect(result.totalErrors).toBe(0); }); }); - describe('asset URL string fields', () => { - it('validates a well-formed cdn.aglty.io URL', () => { + describe("asset URL string fields", () => { + it("validates a well-formed cdn.aglty.io URL", () => { const result = validator.validateContentFields({ - image: 'https://cdn.aglty.io/guid/assets/photo.jpg', + image: "https://cdn.aglty.io/guid/assets/photo.jpg", }); expect(result.totalErrors).toBe(0); }); it('returns an error for a malformed cdn.aglty.io "URL"', () => { const result = validator.validateContentFields({ - image: 'not-a-url-cdn.aglty.io', + image: "not-a-url-cdn.aglty.io", }); expect(result.isValid).toBe(false); expect(result.totalErrors).toBeGreaterThan(0); }); - it('returns a warning when sourceAssets is provided but the URL is not found', () => { + it("returns a warning when sourceAssets is provided but the URL is not found", () => { const result = validator.validateContentFields( - { image: 'https://cdn.aglty.io/guid/assets/missing.jpg' }, + { image: "https://cdn.aglty.io/guid/assets/missing.jpg" }, { sourceAssets: [] } ); expect(result.totalWarnings).toBeGreaterThan(0); }); - it('does not warn when the URL is present in sourceAssets via url property', () => { - const url = 'https://cdn.aglty.io/guid/assets/photo.jpg'; - const result = validator.validateContentFields( - { image: url }, - { sourceAssets: [{ url }] } - ); + it("does not warn when the URL is present in sourceAssets via url property", () => { + const url = "https://cdn.aglty.io/guid/assets/photo.jpg"; + const result = validator.validateContentFields({ image: url }, { sourceAssets: [{ url }] }); expect(result.totalWarnings).toBe(0); }); - it('does not warn when the URL is present in sourceAssets via originUrl', () => { - const url = 'https://cdn.aglty.io/guid/assets/photo.jpg'; - const result = validator.validateContentFields( - { image: url }, - { sourceAssets: [{ originUrl: url }] } - ); + it("does not warn when the URL is present in sourceAssets via originUrl", () => { + const url = "https://cdn.aglty.io/guid/assets/photo.jpg"; + const result = validator.validateContentFields({ image: url }, { sourceAssets: [{ originUrl: url }] }); expect(result.totalWarnings).toBe(0); }); - it('warns for a field that exceeds the recommended length', () => { - const longString = 'a'.repeat(10001); + it("warns for a field that exceeds the recommended length", () => { + const longString = "a".repeat(10001); const result = validator.validateContentFields({ body: longString }); expect(result.totalWarnings).toBeGreaterThan(0); }); }); - describe('content ID string fields', () => { - it('validates a valid comma-separated categoryid string', () => { - const result = validator.validateContentFields({ categoryid: '1,2,3' }); + describe("content ID string fields", () => { + it("validates a valid comma-separated categoryid string", () => { + const result = validator.validateContentFields({ categoryid: "1,2,3" }); expect(result.isValid).toBe(true); expect(result.totalErrors).toBe(0); }); - it('does not error for a categoryid string containing non-numeric parts because isContentIdField guards on the pattern', () => { + it("does not error for a categoryid string containing non-numeric parts because isContentIdField guards on the pattern", () => { // isContentIdField only triggers when the value already matches /^\d+(,\d+)*$/ // so '1,abc,3' is treated as a plain string and passes through without error - const result = validator.validateContentFields({ categoryid: '1,abc,3' }); + const result = validator.validateContentFields({ categoryid: "1,abc,3" }); expect(result.isValid).toBe(true); expect(result.totalErrors).toBe(0); }); - it('returns an error for a zero ID in a categoryid string (zero fails parseInt(id) > 0 check)', () => { - const result = validator.validateContentFields({ categoryid: '0,2' }); + it("returns an error for a zero ID in a categoryid string (zero fails parseInt(id) > 0 check)", () => { + const result = validator.validateContentFields({ categoryid: "0,2" }); expect(result.isValid).toBe(false); expect(result.totalErrors).toBeGreaterThan(0); }); }); - describe('content reference object fields (contentid / contentID)', () => { - it('returns an error for a non-positive contentid in an object field', () => { + describe("content reference object fields (contentid / contentID)", () => { + it("returns an error for a non-positive contentid in an object field", () => { const result = validator.validateContentFields({ ref: { contentid: -5 } }); expect(result.isValid).toBe(false); expect(result.totalErrors).toBeGreaterThan(0); }); - it('returns an error for a non-positive contentID in an object field', () => { + it("returns an error for a non-positive contentID in an object field", () => { const result = validator.validateContentFields({ ref: { contentID: 0 } }); expect(result.isValid).toBe(false); }); - it('passes a positive contentid in an object field', () => { + it("passes a positive contentid in an object field", () => { const result = validator.validateContentFields({ ref: { contentid: 42 } }); expect(result.totalErrors).toBe(0); }); - it('passes a positive contentID in an object field', () => { + it("passes a positive contentID in an object field", () => { const result = validator.validateContentFields({ ref: { contentID: 42 } }); expect(result.totalErrors).toBe(0); }); - it('returns an error for a string contentid in an object field', () => { - const result = validator.validateContentFields({ ref: { contentid: 'bad' } }); + it("returns an error for a string contentid in an object field", () => { + const result = validator.validateContentFields({ ref: { contentid: "bad" } }); expect(result.isValid).toBe(false); }); }); - describe('referencename + sortids pattern', () => { - it('passes valid sortids with a referencename', () => { + describe("referencename + sortids pattern", () => { + it("passes valid sortids with a referencename", () => { const result = validator.validateContentFields({ - items: { referencename: 'my-list', sortids: '1,2,3' }, + items: { referencename: "my-list", sortids: "1,2,3" }, }); expect(result.totalErrors).toBe(0); }); - it('returns an error for invalid sortids', () => { + it("returns an error for invalid sortids", () => { const result = validator.validateContentFields({ - items: { referencename: 'my-list', sortids: '1,abc' }, + items: { referencename: "my-list", sortids: "1,abc" }, }); expect(result.isValid).toBe(false); expect(result.totalErrors).toBeGreaterThan(0); }); - it('returns an error for zero sortids', () => { + it("returns an error for zero sortids", () => { const result = validator.validateContentFields({ - items: { referencename: 'my-list', sortids: '0,2' }, + items: { referencename: "my-list", sortids: "0,2" }, }); expect(result.isValid).toBe(false); }); - it('warns when the container reference is not found in sourceContainers', () => { + it("warns when the container reference is not found in sourceContainers", () => { const result = validator.validateContentFields( - { items: { referencename: 'ghost-list', sortids: '1' } }, - { sourceContainers: [{ referenceName: 'other-list' }] } + { items: { referencename: "ghost-list", sortids: "1" } }, + { sourceContainers: [{ referenceName: "other-list" }] } ); expect(result.totalWarnings).toBeGreaterThan(0); }); - it('does not warn when the container reference IS found in sourceContainers', () => { + it("does not warn when the container reference IS found in sourceContainers", () => { const result = validator.validateContentFields( - { items: { referencename: 'known-list', sortids: '1' } }, - { sourceContainers: [{ referenceName: 'known-list' }] } + { items: { referencename: "known-list", sortids: "1" } }, + { sourceContainers: [{ referenceName: "known-list" }] } ); expect(result.totalWarnings).toBe(0); }); }); - describe('gallery reference fields', () => { - it('returns an error for a non-positive mediaGroupingID', () => { + describe("gallery reference fields", () => { + it("returns an error for a non-positive mediaGroupingID", () => { const result = validator.validateContentFields({ gallery: { mediaGroupingID: -1 } }); expect(result.isValid).toBe(false); expect(result.totalErrors).toBeGreaterThan(0); }); - it('passes a positive mediaGroupingID', () => { + it("passes a positive mediaGroupingID", () => { const result = validator.validateContentFields({ gallery: { mediaGroupingID: 10 } }); expect(result.totalErrors).toBe(0); }); }); - describe('array field validation', () => { - it('validates each item in an array field recursively', () => { + describe("array field validation", () => { + it("validates each item in an array field recursively", () => { const result = validator.validateContentFields({ - items: [ - { contentid: 1 }, - { contentid: -5 }, - ], + items: [{ contentid: 1 }, { contentid: -5 }], }); expect(result.isValid).toBe(false); expect(result.totalErrors).toBeGreaterThan(0); }); - it('passes when all array items are valid', () => { + it("passes when all array items are valid", () => { const result = validator.validateContentFields({ items: [{ contentid: 1 }, { contentid: 2 }], }); @@ -279,77 +270,77 @@ describe('ContentFieldValidator.validateContentFields', () => { }); }); - describe('fieldResults map', () => { - it('contains an entry per validated field', () => { - const result = validator.validateContentFields({ a: 'x', b: 'y' }); + describe("fieldResults map", () => { + it("contains an entry per validated field", () => { + const result = validator.validateContentFields({ a: "x", b: "y" }); expect(result.fieldResults.size).toBe(2); - expect(result.fieldResults.has('a')).toBe(true); - expect(result.fieldResults.has('b')).toBe(true); + expect(result.fieldResults.has("a")).toBe(true); + expect(result.fieldResults.has("b")).toBe(true); }); }); }); // ─── sanitizeField ──────────────────────────────────────────────────────────── -describe('ContentFieldValidator.sanitizeField', () => { +describe("ContentFieldValidator.sanitizeField", () => { let validator: ContentFieldValidator; beforeEach(() => { validator = new ContentFieldValidator(); }); - it('returns null unchanged', () => { - expect(validator.sanitizeField('f', null)).toBeNull(); + it("returns null unchanged", () => { + expect(validator.sanitizeField("f", null)).toBeNull(); }); - it('returns undefined unchanged', () => { - expect(validator.sanitizeField('f', undefined)).toBeUndefined(); + it("returns undefined unchanged", () => { + expect(validator.sanitizeField("f", undefined)).toBeUndefined(); }); - it('trims whitespace from string fields', () => { - expect(validator.sanitizeField('title', ' hello ')).toBe('hello'); + it("trims whitespace from string fields", () => { + expect(validator.sanitizeField("title", " hello ")).toBe("hello"); }); - it('removes null characters from string fields', () => { - expect(validator.sanitizeField('body', 'hello\0world')).toBe('helloworld'); + it("removes null characters from string fields", () => { + expect(validator.sanitizeField("body", "hello\0world")).toBe("helloworld"); }); - it('returns 0 for non-finite numbers', () => { - expect(validator.sanitizeField('val', Infinity)).toBe(0); - expect(validator.sanitizeField('val', NaN)).toBe(0); + it("returns 0 for non-finite numbers", () => { + expect(validator.sanitizeField("val", Infinity)).toBe(0); + expect(validator.sanitizeField("val", NaN)).toBe(0); }); - it('preserves finite numbers unchanged', () => { - expect(validator.sanitizeField('count', 42)).toBe(42); - expect(validator.sanitizeField('price', -3.14)).toBe(-3.14); + it("preserves finite numbers unchanged", () => { + expect(validator.sanitizeField("count", 42)).toBe(42); + expect(validator.sanitizeField("price", -3.14)).toBe(-3.14); }); - it('sanitizes string values inside nested objects recursively', () => { - const result = validator.sanitizeField('obj', { text: ' padded ' }); - expect(result.text).toBe('padded'); + it("sanitizes string values inside nested objects recursively", () => { + const result = validator.sanitizeField("obj", { text: " padded " }); + expect(result.text).toBe("padded"); }); - it('sanitizes string values inside arrays recursively', () => { - const result = validator.sanitizeField('arr', [' a ', ' b ']); - expect(result).toEqual(['a', 'b']); + it("sanitizes string values inside arrays recursively", () => { + const result = validator.sanitizeField("arr", [" a ", " b "]); + expect(result).toEqual(["a", "b"]); }); - it('passes through boolean values unchanged', () => { - expect(validator.sanitizeField('flag', true)).toBe(true); - expect(validator.sanitizeField('flag', false)).toBe(false); + it("passes through boolean values unchanged", () => { + expect(validator.sanitizeField("flag", true)).toBe(true); + expect(validator.sanitizeField("flag", false)).toBe(false); }); }); // ─── getValidationSummary ───────────────────────────────────────────────────── -describe('ContentFieldValidator.getValidationSummary', () => { +describe("ContentFieldValidator.getValidationSummary", () => { let validator: ContentFieldValidator; beforeEach(() => { validator = new ContentFieldValidator(); }); - it('returns zero counts for an empty map', () => { + it("returns zero counts for an empty map", () => { const summary = validator.getValidationSummary(new Map()); expect(summary.totalFields).toBe(0); expect(summary.validFields).toBe(0); @@ -358,37 +349,37 @@ describe('ContentFieldValidator.getValidationSummary', () => { expect(summary.criticalFields).toHaveLength(0); }); - it('counts valid, warned, and errored fields correctly', () => { + it("counts valid, warned, and errored fields correctly", () => { const fieldResults = new Map([ - ['ok', { isValid: true, field: 'x', warnings: [], errors: [] }], - ['warned', { isValid: true, field: 'y', warnings: ['w1'], errors: [] }], - ['errored', { isValid: false, field: 'z', warnings: [], errors: ['e1'] }], + ["ok", { isValid: true, field: "x", warnings: [], errors: [] }], + ["warned", { isValid: true, field: "y", warnings: ["w1"], errors: [] }], + ["errored", { isValid: false, field: "z", warnings: [], errors: ["e1"] }], ]); const summary = validator.getValidationSummary(fieldResults); expect(summary.totalFields).toBe(3); expect(summary.validFields).toBe(2); expect(summary.fieldsWithWarnings).toBe(1); expect(summary.fieldsWithErrors).toBe(1); - expect(summary.criticalFields).toContain('errored'); - expect(summary.criticalFields).not.toContain('ok'); + expect(summary.criticalFields).toContain("errored"); + expect(summary.criticalFields).not.toContain("ok"); }); - it('includes a field in criticalFields when it has errors', () => { + it("includes a field in criticalFields when it has errors", () => { const fieldResults = new Map([ - ['badField', { isValid: false, field: null, warnings: [], errors: ['bad content ID'] }], + ["badField", { isValid: false, field: null, warnings: [], errors: ["bad content ID"] }], ]); const { criticalFields } = validator.getValidationSummary(fieldResults); - expect(criticalFields).toContain('badField'); + expect(criticalFields).toContain("badField"); }); - it('derives summary from validateContentFields output', () => { + it("derives summary from validateContentFields output", () => { const { fieldResults } = validator.validateContentFields({ - title: 'Good', + title: "Good", ref: { contentid: -1 }, }); const summary = validator.getValidationSummary(fieldResults); expect(summary.totalFields).toBe(2); expect(summary.fieldsWithErrors).toBe(1); - expect(summary.criticalFields).toContain('ref'); + expect(summary.criticalFields).toContain("ref"); }); }); diff --git a/src/lib/downloaders/download-containers.ts b/src/lib/downloaders/download-containers.ts index 2144255..35af5a2 100644 --- a/src/lib/downloaders/download-containers.ts +++ b/src/lib/downloaders/download-containers.ts @@ -7,25 +7,25 @@ import * as fs from "fs"; import { parse } from "date-fns"; export async function downloadAllContainers( - guid: string, + guid: string // changeDelta: ChangeDelta ): Promise { const fileOps = new fileOperations(guid); const update = state.update; // Use state.update instead of parameter const apiClient = getApiClient(); const logger = getLoggerForGuid(guid); // Use GUID-specific logger - + if (!logger) { console.warn(`⚠️ No logger found for GUID ${guid}, skipping container logging`); return; } - + logger.startTimer(); - const containersFolderPath = fileOps.getDataFolderPath('containers'); + const containersFolderPath = fileOps.getDataFolderPath("containers"); // Use fileOperations to create containers folder - fileOps.createFolder('containers'); + fileOps.createFolder("containers"); let totalContainers = 0; // Define totalContainers in a broader scope for the catch block const startTime = Date.now(); // Track start time for performance measurement @@ -36,10 +36,10 @@ export async function downloadAllContainers( if (!fs.existsSync(filePath)) { return { exists: false }; } - const content = JSON.parse(fs.readFileSync(filePath, 'utf8')); + const content = JSON.parse(fs.readFileSync(filePath, "utf8")); return { lastModifiedDate: content.lastModifiedDate, - exists: true + exists: true, }; } catch (error) { return { exists: false }; @@ -47,17 +47,20 @@ export async function downloadAllContainers( } // Helper function to check if container needs download based on lastModifiedDate - function shouldDownloadContainer(apiContainer: any, localInfo: { lastModifiedDate?: string; exists: boolean }): { shouldDownload: boolean; reason: string } { + function shouldDownloadContainer( + apiContainer: any, + localInfo: { lastModifiedDate?: string; exists: boolean } + ): { shouldDownload: boolean; reason: string } { if (state.update === false) { - return { shouldDownload: false, reason: '' }; + return { shouldDownload: false, reason: "" }; } if (!localInfo.exists) { - return { shouldDownload: true, reason: 'new file' }; + return { shouldDownload: true, reason: "new file" }; } if (!localInfo.lastModifiedDate || !apiContainer.lastModifiedDate) { - return { shouldDownload: true, reason: 'missing date info' }; + return { shouldDownload: true, reason: "missing date info" }; } //the date format is: 07/23/2025 08:22PM (MM/DD/YYYY hh:mma) so we need to convert it to a Date object // Note: This assumes the date is in the format MM/DD/YYYY hh:mma @@ -66,10 +69,10 @@ export async function downloadAllContainers( const localeDateTime = parse(localInfo.lastModifiedDate, "MM/dd/yyyy hh:mma", new Date()); if (apiDateTime > localeDateTime && state.update === true) { - return { shouldDownload: true, reason: 'content changed' }; + return { shouldDownload: true, reason: "content changed" }; } - return { shouldDownload: false, reason: 'unchanged' }; + return { shouldDownload: false, reason: "unchanged" }; } try { @@ -82,8 +85,8 @@ export async function downloadAllContainers( const apiContainerIds = new Set(containers.map((c) => c.contentViewID.toString())); if (fs.existsSync(containersFolderPath)) { for (const file of fs.readdirSync(containersFolderPath)) { - if (!file.endsWith('.json')) continue; - const containerId = file.replace('.json', ''); + if (!file.endsWith(".json")) continue; + const containerId = file.replace(".json", ""); if (!apiContainerIds.has(containerId)) { fs.unlinkSync(path.join(containersFolderPath, file)); logger.info(`Removed deleted container file: ${file}`); @@ -113,19 +116,19 @@ export async function downloadAllContainers( containerRef, containerID, containerName, - reason: downloadDecision.reason + reason: downloadDecision.reason, }); } else { skippableContainers.push({ containerRef, containerID, containerName, - reason: downloadDecision.reason + reason: downloadDecision.reason, }); } } - if(skippableContainers.length > 0){ + if (skippableContainers.length > 0) { logger.changeDetectionSummary("container", downloadableContainers.length, skippableContainers.length); } @@ -159,11 +162,11 @@ export async function downloadAllContainers( // Export container JSON fileOps.exportFiles(`containers`, containerID.toString(), container); - logger.container.downloaded(container,); + logger.container.downloaded(container); return { success: true, container }; } catch (error: any) { - logger.container.error(null, `ID: ${containerID} - ${error.message || 'Unknown error'}`); + logger.container.error(null, `ID: ${containerID} - ${error.message || "Unknown error"}`); return { success: false, containerRef, error }; } }); @@ -187,7 +190,6 @@ export async function downloadAllContainers( logger.endTimer(); const errorCount = downloadableContainers.length - downloadedCount; logger.summary("pull", downloadedCount, skippedCount, errorCount); - } catch (error: any) { logger.error(`Error in downloadAllContainers: ${error.message || error}`); throw error; diff --git a/src/lib/downloaders/download-galleries.ts b/src/lib/downloaders/download-galleries.ts index fb6005a..be1e820 100644 --- a/src/lib/downloaders/download-galleries.ts +++ b/src/lib/downloaders/download-galleries.ts @@ -4,19 +4,17 @@ import ansiColors from "ansi-colors"; import { getAllChannels } from "../shared/get-all-channels"; import * as mgmtApi from "@agility/management-sdk"; -export async function downloadAllGalleries( - guid: string, -): Promise { +export async function downloadAllGalleries(guid: string): Promise { const fileOps = new fileOperations(guid); const update = state.update; // Use state.update instead of parameter const apiClient = getApiClient(); const logger = getLoggerForGuid(guid); // Use GUID-specific logger - + if (!logger) { console.warn(`⚠️ No logger found for GUID ${guid}, skipping gallery logging`); return; } - + logger.startTimer(); let index = 0; @@ -34,34 +32,33 @@ export async function downloadAllGalleries( console.error(error); return; } - - for(const gallery of initialRecords.assetMediaGroupings){ - const filename = gallery.mediaGroupingID + ".json"; - const localGallery = fileOps.readJsonFile(`galleries/${filename}`); - if(!localGallery){ + + for (const gallery of initialRecords.assetMediaGroupings) { + const filename = gallery.mediaGroupingID + ".json"; + const localGallery = fileOps.readJsonFile(`galleries/${filename}`); + if (!localGallery) { + fileOps.exportFiles("galleries", gallery.mediaGroupingID, gallery); + logger.gallery.downloaded(gallery); + downloadedCount++; + } else { + const incomingGalleryModifiedOn = new Date(gallery.modifiedOn); + const localGalleryModifiedOn = new Date(localGallery.modifiedOn); + if (incomingGalleryModifiedOn > localGalleryModifiedOn) { fileOps.exportFiles("galleries", gallery.mediaGroupingID, gallery); logger.gallery.downloaded(gallery); downloadedCount++; } else { - const incomingGalleryModifiedOn = new Date(gallery.modifiedOn); - const localGalleryModifiedOn = new Date(localGallery.modifiedOn); - if(incomingGalleryModifiedOn > localGalleryModifiedOn){ - fileOps.exportFiles("galleries", gallery.mediaGroupingID, gallery); - logger.gallery.downloaded(gallery); - downloadedCount++; - } else { - logger.gallery.skipped(gallery); - skippedCount++; - } + logger.gallery.skipped(gallery); + skippedCount++; } + } index++; } - + logger.endTimer(); logger.summary("pull", downloadedCount, skippedCount, 0); - } catch (error: any) { console.error(`Error in downloadAllGalleries: ${error.message}`); throw error; } -} +} diff --git a/src/lib/downloaders/download-models.ts b/src/lib/downloaders/download-models.ts index 3d00321..dc97c63 100644 --- a/src/lib/downloaders/download-models.ts +++ b/src/lib/downloaders/download-models.ts @@ -4,31 +4,29 @@ import * as path from "path"; import * as fs from "fs"; import { getAllChannels } from "lib/shared/get-all-channels"; -export async function downloadAllModels( - guid: string, -): Promise { +export async function downloadAllModels(guid: string): Promise { // Get values from fileOps which is already configured for this specific GUID/locale const fileOps = new fileOperations(guid); const apiClient = getApiClient(); const logger = getLoggerForGuid(guid); logger.startTimer(); - const modelsFolderPath = fileOps.getDataFolderPath('models'); + const modelsFolderPath = fileOps.getDataFolderPath("models"); // Use fileOperations to create models folder - fileOps.createFolder('models'); + fileOps.createFolder("models"); let totalModels = 0; - + // Helper function to get local model metadata function getLocalModelInfo(filePath: string): { lastModifiedDate?: string; exists: boolean } { try { if (!fs.existsSync(filePath)) { return { exists: false }; } - const content = JSON.parse(fs.readFileSync(filePath, 'utf8')); + const content = JSON.parse(fs.readFileSync(filePath, "utf8")); return { lastModifiedDate: content.lastModifiedDate, - exists: true + exists: true, }; } catch (error) { return { exists: false }; @@ -36,30 +34,32 @@ export async function downloadAllModels( } // Helper function to check if model needs download based on lastModifiedDate - function shouldDownloadModel(apiModel: any, localInfo: { lastModifiedDate?: string; exists: boolean }): { shouldDownload: boolean; reason: string } { - - if (state.update === false){ - return { shouldDownload: false, reason: '' }; + function shouldDownloadModel( + apiModel: any, + localInfo: { lastModifiedDate?: string; exists: boolean } + ): { shouldDownload: boolean; reason: string } { + if (state.update === false) { + return { shouldDownload: false, reason: "" }; } if (!localInfo.exists) { - return { shouldDownload: true, reason: 'new file' }; + return { shouldDownload: true, reason: "new file" }; } if (!localInfo.lastModifiedDate || !apiModel.lastModifiedDate) { - return { shouldDownload: true, reason: 'missing date info' }; + return { shouldDownload: true, reason: "missing date info" }; } const apiDate = new Date(apiModel.lastModifiedDate); const localDate = new Date(localInfo.lastModifiedDate); if (apiDate > localDate) { - return { shouldDownload: true, reason: 'content changed' }; + return { shouldDownload: true, reason: "content changed" }; } - return { shouldDownload: false, reason: 'unchanged' }; + return { shouldDownload: false, reason: "unchanged" }; } - + try { // Phase 1: Collect all model metadata const contentModules = await apiClient.modelMethods.getContentModules(false, guid, false); @@ -72,8 +72,8 @@ export async function downloadAllModels( const apiModelIds = new Set(allModels.map((m) => m.id.toString())); if (fs.existsSync(modelsFolderPath)) { for (const file of fs.readdirSync(modelsFolderPath)) { - if (!file.endsWith('.json')) continue; - const modelId = file.replace('.json', ''); + if (!file.endsWith(".json")) continue; + const modelId = file.replace(".json", ""); if (!apiModelIds.has(modelId)) { fs.unlinkSync(path.join(modelsFolderPath, file)); logger.info(`Removed deleted model file: ${file}`); @@ -88,34 +88,32 @@ export async function downloadAllModels( const modelSummary = allModels[i]; const fileName = modelSummary.id.toString(); const modelFilePath = path.join(modelsFolderPath, `${fileName}.json`); - + // Determine model type based on which array it came from - const modelType = i < contentModules.length ? 'content' : 'page'; - + const modelType = i < contentModules.length ? "content" : "page"; + // Get local model info for comparison const localInfo = getLocalModelInfo(modelFilePath); const downloadDecision = shouldDownloadModel(modelSummary, localInfo); - + if (downloadDecision.shouldDownload) { - downloadableModels.push({ - modelSummary, - fileName, - modelType, - reason: downloadDecision.reason + downloadableModels.push({ + modelSummary, + fileName, + modelType, + reason: downloadDecision.reason, }); } else { - skippableModels.push({ - modelSummary, - fileName, - modelType, - reason: downloadDecision.reason + skippableModels.push({ + modelSummary, + fileName, + modelType, + reason: downloadDecision.reason, }); - - } } - if(skippableModels.length > 0){ + if (skippableModels.length > 0) { logger.changeDetectionSummary("model", downloadableModels.length, skippableModels.length); } @@ -127,7 +125,7 @@ export async function downloadAllModels( // Execute model downloads concurrently in batches const CONCURRENT_BATCH_SIZE = 20; // Download max 20 models at once const batches = []; - + for (let i = 0; i < downloadableModels.length; i += CONCURRENT_BATCH_SIZE) { batches.push(downloadableModels.slice(i, i + CONCURRENT_BATCH_SIZE)); } @@ -139,20 +137,20 @@ export async function downloadAllModels( // Process each batch concurrently for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) { const batch = batches[batchIndex]; - + // Create download promises for this batch const downloadPromises = batch.map(async (item) => { const { modelSummary, fileName, modelType, reason } = item; try { // Always fetch full model details regardless of type const modelDetails = await apiClient.modelMethods.getContentModel(modelSummary.id, guid); - + if (!modelDetails) { throw new Error("Could not retrieve model details."); } // Export model JSON fileOps.exportFiles(`models`, fileName, modelDetails); - logger.model.downloaded(modelDetails); + logger.model.downloaded(modelDetails); return { success: true, modelDetails }; } catch (error: any) { logger.model.error(item, error); @@ -162,7 +160,7 @@ export async function downloadAllModels( // Wait for this batch to complete const results = await Promise.all(downloadPromises); - + // Update counters for (const result of results) { processedCount++; @@ -174,9 +172,8 @@ export async function downloadAllModels( logger.endTimer(); logger.summary("pull", downloadedCount, 0, 0); - } catch (error: any) { logger.error("Error in downloadAllModels:", error); throw error; } -} +} diff --git a/src/lib/downloaders/download-operations-config.ts b/src/lib/downloaders/download-operations-config.ts index d5f88f6..6411928 100644 --- a/src/lib/downloaders/download-operations-config.ts +++ b/src/lib/downloaders/download-operations-config.ts @@ -1,13 +1,13 @@ // Import existing downloaders -import { downloadAllGalleries } from './download-galleries'; -import { downloadAllAssets } from './download-assets'; -import { downloadAllModels } from './download-models'; -import { downloadAllTemplates } from './download-templates'; -import { downloadAllContainers } from './download-containers'; -import { downloadAllSyncSDK } from './download-sync-sdk'; -import { downloadAllSitemaps } from './download-sitemaps'; -import { getState } from '../../core/state'; -import ansiColors from 'ansi-colors'; +import { downloadAllGalleries } from "./download-galleries"; +import { downloadAllAssets } from "./download-assets"; +import { downloadAllModels } from "./download-models"; +import { downloadAllTemplates } from "./download-templates"; +import { downloadAllContainers } from "./download-containers"; +import { downloadAllSyncSDK } from "./download-sync-sdk"; +import { downloadAllSitemaps } from "./download-sitemaps"; +import { getState } from "../../core/state"; +import ansiColors from "ansi-colors"; // Central configuration for all download operations export interface OperationConfig { @@ -20,68 +20,68 @@ export interface OperationConfig { export const DOWNLOAD_OPERATIONS: Record = { syncSDK: { - name: 'downloadAllSyncSDK', - description: 'Download content items and sitemaps via Content Sync SDK', + name: "downloadAllSyncSDK", + description: "Download content items and sitemaps via Content Sync SDK", handler: async (guid) => { // Sync SDK will handle locales internally via guidLocaleMap (user will update this) // For now, use default locale - this will be converted to use guidLocaleMap internally await downloadAllSyncSDK(guid); }, - elements: ['Content', 'Sitemaps'], // NOTE: Content Sync SDK doesn't download page structures - only content items - dependencies: ['Models', 'Containers', 'Assets', 'Galleries', 'Templates'] // Content requires Models and Containers + elements: ["Content", "Sitemaps"], // NOTE: Content Sync SDK doesn't download page structures - only content items + dependencies: ["Models", "Containers", "Assets", "Galleries", "Templates"], // Content requires Models and Containers }, galleries: { - name: 'downloadAllGalleries', - description: 'Download asset galleries and media groupings', + name: "downloadAllGalleries", + description: "Download asset galleries and media groupings", handler: async (guid) => { await downloadAllGalleries(guid); }, - elements: ['Galleries'], + elements: ["Galleries"], // dependencies: ['Assets'] // Galleries require Assets to be meaningful }, assets: { - name: 'downloadAllAssets', - description: 'Download media files and asset metadata', + name: "downloadAllAssets", + description: "Download media files and asset metadata", handler: async (guid) => { await downloadAllAssets(guid); }, - elements: ['Assets'], - dependencies: ['Galleries'] // Assets require Galleries to be meaningful + elements: ["Assets"], + dependencies: ["Galleries"], // Assets require Galleries to be meaningful }, models: { - name: 'downloadAllModels', - description: 'Download content models and field definitions', + name: "downloadAllModels", + description: "Download content models and field definitions", handler: async (guid) => { await downloadAllModels(guid); }, - elements: ['Models'] + elements: ["Models"], }, templates: { - name: 'downloadAllTemplates', - description: 'Download page templates and layouts', + name: "downloadAllTemplates", + description: "Download page templates and layouts", handler: async (guid) => { await downloadAllTemplates(guid); }, - elements: ['Templates'], + elements: ["Templates"], // dependencies: ['Models', 'Containers', 'Pages', 'Content'] // Templates reference Models for container definitions }, containers: { - name: 'downloadAllContainers', - description: 'Download content containers and views', + name: "downloadAllContainers", + description: "Download content containers and views", handler: async (guid) => { await downloadAllContainers(guid); }, - elements: ['Containers'], - dependencies: ['Models'] // Containers require Models to be meaningful + elements: ["Containers"], + dependencies: ["Models"], // Containers require Models to be meaningful }, sitemaps: { - name: 'downloadAllSitemaps', - description: 'Download sitemaps', + name: "downloadAllSitemaps", + description: "Download sitemaps", handler: async (guid) => { await downloadAllSitemaps(guid); }, - elements: ['Sitemaps'] - } + elements: ["Sitemaps"], + }, }; export class DownloadOperationsRegistry { @@ -90,53 +90,52 @@ export class DownloadOperationsRegistry { */ static getOperationsForElements(fromPush: boolean): OperationConfig[] { const state = getState(); - const elementList = state.elements ? state.elements.split(",") : - ['Galleries', 'Assets', 'Models', 'Containers', 'Content', 'Templates', 'Pages', 'Sitemaps', 'Redirections']; - + const elementList = state.elements + ? state.elements.split(",") + : ["Galleries", "Assets", "Models", "Containers", "Content", "Templates", "Pages", "Sitemaps", "Redirections"]; + // Resolve dependencies and update state const { resolvedElements, autoIncluded } = this.resolveDependencies(elementList); - + // Update state.elements with resolved dependencies if any were auto-included if (autoIncluded.length > 0) { // Update the state with resolved elements - const { setState } = require('../../core/state'); - setState({ elements: resolvedElements.join(',') }); + const { setState } = require("../../core/state"); + setState({ elements: resolvedElements.join(",") }); } - + if (fromPush) { return Object.values(DOWNLOAD_OPERATIONS); } // Filter operations based on resolved elements - const relevantOperations = Object.values(DOWNLOAD_OPERATIONS).filter(operation => { + const relevantOperations = Object.values(DOWNLOAD_OPERATIONS).filter((operation) => { // Check if any of the operation's elements are in the resolved element list - return operation.elements.some(element => resolvedElements.includes(element)); + return operation.elements.some((element) => resolvedElements.includes(element)); }); - + return relevantOperations; } /** * Resolve element dependencies */ - private static resolveDependencies(requestedElements: string[]): { - resolvedElements: string[], - autoIncluded: string[] + private static resolveDependencies(requestedElements: string[]): { + resolvedElements: string[]; + autoIncluded: string[]; } { const resolvedElements = new Set(requestedElements); const autoIncluded: string[] = []; - + // Check each requested element for dependencies for (const element of requestedElements) { // Find operations that provide this element - const operations = Object.values(DOWNLOAD_OPERATIONS).filter(op => - op.elements.includes(element) - ); - + const operations = Object.values(DOWNLOAD_OPERATIONS).filter((op) => op.elements.includes(element)); + // Add dependencies for each operation - operations.forEach(operation => { + operations.forEach((operation) => { if (operation.dependencies) { - operation.dependencies.forEach(dep => { + operation.dependencies.forEach((dep) => { if (!resolvedElements.has(dep)) { resolvedElements.add(dep); autoIncluded.push(dep); @@ -145,11 +144,10 @@ export class DownloadOperationsRegistry { } }); } - + return { resolvedElements: Array.from(resolvedElements), - autoIncluded + autoIncluded, }; } - -} +} diff --git a/src/lib/downloaders/download-sitemaps.ts b/src/lib/downloaders/download-sitemaps.ts index 2c66ce4..ebf493c 100644 --- a/src/lib/downloaders/download-sitemaps.ts +++ b/src/lib/downloaders/download-sitemaps.ts @@ -5,34 +5,31 @@ import * as path from "path"; import ansiColors from "ansi-colors"; import { getAllChannels } from "../shared/get-all-channels"; -export async function downloadAllSitemaps( - guid: string, -): Promise { +export async function downloadAllSitemaps(guid: string): Promise { const fileOps = new fileOperations(guid); const locales = state.guidLocaleMap.get(guid); const update = state.update; const apiClient = getApiClient(); const logger = getLoggerForGuid(guid); // Use GUID-specific logger - + if (!logger) { console.warn(`⚠️ No logger found for GUID ${guid}, skipping sitemap logging`); return; } - + logger.startTimer(); // const changeDelta = new ChangeDelta(guid); - // Use fileOperations to create sitemaps folder - fileOps.createFolder('sitemaps'); + fileOps.createFolder("sitemaps"); const startTime = Date.now(); try { // Get the sitemap from API const sitemap = await apiClient.pageMethods.getSitemap(guid, locales[0]); - + if (!sitemap || sitemap.length === 0) { logger.sitemap.skipped(null, "No sitemap found to download"); return; @@ -41,10 +38,10 @@ export async function downloadAllSitemaps( // File path for the sitemap const sitemapFileName = `sitemap.json`; const sitemapFilePath = fileOps.getDataFolderPath(`sitemaps/${sitemapFileName}`); - + // Get local sitemap info for comparison const localSitemapInfo = getLocalSitemapInfo(sitemapFilePath); - + // Check if download is needed (sitemap is an array, so we use the first channel for lastModified check) const firstChannel = sitemap[0]; const sitemapDownloadDecision = shouldDownloadSitemap(firstChannel, localSitemapInfo, update); @@ -58,8 +55,12 @@ export async function downloadAllSitemaps( } logger.endTimer(); - logger.summary("pull", sitemapDownloadDecision.shouldDownload ? 1 : 0, sitemapDownloadDecision.shouldDownload ? 0 : 1, 0); - + logger.summary( + "pull", + sitemapDownloadDecision.shouldDownload ? 1 : 0, + sitemapDownloadDecision.shouldDownload ? 0 : 1, + 0 + ); } catch (error: any) { logger.error(`Failed to download sitemap: ${error.message}`); throw error; @@ -71,10 +72,10 @@ function getLocalSitemapInfo(filePath: string): { lastModified?: string; exists: if (!fs.existsSync(filePath)) { return { exists: false }; } - const content = JSON.parse(fs.readFileSync(filePath, 'utf8')); + const content = JSON.parse(fs.readFileSync(filePath, "utf8")); return { lastModified: content.lastModified, - exists: true + exists: true, }; } catch (error) { return { exists: false }; @@ -82,23 +83,23 @@ function getLocalSitemapInfo(filePath: string): { lastModified?: string; exists: } function shouldDownloadSitemap( - channel: any, + channel: any, localSitemapInfo: { lastModified?: string; exists: boolean }, forceUpdate: boolean = false ): { shouldDownload: boolean; reason: string } { - if (state.update === false){ - return { shouldDownload: false, reason: '' }; + if (state.update === false) { + return { shouldDownload: false, reason: "" }; } - + if (!localSitemapInfo.exists) { - return { shouldDownload: true, reason: 'local file does not exist' }; + return { shouldDownload: true, reason: "local file does not exist" }; } - + // Check if the channel has lastModified date const channelLastModified = channel?.lastModified || channel?.lastModifiedDate; if (channelLastModified && localSitemapInfo.lastModified !== channelLastModified) { - return { shouldDownload: true, reason: 'local file is outdated' }; + return { shouldDownload: true, reason: "local file is outdated" }; } - - return { shouldDownload: false, reason: 'local file is up to date' }; + + return { shouldDownload: false, reason: "local file is up to date" }; } diff --git a/src/lib/downloaders/download-sync-sdk.ts b/src/lib/downloaders/download-sync-sdk.ts index a0cf3f4..422f476 100644 --- a/src/lib/downloaders/download-sync-sdk.ts +++ b/src/lib/downloaders/download-sync-sdk.ts @@ -14,24 +14,16 @@ export async function downloadAllSyncSDK(guid: string) { const channels = await getAllChannels(guid, locales[0]); const downloads: Promise[] = []; - - - channels.forEach(channel => { - locales.forEach(locale => { + channels.forEach((channel) => { + locales.forEach((locale) => { downloads.push(downloadSyncSDKByLocaleAndChannel(guid, channel.channel.toLowerCase(), locale)); }); }); await Promise.allSettled(downloads); - } -export async function downloadSyncSDKByLocaleAndChannel( - guid: string, - channel: string, - locale: string -): Promise { - +export async function downloadSyncSDKByLocaleAndChannel(guid: string, channel: string, locale: string): Promise { const fileOps = new fileOperations(guid, locale); // Get API keys for this specific GUID @@ -40,11 +32,10 @@ export async function downloadSyncSDKByLocaleAndChannel( // Build the path to the instance-specific folder const instanceSpecificPath = fileOps.getDataFolderPath(); - const syncTokenPath = fileOps.getDataFilePath('state', 'sync.json'); + const syncTokenPath = fileOps.getDataFilePath("state", "sync.json"); const isIncrementalSync = await handleSyncToken(syncTokenPath, state.reset); - const logger = getLoggerForGuid(guid); // Configure the Agility Sync client // NOTE: Use determineFetchUrl (not determineBaseUrl) because: @@ -68,13 +59,16 @@ export async function downloadSyncSDKByLocaleAndChannel( rootPath: instanceSpecificPath, logger: logger, // NEW: Pass change delta tracker and mode - isIncrementalSync: isIncrementalSync - } - } + isIncrementalSync: isIncrementalSync, + }, + }, }; // RACE CONDITION FIX: Initialize progress tracking for this specific instance - if (storeInterfaceFileSystem.initializeProgress && typeof storeInterfaceFileSystem.initializeProgress === 'function') { + if ( + storeInterfaceFileSystem.initializeProgress && + typeof storeInterfaceFileSystem.initializeProgress === "function" + ) { storeInterfaceFileSystem.initializeProgress(instanceSpecificPath); } @@ -87,7 +81,10 @@ export async function downloadSyncSDKByLocaleAndChannel( await syncClient.runSync(); // Get enhanced sync stats (pass rootPath for instance isolation) - if (storeInterfaceFileSystem.getAndClearSavedItemStats && typeof storeInterfaceFileSystem.getAndClearSavedItemStats === 'function') { + if ( + storeInterfaceFileSystem.getAndClearSavedItemStats && + typeof storeInterfaceFileSystem.getAndClearSavedItemStats === "function" + ) { const syncResults = storeInterfaceFileSystem.getAndClearSavedItemStats(instanceSpecificPath); } @@ -98,7 +95,7 @@ export async function downloadSyncSDKByLocaleAndChannel( try { if (fs.existsSync(itemsPath)) { const files = fs.readdirSync(itemsPath); - itemCount = files.filter(file => path.extname(file).toLowerCase() === '.json').length; + itemCount = files.filter((file) => path.extname(file).toLowerCase() === ".json").length; itemsFoundMessage = `Verified ${itemCount} content item(s) on disk.`; } } catch (countError: any) { diff --git a/src/lib/downloaders/index.ts b/src/lib/downloaders/index.ts index d7ea3d9..3205cc7 100644 --- a/src/lib/downloaders/index.ts +++ b/src/lib/downloaders/index.ts @@ -1,12 +1,11 @@ // Individual downloader modules -export * from './download-assets'; -export * from './download-galleries'; -export * from './download-models'; -export * from './download-templates'; -export * from './download-containers'; -export * from './download-sync-sdk'; +export * from "./download-assets"; +export * from "./download-galleries"; +export * from "./download-models"; +export * from "./download-templates"; +export * from "./download-containers"; +export * from "./download-sync-sdk"; // Download orchestration modules -export * from './orchestrate-downloaders'; -export * from './download-operations-config'; - \ No newline at end of file +export * from "./orchestrate-downloaders"; +export * from "./download-operations-config"; diff --git a/src/lib/downloaders/orchestrate-downloaders.ts b/src/lib/downloaders/orchestrate-downloaders.ts index 82cec26..d49b15b 100644 --- a/src/lib/downloaders/orchestrate-downloaders.ts +++ b/src/lib/downloaders/orchestrate-downloaders.ts @@ -28,15 +28,15 @@ export class Downloader { */ async guidDownloader(guid: string, fromPush: boolean): Promise { const startTime = Date.now(); - + // Initialize per-GUID logger for true parallel logging (no specific entity type since operations vary) const guidLogger = initializeGuidLogger(guid, "pull"); - + // Log operation header with state information if (guidLogger) { guidLogger.logOperationHeader(); } - + const results: DownloadResults = { successful: [], failed: [], @@ -46,7 +46,6 @@ export class Downloader { }; try { - // Execute all data elements for this GUID await this.downloadDataElements(guid, results, fromPush); @@ -64,14 +63,12 @@ export class Downloader { console.error(`${guid}: Could not finalize log file - ${logError.message}`); } - return results; - } catch (error: any) { - results.failed.push({ operation: 'guid-orchestration', error: error.message }); + results.failed.push({ operation: "guid-orchestration", error: error.message }); results.totalDuration = Date.now() - startTime; console.error(`${guid}: Failed - ${error.message}`); - + // Try to finalize log file even on error try { const logFilePath = finalizeGuidLogger(guid); @@ -82,7 +79,7 @@ export class Downloader { } catch (logError: any) { console.error(`${guid}: Could not finalize log file - ${logError.message}`); } - + return results; } } @@ -94,51 +91,51 @@ export class Downloader { async instanceOrchestrator(fromPush: boolean): Promise { const state = getState(); const allGuids = [...state.sourceGuid, ...state.targetGuid]; - + if (allGuids.length === 0) { - throw new Error('No GUIDs available for download operation'); + throw new Error("No GUIDs available for download operation"); } // Use sequential mode when running against local API to prevent crashes // Local debugging sessions can't handle as many concurrent requests if (state.local) { - console.log(ansiColors.gray('Using sequential download mode for local API...')); + console.log(ansiColors.gray("Using sequential download mode for local API...")); const successfulResults: DownloadResults[] = []; - + for (const guid of allGuids) { try { const result = await this.guidDownloader(guid, fromPush); successfulResults.push(result); } catch (error: any) { - console.error(`Failed download: ${guid} - ${error?.message || 'Unknown error'}`); + console.error(`Failed download: ${guid} - ${error?.message || "Unknown error"}`); } } - + return successfulResults; } - + // Start ALL downloads simultaneously (true parallel execution) for cloud APIs - const downloadTasks = allGuids.map(guid => this.guidDownloader(guid, fromPush)); - + const downloadTasks = allGuids.map((guid) => this.guidDownloader(guid, fromPush)); + const results = await Promise.allSettled(downloadTasks); - + // Process results and separate successful from failed const successfulResults: DownloadResults[] = []; const failedResults: Array<{ guid: string; error: string }> = []; - + allGuids.forEach((guid, index) => { const result = results[index]; - if (result.status === 'fulfilled') { + if (result.status === "fulfilled") { successfulResults.push(result.value); } else { - failedResults.push({ - guid, - error: result.reason?.message || 'Unknown error' + failedResults.push({ + guid, + error: result.reason?.message || "Unknown error", }); console.error(`Failed download: ${guid} - ${result.reason?.message}`); } }); - + // Report parallel execution summary return successfulResults; } @@ -146,11 +143,7 @@ export class Downloader { /** * Execute specific data elements for a GUID */ - private async downloadDataElements( - guid: string, - results: DownloadResults, - fromPush: boolean - ): Promise { + private async downloadDataElements(guid: string, results: DownloadResults, fromPush: boolean): Promise { // Get operations based on elements filter const operations = DownloadOperationsRegistry.getOperationsForElements(fromPush); @@ -160,17 +153,16 @@ export class Downloader { for (const operation of operations) { try { this.config.onOperationStart?.(operation.name, guid); - + await operation.handler(guid); - + results.successful.push(`${operation.name} (${guid})`); this.config.onOperationComplete?.(operation.name, guid, true); - } catch (error: any) { console.log(error); - const errorMessage = error.message || 'Unknown error'; + const errorMessage = error.message || "Unknown error"; results.failed.push({ operation: operation.name, error: errorMessage }); - + this.config.onOperationComplete?.(operation.name, guid, false); console.error(`❌ ${guid}: ${operation.name} failed - ${errorMessage}`); } diff --git a/src/lib/downloaders/store-interface-filesystem.ts b/src/lib/downloaders/store-interface-filesystem.ts index 0242887..3757911 100644 --- a/src/lib/downloaders/store-interface-filesystem.ts +++ b/src/lib/downloaders/store-interface-filesystem.ts @@ -1,14 +1,13 @@ -import ansiColors from "ansi-colors" +import ansiColors from "ansi-colors"; -const fs = require('fs') -const os = require('os') -const path = require('path') -const { lockSync, unlockSync, checkSync, check } = require("proper-lockfile") +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { lockSync, unlockSync, checkSync, check } = require("proper-lockfile"); import { sleep } from "../shared/sleep"; -const { getState, getLoggerForGuid } = require('../../core/state'); -import { Logs } from '../../core/logs'; - +const { getState, getLoggerForGuid } = require("../../core/state"); +import { Logs } from "../../core/logs"; // RACE CONDITION FIX: Convert global stats to instance-specific stats // Use rootPath as unique identifier for each concurrent download @@ -16,23 +15,23 @@ const _instanceStats = new Map(); // Type definitions for better TypeScript support interface ProgressStats { - totalItems: number; - itemsByType: { [itemType: string]: number }; - elapsedTime: number; - itemsPerSecond: number; - recentActivity: Array<{ itemType: string, itemID: string | number, timestamp: number }>; + totalItems: number; + itemsByType: { [itemType: string]: number }; + elapsedTime: number; + itemsPerSecond: number; + recentActivity: Array<{ itemType: string; itemID: string | number; timestamp: number }>; } interface InstanceStatsData { - itemsSavedStats: Array<{ itemType: string, itemID: string | number, languageCode: string, timestamp: number }>; - progressByType: { [itemType: string]: number }; - progressCallback: ((stats: ProgressStats) => void) | null; - syncStartTime: number; + itemsSavedStats: Array<{ itemType: string; itemID: string | number; languageCode: string; timestamp: number }>; + progressByType: { [itemType: string]: number }; + progressCallback: ((stats: ProgressStats) => void) | null; + syncStartTime: number; } require("dotenv").config({ - path: `.env.${process.env.NODE_ENV}`, -}) + path: `.env.${process.env.NODE_ENV}`, +}); /** * Get the logger for the current operation @@ -41,7 +40,7 @@ function getLogger(options: any): Logs | null { // Extract GUID from options.rootPath or options.guid const guid = options?.guid || options?.sourceGuid || extractGuidFromPath(options?.rootPath); if (!guid) return null; - + return getLoggerForGuid(guid); } @@ -50,9 +49,9 @@ function getLogger(options: any): Logs | null { */ function extractGuidFromPath(rootPath: string): string | null { if (!rootPath) return null; - + // Look for GUID pattern in path segments - const segments = rootPath.split('/'); + const segments = rootPath.split("/"); for (const segment of segments) { // Match GUID patterns like "13a8b394-u" or "af9a3c91-4ca0-42db-bdb9-cced53a818d6" if (/^[a-f0-9]{8}-[a-f0-9-]{1,36}$/i.test(segment)) { @@ -67,8 +66,8 @@ function extractGuidFromPath(rootPath: string): string | null { */ function mapItemTypeToEntityType(itemType: string): string { const typeMap = { - 'item': 'content-item', - 'page': 'page' + item: "content-item", + page: "page", }; return typeMap[itemType] || itemType; } @@ -77,23 +76,23 @@ function mapItemTypeToEntityType(itemType: string): string { * Extract entity name from item content */ function extractEntityName(item: any, itemType: string): string { - if (itemType === 'page') { + if (itemType === "page") { return item.name || item.title || `Page ${item.pageID}`; } - if (itemType === 'item') { + if (itemType === "item") { return item.properties?.referenceName || `Content ${item.contentID}`; } - return `${itemType} ${item.id || 'Unknown'}`; + return `${itemType} ${item.id || "Unknown"}`; } /** * Extract reference name from item content */ function extractReferenceName(item: any, itemType: string): string | undefined { - if (itemType === 'page') { + if (itemType === "page") { return item.name; } - if (itemType === 'item') { + if (itemType === "item") { return item.properties?.referenceName; } return undefined; @@ -103,15 +102,15 @@ function extractReferenceName(item: any, itemType: string): string | undefined { * Get or create instance-specific stats for the given rootPath */ const getInstanceStats = (rootPath: string): InstanceStatsData => { - if (!_instanceStats.has(rootPath)) { - _instanceStats.set(rootPath, { - itemsSavedStats: [], - progressByType: {}, - progressCallback: null, - syncStartTime: 0 - }); - } - return _instanceStats.get(rootPath); + if (!_instanceStats.has(rootPath)) { + _instanceStats.set(rootPath, { + itemsSavedStats: [], + progressByType: {}, + progressCallback: null, + syncStartTime: 0, + }); + } + return _instanceStats.get(rootPath); }; /** @@ -119,101 +118,101 @@ const getInstanceStats = (rootPath: string): InstanceStatsData => { * This allows the UI to get real-time updates during sync operations */ const setProgressCallback = (callback: ((stats: ProgressStats) => void) | null, rootPath?: string) => { - if (rootPath) { - const instanceStats = getInstanceStats(rootPath); - instanceStats.progressCallback = callback; - } else { - // Fallback: set for all instances if rootPath not specified - _instanceStats.forEach((stats) => { - stats.progressCallback = callback; - }); - } + if (rootPath) { + const instanceStats = getInstanceStats(rootPath); + instanceStats.progressCallback = callback; + } else { + // Fallback: set for all instances if rootPath not specified + _instanceStats.forEach((stats) => { + stats.progressCallback = callback; + }); + } }; /** * Initialize progress tracking for a new sync operation */ const initializeProgress = (rootPath?: string) => { - if (rootPath) { - const instanceStats = getInstanceStats(rootPath); - instanceStats.itemsSavedStats = []; - instanceStats.progressByType = {}; - instanceStats.syncStartTime = Date.now(); - } else { - // Fallback: initialize all instances if rootPath not specified - _instanceStats.forEach((stats) => { - stats.itemsSavedStats = []; - stats.progressByType = {}; - stats.syncStartTime = Date.now(); - }); - } + if (rootPath) { + const instanceStats = getInstanceStats(rootPath); + instanceStats.itemsSavedStats = []; + instanceStats.progressByType = {}; + instanceStats.syncStartTime = Date.now(); + } else { + // Fallback: initialize all instances if rootPath not specified + _instanceStats.forEach((stats) => { + stats.itemsSavedStats = []; + stats.progressByType = {}; + stats.syncStartTime = Date.now(); + }); + } }; /** * Clean up old progress data to prevent memory bloat during long operations */ const cleanupProgressData = (rootPath: string) => { - const instanceStats = getInstanceStats(rootPath); - const MAX_STATS_HISTORY = 200; // Limit for memory management - if (instanceStats.itemsSavedStats.length > MAX_STATS_HISTORY) { - instanceStats.itemsSavedStats = instanceStats.itemsSavedStats.slice(-MAX_STATS_HISTORY); - } + const instanceStats = getInstanceStats(rootPath); + const MAX_STATS_HISTORY = 200; // Limit for memory management + if (instanceStats.itemsSavedStats.length > MAX_STATS_HISTORY) { + instanceStats.itemsSavedStats = instanceStats.itemsSavedStats.slice(-MAX_STATS_HISTORY); + } }; /** * Get current progress statistics without clearing the data */ const getProgressStats = (rootPath: string): ProgressStats => { - const instanceStats = getInstanceStats(rootPath); - const elapsedTime = Date.now() - instanceStats.syncStartTime; - const totalItems = instanceStats.itemsSavedStats.length; - - return { - totalItems, - itemsByType: { ...instanceStats.progressByType }, - elapsedTime, - itemsPerSecond: totalItems > 0 ? (totalItems / (elapsedTime / 1000)) : 0, - recentActivity: instanceStats.itemsSavedStats.slice(-10).map(item => ({ - itemType: item.itemType, - itemID: item.itemID, - timestamp: item.timestamp - })) - }; + const instanceStats = getInstanceStats(rootPath); + const elapsedTime = Date.now() - instanceStats.syncStartTime; + const totalItems = instanceStats.itemsSavedStats.length; + + return { + totalItems, + itemsByType: { ...instanceStats.progressByType }, + elapsedTime, + itemsPerSecond: totalItems > 0 ? totalItems / (elapsedTime / 1000) : 0, + recentActivity: instanceStats.itemsSavedStats.slice(-10).map((item) => ({ + itemType: item.itemType, + itemID: item.itemID, + timestamp: item.timestamp, + })), + }; }; /** * Update progress and trigger callback if set */ const updateProgress = (itemType: string, itemID: string | number, rootPath: string) => { - const instanceStats = getInstanceStats(rootPath); - - // Add to stats - instanceStats.itemsSavedStats.push({ - itemType, - itemID, - languageCode: 'unknown', // Language not available at this level - timestamp: Date.now() - }); - - // Update type counts - instanceStats.progressByType[itemType] = (instanceStats.progressByType[itemType] || 0) + 1; - - // Clean up old data periodically - if (instanceStats.itemsSavedStats.length % 50 === 0) { - cleanupProgressData(rootPath); - } - - // Trigger callback if set - if (instanceStats.progressCallback) { - instanceStats.progressCallback(getProgressStats(rootPath)); - } + const instanceStats = getInstanceStats(rootPath); + + // Add to stats + instanceStats.itemsSavedStats.push({ + itemType, + itemID, + languageCode: "unknown", // Language not available at this level + timestamp: Date.now(), + }); + + // Update type counts + instanceStats.progressByType[itemType] = (instanceStats.progressByType[itemType] || 0) + 1; + + // Clean up old data periodically + if (instanceStats.itemsSavedStats.length % 50 === 0) { + cleanupProgressData(rootPath); + } + + // Trigger callback if set + if (instanceStats.progressCallback) { + instanceStats.progressCallback(getProgressStats(rootPath)); + } }; /** * The function to handle saving/updating an item to your storage. This could be a Content Item, Page, Url Redirections, Sync State (state), or Sitemap. * @param {Object} params - The parameters object * @param {Object} params.options - A flexible object that can contain any properties specifically related to this interface - * @param {String} params.options.rootPath - The path to store/access the content as JSON + * @param {String} params.options.rootPath - The path to store/access the content as JSON * @param {Object} params.item - The object representing the Content Item, Page, Url Redirections, Sync State (state), or Sitemap that needs to be saved/updated * @param {String} params.itemType - The type of item being saved/updated, expected values are `item`, `page`, `sitemap`, `nestedsitemap`, `state`, `urlredirections` * @param {String} params.languageCode - The locale code associated to the item being saved/updated @@ -221,289 +220,274 @@ const updateProgress = (itemType: string, itemID: string | number, rootPath: str * @returns {Void} */ const saveItem = async ({ options, item, itemType, languageCode, itemID }) => { + // Null/undefined safety check - prevent crashes when SDK passes undefined items + if (item === null || item === undefined) { + console.warn(`⚠️ Skipping save for ${itemType} (ID: ${itemID}) - item is ${item}`); + return; + } - // Null/undefined safety check - prevent crashes when SDK passes undefined items - if (item === null || item === undefined) { - console.warn(`⚠️ Skipping save for ${itemType} (ID: ${itemID}) - item is ${item}`); - return; - } - - const cwd = process.cwd(); - let filePath = getFilePath({ options, itemType, languageCode, itemID }); - const absoluteFilePath = path.resolve(cwd, filePath); - let dirPath = path.dirname(absoluteFilePath); - const forceOverwrite = options.forceOverwrite; - - // Get the logger for this operation - const logger = options.logger; - - try { - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - - if (!fs.existsSync(dirPath)) { - throw new Error(`Failed to create directory: ${dirPath}`); - } - } - - let json = JSON.stringify(item); - fs.writeFileSync(absoluteFilePath, json); - - // Use structured logging instead of basic console.log - if (logger) { - - // if(itemType !== 'item' && itemType !== 'sitemap' && itemType !== 'list') { console.log('item', item); } - // Map itemType to appropriate logger method and include locale for content/pages - if (itemType === 'item') { - logger.content.downloaded(item, undefined, languageCode); - } else if (itemType === 'page') { - logger.page.downloaded(item, undefined, languageCode); - } else if (itemType === 'sitemap') { - logger.sitemap.downloaded({ name: 'sitemap.json' }); - } else { - // Fallback for other item types - // const entityName = extractEntityName(item, itemType); - // logger.info(`✓ Downloaded ${itemType}: ${entityName} [${languageCode}]`); - } - } else { - // Fallback to basic logging if no logger available - // const state = getState(); - // if (state.verbose) { - // console.log('✓ Downloaded',ansiColors.cyan(itemType), ansiColors.white(itemID)); - // } - } - - if (!fs.existsSync(absoluteFilePath)) { - throw new Error(`File was not created: ${absoluteFilePath}`); - } - - // REMOVE direct log, PUSH to stats array - // console.log(`✓ Downloaded ${ansiColors.cyan(itemType)} (ID: ${itemID})`); - // updateProgress(itemType, itemID, options.rootPath); - - } catch (error) { - // Use structured error logging if available - if (logger) { - if (itemType === 'item') { - logger.contentitem.error(item, error, languageCode); - } else if (itemType === 'page') { - logger.page.error(item, error, languageCode); - } else { - logger.error(`Failed to save ${itemType} (ID: ${itemID}): ${error.message}`); - } - } else { - console.error('Error in saveItem:', error); - } - - console.error('Error details:', { - filePath, - absoluteFilePath, - dirPath, - cwd, - error: error.message, - stack: error.stack - }); - throw error; - } -} + const cwd = process.cwd(); + let filePath = getFilePath({ options, itemType, languageCode, itemID }); + const absoluteFilePath = path.resolve(cwd, filePath); + let dirPath = path.dirname(absoluteFilePath); + const forceOverwrite = options.forceOverwrite; + + // Get the logger for this operation + const logger = options.logger; + + try { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + + if (!fs.existsSync(dirPath)) { + throw new Error(`Failed to create directory: ${dirPath}`); + } + } + + let json = JSON.stringify(item); + fs.writeFileSync(absoluteFilePath, json); + + // Use structured logging instead of basic console.log + if (logger) { + // if(itemType !== 'item' && itemType !== 'sitemap' && itemType !== 'list') { console.log('item', item); } + // Map itemType to appropriate logger method and include locale for content/pages + if (itemType === "item") { + logger.content.downloaded(item, undefined, languageCode); + } else if (itemType === "page") { + logger.page.downloaded(item, undefined, languageCode); + } else if (itemType === "sitemap") { + logger.sitemap.downloaded({ name: "sitemap.json" }); + } else { + // Fallback for other item types + // const entityName = extractEntityName(item, itemType); + // logger.info(`✓ Downloaded ${itemType}: ${entityName} [${languageCode}]`); + } + } else { + // Fallback to basic logging if no logger available + // const state = getState(); + // if (state.verbose) { + // console.log('✓ Downloaded',ansiColors.cyan(itemType), ansiColors.white(itemID)); + // } + } + + if (!fs.existsSync(absoluteFilePath)) { + throw new Error(`File was not created: ${absoluteFilePath}`); + } + + // REMOVE direct log, PUSH to stats array + // console.log(`✓ Downloaded ${ansiColors.cyan(itemType)} (ID: ${itemID})`); + // updateProgress(itemType, itemID, options.rootPath); + } catch (error) { + // Use structured error logging if available + if (logger) { + if (itemType === "item") { + logger.contentitem.error(item, error, languageCode); + } else if (itemType === "page") { + logger.page.error(item, error, languageCode); + } else { + logger.error(`Failed to save ${itemType} (ID: ${itemID}): ${error.message}`); + } + } else { + console.error("Error in saveItem:", error); + } + + console.error("Error details:", { + filePath, + absoluteFilePath, + dirPath, + cwd, + error: error.message, + stack: error.stack, + }); + throw error; + } +}; /** * The function to handle deleting an item to your storage. This could be a Content Item, Page, Url Redirections, Sync State (state), or Sitemap. * @param {Object} params - The parameters object * @param {Object} params.options - A flexible object that can contain any properties specifically related to this interface - * @param {String} params.options.rootPath - The path to store/access the content as JSON + * @param {String} params.options.rootPath - The path to store/access the content as JSON * @param {String} params.itemType - The type of item being deleted, expected values are `item`, `page`, `sitemap`, `nestedsitemap`, `state`, `urlredirections` * @param {String} params.languageCode - The locale code associated to the item being saved/updated * @param {(String|Number)} params.itemID - The ID of the item being deleted - this could be a string or number depending on the itemType * @returns {Void} */ const deleteItem = async ({ options, itemType, languageCode, itemID }) => { + let filePath = getFilePath({ options, itemType, languageCode, itemID }); - let filePath = getFilePath({ options, itemType, languageCode, itemID }); - - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath); - } - -} + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } +}; /** * The function to handle updating and placing a Content Item into a "list" so that you can handle querying a collection of items. * @param {Object} params - The parameters object * @param {Object} params.options - A flexible object that can contain any properties specifically related to this interface - * @param {String} params.options.rootPath - The path to store/access the content as JSON + * @param {String} params.options.rootPath - The path to store/access the content as JSON * @param {Object} params.item - The object representing the Content Item - * @param {String} params.languageCode - The locale code associated to the item being saved/updated + * @param {String} params.languageCode - The locale code associated to the item being saved/updated * @param {(String|Number)} params.itemID - The ID of the item being updated - this could be a string or number depending on the itemType * @param {String} params.referenceName - The reference name of the Content List that this Content Item should be added to * @param {String} params.definitionName - The Model name that the Content Item is based on * @returns {Void} */ const mergeItemToList = async ({ options, item, languageCode, itemID, referenceName, definitionName }) => { + let contentList = await getItem({ options, itemType: "list", languageCode, itemID: referenceName }); + + if (contentList == null) { + //initialize the list + contentList = [item]; + } else { + //replace the item... + const cIndex = contentList.findIndex((ci) => { + return ci.contentID === itemID; + }); - let contentList = await getItem({ options, itemType: "list", languageCode, itemID: referenceName }); - - if (contentList == null) { - //initialize the list - contentList = [item]; - } else { - //replace the item... - const cIndex = contentList.findIndex((ci) => { - return ci.contentID === itemID; - }); - - if (item.properties.state === 3) { - //*** deleted item (remove from the list) *** - if (cIndex >= 0) { - //remove the item - contentList.splice(cIndex, 1); - } - - } else { - //*** regular item (merge) *** - if (cIndex >= 0) { - //replace the existing item - contentList[cIndex] = item; - } else { - //and it to the end of the - contentList.push(item); - } - } - } - - await saveItem({ options, item: contentList, itemType: "list", languageCode, itemID: referenceName }); -} + if (item.properties.state === 3) { + //*** deleted item (remove from the list) *** + if (cIndex >= 0) { + //remove the item + contentList.splice(cIndex, 1); + } + } else { + //*** regular item (merge) *** + if (cIndex >= 0) { + //replace the existing item + contentList[cIndex] = item; + } else { + //and it to the end of the + contentList.push(item); + } + } + } + + await saveItem({ options, item: contentList, itemType: "list", languageCode, itemID: referenceName }); +}; /** * The function to handle retrieving a Content Item, Page, Url Redirections, Sync State (state), or Sitemap * @param {Object} params - The parameters object * @param {Object} params.options - A flexible object that can contain any properties specifically related to this interface - * @param {String} params.options.rootPath - The path to store/access the content as JSON + * @param {String} params.options.rootPath - The path to store/access the content as JSON * @param {String} params.itemType - The type of item being accessed, expected values are `item`, `list`, `page`, `sitemap`, `nestedsitemap`, `state`, `urlredirections` * @param {String} params.languageCode - The locale code associated to the item being accessed * @param {(String|Number)} params.itemID - The ID of the item being accessed - this could be a string or number depending on the itemType * @returns {Object} */ const getItem = async ({ options, itemType, languageCode, itemID }) => { - let filePath = getFilePath({ options, itemType, languageCode, itemID }); + let filePath = getFilePath({ options, itemType, languageCode, itemID }); - if (!fs.existsSync(filePath)) return null; + if (!fs.existsSync(filePath)) return null; - let json = fs.readFileSync(filePath, 'utf8'); - return JSON.parse(json); -} + let json = fs.readFileSync(filePath, "utf8"); + return JSON.parse(json); +}; /** * The function to handle clearing the cache of synchronized data from the CMS * @param {Object} params - The parameters object * @param {Object} params.options - A flexible object that can contain any properties specifically related to this interface - * @param {String} params.options.rootPath - The path to store/access the content as JSON + * @param {String} params.options.rootPath - The path to store/access the content as JSON * @returns {Void} */ const clearItems = async ({ options }) => { - fs.rmdirSync(options.rootPath, { recursive: true }) -} - - + fs.rmdirSync(options.rootPath, { recursive: true }); +}; /** * The function to handle multi-threaded Syncs that may be happening at the same time. If you need to prevent a sync from happening and let it wait until another sync has finished use this. * @returns {Promise} */ const mutexLock = async () => { + const dir = os.tmpdir(); + const lockFile = `${dir}/${"agility-sync"}.mutex`; + if (!fs.existsSync(lockFile)) { + fs.writeFileSync(lockFile, "agility-sync"); + } + //THE LOCK IS ALREADY HELD - WAIT UP! + await waitOnLock(lockFile); + + try { + return lockSync(lockFile); + } catch (err) { + if (`${err}`.indexOf("Lock file is already being held") !== -1) { + //this error happens when 2 processes try to get a lock at the EXACT same time (very rare) + await sleep(100); + await waitOnLock(lockFile); + + try { + return lockSync(lockFile); + } catch (e2) { + if (`${err}`.indexOf("Lock file is already being held") !== -1) { + //this error happens when 2 processes try to get a lock at the EXACT same time (very rare) + await sleep(100); + await waitOnLock(lockFile); + return lockSync(lockFile); + } + } + } - const dir = os.tmpdir(); - const lockFile = `${dir}/${"agility-sync"}.mutex` - if (! fs.existsSync(lockFile)) { - fs.writeFileSync(lockFile, "agility-sync"); - } - - //THE LOCK IS ALREADY HELD - WAIT UP! - await waitOnLock(lockFile) - - try { - return lockSync(lockFile) - } catch (err) { - if (`${err}`.indexOf("Lock file is already being held") !== -1) { - - //this error happens when 2 processes try to get a lock at the EXACT same time (very rare) - await sleep(100) - await waitOnLock(lockFile) - - try { - return lockSync(lockFile) - } catch (e2) { - if (`${err}`.indexOf("Lock file is already being held") !== -1) { - - //this error happens when 2 processes try to get a lock at the EXACT same time (very rare) - await sleep(100) - await waitOnLock(lockFile) - return lockSync(lockFile) - } - } - } - - throw Error("The mutex lock could not be obtained.") - } - -} - + throw Error("The mutex lock could not be obtained."); + } +}; //private function to get a wait on a lock file const waitOnLock = async (lockFile) => { - while (await check(lockFile)) { - await sleep(100) - } -} + while (await check(lockFile)) { + await sleep(100); + } +}; //private function to get path of an item const getFilePath = ({ options, itemType, languageCode, itemID }) => { - if(typeof itemID === 'string' || itemID instanceof String){ - itemID = itemID.replace(/[`!@#$%^&*()+\=\[\]{};':"\\|,.<>\/?~]/g, ""); - } - - // Fix inconsistency: Convert "page" (singular) to "pages" (plural) - // to match where get-pages.ts expects to find them - // if (itemType === 'page') { - // itemType = 'pages'; - // } - - const fileName = `${itemID}.json`; - return path.join(options.rootPath, itemType, fileName); -} + if (typeof itemID === "string" || itemID instanceof String) { + itemID = itemID.replace(/[`!@#$%^&*()+\=\[\]{};':"\\|,.<>\/?~]/g, ""); + } + + // Fix inconsistency: Convert "page" (singular) to "pages" (plural) + // to match where get-pages.ts expects to find them + // if (itemType === 'page') { + // itemType = 'pages'; + // } + + const fileName = `${itemID}.json`; + return path.join(options.rootPath, itemType, fileName); +}; // Enhanced function to get and clear saved item stats with progress data const getAndClearSavedItemStats = (rootPath: string) => { - const instanceStats = getInstanceStats(rootPath); - const stats = getProgressStats(rootPath); - - // Prepare detailed summary - const summary = { - totalItems: stats.totalItems, - elapsedTime: stats.elapsedTime, - itemsPerSecond: stats.itemsPerSecond - }; - - // Clear stats for this instance - instanceStats.itemsSavedStats = []; - instanceStats.progressByType = {}; - - return { - summary, - itemsByType: stats.itemsByType, - recentActivity: stats.recentActivity - }; + const instanceStats = getInstanceStats(rootPath); + const stats = getProgressStats(rootPath); + + // Prepare detailed summary + const summary = { + totalItems: stats.totalItems, + elapsedTime: stats.elapsedTime, + itemsPerSecond: stats.itemsPerSecond, + }; + + // Clear stats for this instance + instanceStats.itemsSavedStats = []; + instanceStats.progressByType = {}; + + return { + summary, + itemsByType: stats.itemsByType, + recentActivity: stats.recentActivity, + }; }; module.exports = { - saveItem, - deleteItem, - mergeItemToList, - getItem, - clearItems, - mutexLock, - getAndClearSavedItemStats, // RE-ADD Export - setProgressCallback, - initializeProgress, - getCurrentProgress: getProgressStats, // Alias for getProgressStats - updateProgress, - cleanupProgressData // NEW: Memory cleanup function -} \ No newline at end of file + saveItem, + deleteItem, + mergeItemToList, + getItem, + clearItems, + mutexLock, + getAndClearSavedItemStats, // RE-ADD Export + setProgressCallback, + initializeProgress, + getCurrentProgress: getProgressStats, // Alias for getProgressStats + updateProgress, + cleanupProgressData, // NEW: Memory cleanup function +}; diff --git a/src/lib/downloaders/sync-token-handler.ts b/src/lib/downloaders/sync-token-handler.ts index d75d9ab..98bcfc8 100644 --- a/src/lib/downloaders/sync-token-handler.ts +++ b/src/lib/downloaders/sync-token-handler.ts @@ -2,7 +2,6 @@ import ansiColors from "ansi-colors"; import * as fs from "fs"; export async function handleSyncToken(syncTokenPath: string, reset: boolean) { - const syncTokenExists = fs.existsSync(syncTokenPath); if (!reset) { diff --git a/src/lib/downloaders/tests/download-assets.test.ts b/src/lib/downloaders/tests/download-assets.test.ts index d6b7014..cce5e09 100644 --- a/src/lib/downloaders/tests/download-assets.test.ts +++ b/src/lib/downloaders/tests/download-assets.test.ts @@ -1,19 +1,19 @@ -import { resetState, setState, state } from 'core/state'; +import { resetState, setState, state } from "core/state"; -jest.mock('core/fileOperations', () => ({ +jest.mock("core/fileOperations", () => ({ fileOperations: jest.fn().mockImplementation(() => ({ createFolder: jest.fn(), exportFiles: jest.fn(), downloadFile: jest.fn(), - getDataFolderPath: jest.fn().mockReturnValue('/tmp/agility-mock-assets'), + getDataFolderPath: jest.fn().mockReturnValue("/tmp/agility-mock-assets"), })), })); -jest.mock('lib/shared/get-all-channels', () => ({ - getAllChannels: jest.fn().mockResolvedValue([{ channel: 'website' }]), +jest.mock("lib/shared/get-all-channels", () => ({ + getAllChannels: jest.fn().mockResolvedValue([{ channel: "website" }]), })); -import { downloadAllAssets } from 'lib/downloaders/download-assets'; +import { downloadAllAssets } from "lib/downloaders/download-assets"; function makeMockLogger() { return { @@ -30,9 +30,9 @@ function makeMockLogger() { beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -41,54 +41,52 @@ afterEach(() => { // ─── downloadAllAssets guard clause ─────────────────────────────────────────── -describe('downloadAllAssets', () => { - describe('guard clause: no logger for GUID', () => { - it('returns early without throwing when getLoggerForGuid returns null', async () => { - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(null); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ +describe("downloadAllAssets", () => { + describe("guard clause: no logger for GUID", () => { + it("returns early without throwing when getLoggerForGuid returns null", async () => { + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(null); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ assetMethods: { getMediaList: jest.fn() }, }); - await expect(downloadAllAssets('test-guid-u')).resolves.toBeUndefined(); + await expect(downloadAllAssets("test-guid-u")).resolves.toBeUndefined(); }); - it('logs a warning when no logger is found for the GUID', async () => { - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(null); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + it("logs a warning when no logger is found for the GUID", async () => { + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(null); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ assetMethods: { getMediaList: jest.fn() }, }); - await downloadAllAssets('test-guid-u'); + await downloadAllAssets("test-guid-u"); - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining('No logger found for GUID test-guid-u') - ); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("No logger found for GUID test-guid-u")); }); }); - describe('guard clause: logger present, API propagates error', () => { - it('throws when the API client call rejects', async () => { - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(makeMockLogger()); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + describe("guard clause: logger present, API propagates error", () => { + it("throws when the API client call rejects", async () => { + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(makeMockLogger()); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ assetMethods: { - getMediaList: jest.fn().mockRejectedValue(new Error('API unavailable')), + getMediaList: jest.fn().mockRejectedValue(new Error("API unavailable")), }, }); - await expect(downloadAllAssets('test-guid-u')).rejects.toThrow('API unavailable'); + await expect(downloadAllAssets("test-guid-u")).rejects.toThrow("API unavailable"); }); }); - describe('empty assets list', () => { - it('returns without error when API returns zero assets', async () => { - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(makeMockLogger()); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + describe("empty assets list", () => { + it("returns without error when API returns zero assets", async () => { + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(makeMockLogger()); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ assetMethods: { getMediaList: jest.fn().mockResolvedValue({ totalCount: 0, assetMedias: [] }), }, }); - await expect(downloadAllAssets('test-guid-u')).resolves.toBeUndefined(); + await expect(downloadAllAssets("test-guid-u")).resolves.toBeUndefined(); }); }); }); diff --git a/src/lib/downloaders/tests/download-containers.test.ts b/src/lib/downloaders/tests/download-containers.test.ts index 722a729..4fc854b 100644 --- a/src/lib/downloaders/tests/download-containers.test.ts +++ b/src/lib/downloaders/tests/download-containers.test.ts @@ -1,14 +1,14 @@ -import { resetState, state } from 'core/state'; +import { resetState, state } from "core/state"; -jest.mock('core/fileOperations', () => ({ +jest.mock("core/fileOperations", () => ({ fileOperations: jest.fn().mockImplementation(() => ({ createFolder: jest.fn(), exportFiles: jest.fn(), - getDataFolderPath: jest.fn().mockReturnValue('/tmp/agility-mock-containers'), + getDataFolderPath: jest.fn().mockReturnValue("/tmp/agility-mock-containers"), })), })); -import { downloadAllContainers } from 'lib/downloaders/download-containers'; +import { downloadAllContainers } from "lib/downloaders/download-containers"; function makeMockLogger() { return { @@ -24,9 +24,9 @@ function makeMockLogger() { beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -35,56 +35,54 @@ afterEach(() => { // ─── downloadAllContainers guard clause ─────────────────────────────────────── -describe('downloadAllContainers', () => { - describe('guard clause: no logger for GUID', () => { - it('returns early without throwing when getLoggerForGuid returns null', async () => { - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(null); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ +describe("downloadAllContainers", () => { + describe("guard clause: no logger for GUID", () => { + it("returns early without throwing when getLoggerForGuid returns null", async () => { + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(null); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ containerMethods: { getContainerList: jest.fn() }, }); - await expect(downloadAllContainers('test-guid-u')).resolves.toBeUndefined(); + await expect(downloadAllContainers("test-guid-u")).resolves.toBeUndefined(); }); - it('logs a warning when no logger is found', async () => { - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(null); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + it("logs a warning when no logger is found", async () => { + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(null); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ containerMethods: { getContainerList: jest.fn() }, }); - await downloadAllContainers('test-guid-u'); + await downloadAllContainers("test-guid-u"); - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining('No logger found for GUID test-guid-u') - ); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("No logger found for GUID test-guid-u")); }); }); - describe('guard clause: logger present, API propagates error', () => { - it('throws when containerMethods.getContainerList rejects', async () => { - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(makeMockLogger()); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + describe("guard clause: logger present, API propagates error", () => { + it("throws when containerMethods.getContainerList rejects", async () => { + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(makeMockLogger()); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ containerMethods: { - getContainerList: jest.fn().mockRejectedValue(new Error('Container API error')), + getContainerList: jest.fn().mockRejectedValue(new Error("Container API error")), }, }); - await expect(downloadAllContainers('test-guid-u')).rejects.toThrow('Container API error'); + await expect(downloadAllContainers("test-guid-u")).rejects.toThrow("Container API error"); }); }); - describe('empty containers list', () => { - it('returns early without error when API returns empty array', async () => { + describe("empty containers list", () => { + it("returns early without error when API returns empty array", async () => { const mockLogger = makeMockLogger(); - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(mockLogger); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(mockLogger); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ containerMethods: { getContainerList: jest.fn().mockResolvedValue([]), }, }); - await expect(downloadAllContainers('test-guid-u')).resolves.toBeUndefined(); - expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('No containers found')); + await expect(downloadAllContainers("test-guid-u")).resolves.toBeUndefined(); + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining("No containers found")); }); }); }); diff --git a/src/lib/downloaders/tests/download-galleries.test.ts b/src/lib/downloaders/tests/download-galleries.test.ts index 69ca024..44784f1 100644 --- a/src/lib/downloaders/tests/download-galleries.test.ts +++ b/src/lib/downloaders/tests/download-galleries.test.ts @@ -1,19 +1,19 @@ -import { resetState, state } from 'core/state'; +import { resetState, state } from "core/state"; -jest.mock('core/fileOperations', () => ({ +jest.mock("core/fileOperations", () => ({ fileOperations: jest.fn().mockImplementation(() => ({ createFolder: jest.fn(), exportFiles: jest.fn(), readJsonFile: jest.fn().mockReturnValue(null), - getDataFolderPath: jest.fn().mockReturnValue('/tmp/agility-mock-galleries'), + getDataFolderPath: jest.fn().mockReturnValue("/tmp/agility-mock-galleries"), })), })); -jest.mock('lib/shared/get-all-channels', () => ({ - getAllChannels: jest.fn().mockResolvedValue([{ channel: 'website' }]), +jest.mock("lib/shared/get-all-channels", () => ({ + getAllChannels: jest.fn().mockResolvedValue([{ channel: "website" }]), })); -import { downloadAllGalleries } from 'lib/downloaders/download-galleries'; +import { downloadAllGalleries } from "lib/downloaders/download-galleries"; function makeMockLogger() { return { @@ -28,9 +28,9 @@ function makeMockLogger() { beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -39,64 +39,62 @@ afterEach(() => { // ─── downloadAllGalleries guard clause ──────────────────────────────────────── -describe('downloadAllGalleries', () => { - describe('guard clause: no logger for GUID', () => { - it('returns early without throwing when getLoggerForGuid returns null', async () => { - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(null); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ +describe("downloadAllGalleries", () => { + describe("guard clause: no logger for GUID", () => { + it("returns early without throwing when getLoggerForGuid returns null", async () => { + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(null); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ assetMethods: { getGalleries: jest.fn() }, }); - await expect(downloadAllGalleries('test-guid-u')).resolves.toBeUndefined(); + await expect(downloadAllGalleries("test-guid-u")).resolves.toBeUndefined(); }); - it('logs a warning when no logger is found', async () => { - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(null); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + it("logs a warning when no logger is found", async () => { + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(null); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ assetMethods: { getGalleries: jest.fn() }, }); - await downloadAllGalleries('test-guid-u'); + await downloadAllGalleries("test-guid-u"); - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining('No logger found for GUID test-guid-u') - ); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("No logger found for GUID test-guid-u")); }); }); - describe('with logger present', () => { - it('returns early without throwing when getGalleries throws (graceful inner error)', async () => { - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(makeMockLogger()); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + describe("with logger present", () => { + it("returns early without throwing when getGalleries throws (graceful inner error)", async () => { + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(makeMockLogger()); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ assetMethods: { - getGalleries: jest.fn().mockRejectedValue(new Error('Gallery API error')), + getGalleries: jest.fn().mockRejectedValue(new Error("Gallery API error")), }, }); // The function has an inner try-catch that catches the getGalleries error and returns - await expect(downloadAllGalleries('test-guid-u')).resolves.toBeUndefined(); + await expect(downloadAllGalleries("test-guid-u")).resolves.toBeUndefined(); }); - it('processes an empty gallery list without error', async () => { + it("processes an empty gallery list without error", async () => { const mockLogger = makeMockLogger(); - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(mockLogger); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(mockLogger); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ assetMethods: { getGalleries: jest.fn().mockResolvedValue({ assetMediaGroupings: [] }), }, }); - await expect(downloadAllGalleries('test-guid-u')).resolves.toBeUndefined(); - expect(mockLogger.summary).toHaveBeenCalledWith('pull', 0, 0, 0); + await expect(downloadAllGalleries("test-guid-u")).resolves.toBeUndefined(); + expect(mockLogger.summary).toHaveBeenCalledWith("pull", 0, 0, 0); }); - it('downloads a gallery that does not exist locally', async () => { - const { fileOperations } = require('core/fileOperations'); + it("downloads a gallery that does not exist locally", async () => { + const { fileOperations } = require("core/fileOperations"); const mockLogger = makeMockLogger(); - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(mockLogger); + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(mockLogger); - const mockGallery = { mediaGroupingID: 1, name: 'Gallery One', modifiedOn: '2025-01-01T00:00:00Z' }; - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + const mockGallery = { mediaGroupingID: 1, name: "Gallery One", modifiedOn: "2025-01-01T00:00:00Z" }; + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ assetMethods: { getGalleries: jest.fn().mockResolvedValue({ assetMediaGroupings: [mockGallery] }), }, @@ -105,23 +103,23 @@ describe('downloadAllGalleries', () => { createFolder: jest.fn(), exportFiles: jest.fn(), readJsonFile: jest.fn().mockReturnValue(null), // no local copy - getDataFolderPath: jest.fn().mockReturnValue('/tmp/agility-mock-galleries'), + getDataFolderPath: jest.fn().mockReturnValue("/tmp/agility-mock-galleries"), })); - await downloadAllGalleries('test-guid-u'); + await downloadAllGalleries("test-guid-u"); expect(mockLogger.gallery.downloaded).toHaveBeenCalled(); - expect(mockLogger.summary).toHaveBeenCalledWith('pull', 1, 0, 0); + expect(mockLogger.summary).toHaveBeenCalledWith("pull", 1, 0, 0); }); - it('skips a gallery that is up to date locally', async () => { - const { fileOperations } = require('core/fileOperations'); + it("skips a gallery that is up to date locally", async () => { + const { fileOperations } = require("core/fileOperations"); const mockLogger = makeMockLogger(); - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(mockLogger); + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(mockLogger); - const sameDate = '2025-01-01T00:00:00Z'; - const mockGallery = { mediaGroupingID: 2, name: 'Gallery Two', modifiedOn: sameDate }; - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + const sameDate = "2025-01-01T00:00:00Z"; + const mockGallery = { mediaGroupingID: 2, name: "Gallery Two", modifiedOn: sameDate }; + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ assetMethods: { getGalleries: jest.fn().mockResolvedValue({ assetMediaGroupings: [mockGallery] }), }, @@ -130,24 +128,24 @@ describe('downloadAllGalleries', () => { createFolder: jest.fn(), exportFiles: jest.fn(), readJsonFile: jest.fn().mockReturnValue({ mediaGroupingID: 2, modifiedOn: sameDate }), - getDataFolderPath: jest.fn().mockReturnValue('/tmp/agility-mock-galleries'), + getDataFolderPath: jest.fn().mockReturnValue("/tmp/agility-mock-galleries"), })); - await downloadAllGalleries('test-guid-u'); + await downloadAllGalleries("test-guid-u"); expect(mockLogger.gallery.skipped).toHaveBeenCalled(); - expect(mockLogger.summary).toHaveBeenCalledWith('pull', 0, 1, 0); + expect(mockLogger.summary).toHaveBeenCalledWith("pull", 0, 1, 0); }); - it('downloads a gallery when remote is newer than local', async () => { - const { fileOperations } = require('core/fileOperations'); + it("downloads a gallery when remote is newer than local", async () => { + const { fileOperations } = require("core/fileOperations"); const mockLogger = makeMockLogger(); - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(mockLogger); + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(mockLogger); - const remoteDate = '2025-06-01T00:00:00Z'; - const localDate = '2025-01-01T00:00:00Z'; - const mockGallery = { mediaGroupingID: 3, name: 'Gallery Three', modifiedOn: remoteDate }; - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + const remoteDate = "2025-06-01T00:00:00Z"; + const localDate = "2025-01-01T00:00:00Z"; + const mockGallery = { mediaGroupingID: 3, name: "Gallery Three", modifiedOn: remoteDate }; + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ assetMethods: { getGalleries: jest.fn().mockResolvedValue({ assetMediaGroupings: [mockGallery] }), }, @@ -156,13 +154,13 @@ describe('downloadAllGalleries', () => { createFolder: jest.fn(), exportFiles: jest.fn(), readJsonFile: jest.fn().mockReturnValue({ mediaGroupingID: 3, modifiedOn: localDate }), - getDataFolderPath: jest.fn().mockReturnValue('/tmp/agility-mock-galleries'), + getDataFolderPath: jest.fn().mockReturnValue("/tmp/agility-mock-galleries"), })); - await downloadAllGalleries('test-guid-u'); + await downloadAllGalleries("test-guid-u"); expect(mockLogger.gallery.downloaded).toHaveBeenCalled(); - expect(mockLogger.summary).toHaveBeenCalledWith('pull', 1, 0, 0); + expect(mockLogger.summary).toHaveBeenCalledWith("pull", 1, 0, 0); }); }); }); diff --git a/src/lib/downloaders/tests/download-models.test.ts b/src/lib/downloaders/tests/download-models.test.ts index ee03255..59931a2 100644 --- a/src/lib/downloaders/tests/download-models.test.ts +++ b/src/lib/downloaders/tests/download-models.test.ts @@ -1,18 +1,18 @@ -import { resetState, state } from 'core/state'; +import { resetState, state } from "core/state"; -jest.mock('core/fileOperations', () => ({ +jest.mock("core/fileOperations", () => ({ fileOperations: jest.fn().mockImplementation(() => ({ createFolder: jest.fn(), exportFiles: jest.fn(), - getDataFolderPath: jest.fn().mockReturnValue('/tmp/agility-mock-models'), + getDataFolderPath: jest.fn().mockReturnValue("/tmp/agility-mock-models"), })), })); -jest.mock('lib/shared/get-all-channels', () => ({ - getAllChannels: jest.fn().mockResolvedValue([{ channel: 'website' }]), +jest.mock("lib/shared/get-all-channels", () => ({ + getAllChannels: jest.fn().mockResolvedValue([{ channel: "website" }]), })); -import { downloadAllModels } from 'lib/downloaders/download-models'; +import { downloadAllModels } from "lib/downloaders/download-models"; function makeMockLogger() { return { @@ -28,9 +28,9 @@ function makeMockLogger() { beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -39,58 +39,58 @@ afterEach(() => { // ─── downloadAllModels ──────────────────────────────────────────────────────── -describe('downloadAllModels', () => { - describe('guard clause: API propagates error', () => { - it('throws when getContentModules rejects', async () => { +describe("downloadAllModels", () => { + describe("guard clause: API propagates error", () => { + it("throws when getContentModules rejects", async () => { // download-models.ts calls logger.startTimer() before the API, so provide a mock logger - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(makeMockLogger()); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(makeMockLogger()); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ modelMethods: { - getContentModules: jest.fn().mockRejectedValue(new Error('Model API error')), + getContentModules: jest.fn().mockRejectedValue(new Error("Model API error")), getPageModules: jest.fn().mockResolvedValue([]), }, }); - await expect(downloadAllModels('test-guid-u')).rejects.toThrow('Model API error'); + await expect(downloadAllModels("test-guid-u")).rejects.toThrow("Model API error"); }); - it('throws when getPageModules rejects', async () => { - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(makeMockLogger()); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + it("throws when getPageModules rejects", async () => { + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(makeMockLogger()); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ modelMethods: { getContentModules: jest.fn().mockResolvedValue([]), - getPageModules: jest.fn().mockRejectedValue(new Error('Page modules error')), + getPageModules: jest.fn().mockRejectedValue(new Error("Page modules error")), }, }); - await expect(downloadAllModels('test-guid-u')).rejects.toThrow('Page modules error'); + await expect(downloadAllModels("test-guid-u")).rejects.toThrow("Page modules error"); }); }); - describe('empty models list', () => { - it('returns without error when both model lists are empty', async () => { - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(makeMockLogger()); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + describe("empty models list", () => { + it("returns without error when both model lists are empty", async () => { + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(makeMockLogger()); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ modelMethods: { getContentModules: jest.fn().mockResolvedValue([]), getPageModules: jest.fn().mockResolvedValue([]), }, }); - await expect(downloadAllModels('test-guid-u')).resolves.toBeUndefined(); + await expect(downloadAllModels("test-guid-u")).resolves.toBeUndefined(); }); }); - describe('download flow', () => { - it('calls getContentModel for each downloadable model', async () => { + describe("download flow", () => { + it("calls getContentModel for each downloadable model", async () => { const mockLogger = makeMockLogger(); - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(mockLogger); + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(mockLogger); - const modelSummary = { id: 42, lastModifiedDate: '2025-01-01T00:00:00Z' }; - const modelDetails = { id: 42, referenceName: 'blogPost', fields: [] }; + const modelSummary = { id: 42, lastModifiedDate: "2025-01-01T00:00:00Z" }; + const modelDetails = { id: 42, referenceName: "blogPost", fields: [] }; const getContentModel = jest.fn().mockResolvedValue(modelDetails); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ modelMethods: { getContentModules: jest.fn().mockResolvedValue([modelSummary]), getPageModules: jest.fn().mockResolvedValue([]), @@ -98,19 +98,19 @@ describe('downloadAllModels', () => { }, }); - await downloadAllModels('test-guid-u'); + await downloadAllModels("test-guid-u"); - expect(getContentModel).toHaveBeenCalledWith(42, 'test-guid-u'); + expect(getContentModel).toHaveBeenCalledWith(42, "test-guid-u"); expect(mockLogger.model.downloaded).toHaveBeenCalledWith(modelDetails); }); - it('records a model error when getContentModel returns null', async () => { + it("records a model error when getContentModel returns null", async () => { const mockLogger = makeMockLogger(); - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(mockLogger); + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(mockLogger); - const modelSummary = { id: 10, lastModifiedDate: '2025-01-01T00:00:00Z' }; + const modelSummary = { id: 10, lastModifiedDate: "2025-01-01T00:00:00Z" }; - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ modelMethods: { getContentModules: jest.fn().mockResolvedValue([modelSummary]), getPageModules: jest.fn().mockResolvedValue([]), @@ -118,18 +118,18 @@ describe('downloadAllModels', () => { }, }); - await downloadAllModels('test-guid-u'); + await downloadAllModels("test-guid-u"); expect(mockLogger.model.error).toHaveBeenCalled(); }); - it('calls endTimer and summary after processing', async () => { + it("calls endTimer and summary after processing", async () => { const mockLogger = makeMockLogger(); - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(mockLogger); + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(mockLogger); - const modelSummary = { id: 5, lastModifiedDate: '2025-01-01T00:00:00Z' }; - const modelDetails = { id: 5, referenceName: 'article', fields: [] }; - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + const modelSummary = { id: 5, lastModifiedDate: "2025-01-01T00:00:00Z" }; + const modelDetails = { id: 5, referenceName: "article", fields: [] }; + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ modelMethods: { getContentModules: jest.fn().mockResolvedValue([modelSummary]), getPageModules: jest.fn().mockResolvedValue([]), @@ -137,10 +137,10 @@ describe('downloadAllModels', () => { }, }); - await downloadAllModels('test-guid-u'); + await downloadAllModels("test-guid-u"); expect(mockLogger.endTimer).toHaveBeenCalled(); - expect(mockLogger.summary).toHaveBeenCalledWith('pull', 1, 0, 0); + expect(mockLogger.summary).toHaveBeenCalledWith("pull", 1, 0, 0); }); }); }); diff --git a/src/lib/downloaders/tests/download-operations-config.test.ts b/src/lib/downloaders/tests/download-operations-config.test.ts index 7802067..1b439ad 100644 --- a/src/lib/downloaders/tests/download-operations-config.test.ts +++ b/src/lib/downloaders/tests/download-operations-config.test.ts @@ -1,15 +1,15 @@ -import { resetState, setState } from 'core/state'; +import { resetState, setState } from "core/state"; import { DOWNLOAD_OPERATIONS, DownloadOperationsRegistry, OperationConfig, -} from 'lib/downloaders/download-operations-config'; +} from "lib/downloaders/download-operations-config"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -18,31 +18,31 @@ afterEach(() => { // ─── DOWNLOAD_OPERATIONS constant ───────────────────────────────────────────── -describe('DOWNLOAD_OPERATIONS', () => { - it('defines entries for all expected operation keys', () => { - const expectedKeys = ['syncSDK', 'galleries', 'assets', 'models', 'templates', 'containers', 'sitemaps']; +describe("DOWNLOAD_OPERATIONS", () => { + it("defines entries for all expected operation keys", () => { + const expectedKeys = ["syncSDK", "galleries", "assets", "models", "templates", "containers", "sitemaps"]; for (const key of expectedKeys) { expect(DOWNLOAD_OPERATIONS).toHaveProperty(key); } }); - it('each operation has a name, description, handler, and elements array', () => { + it("each operation has a name, description, handler, and elements array", () => { for (const [key, op] of Object.entries(DOWNLOAD_OPERATIONS)) { - expect(typeof op.name).toBe('string'); - expect(typeof op.description).toBe('string'); - expect(typeof op.handler).toBe('function'); + expect(typeof op.name).toBe("string"); + expect(typeof op.description).toBe("string"); + expect(typeof op.handler).toBe("function"); expect(Array.isArray(op.elements)).toBe(true); expect(op.elements.length).toBeGreaterThan(0); } }); - it('each operation handler is a function that accepts a guid string', () => { + it("each operation handler is a function that accepts a guid string", () => { for (const op of Object.values(DOWNLOAD_OPERATIONS)) { expect(op.handler.length).toBeGreaterThanOrEqual(1); } }); - it('optional dependencies field is an array when present', () => { + it("optional dependencies field is an array when present", () => { for (const op of Object.values(DOWNLOAD_OPERATIONS)) { if (op.dependencies !== undefined) { expect(Array.isArray(op.dependencies)).toBe(true); @@ -50,72 +50,72 @@ describe('DOWNLOAD_OPERATIONS', () => { } }); - describe('syncSDK operation', () => { - it('has Content and Sitemaps in elements', () => { - expect(DOWNLOAD_OPERATIONS.syncSDK.elements).toContain('Content'); - expect(DOWNLOAD_OPERATIONS.syncSDK.elements).toContain('Sitemaps'); + describe("syncSDK operation", () => { + it("has Content and Sitemaps in elements", () => { + expect(DOWNLOAD_OPERATIONS.syncSDK.elements).toContain("Content"); + expect(DOWNLOAD_OPERATIONS.syncSDK.elements).toContain("Sitemaps"); }); - it('has Models and Containers in its dependencies', () => { - expect(DOWNLOAD_OPERATIONS.syncSDK.dependencies).toContain('Models'); - expect(DOWNLOAD_OPERATIONS.syncSDK.dependencies).toContain('Containers'); + it("has Models and Containers in its dependencies", () => { + expect(DOWNLOAD_OPERATIONS.syncSDK.dependencies).toContain("Models"); + expect(DOWNLOAD_OPERATIONS.syncSDK.dependencies).toContain("Containers"); }); }); - describe('containers operation', () => { - it('has Models as a dependency', () => { - expect(DOWNLOAD_OPERATIONS.containers.dependencies).toContain('Models'); + describe("containers operation", () => { + it("has Models as a dependency", () => { + expect(DOWNLOAD_OPERATIONS.containers.dependencies).toContain("Models"); }); }); - describe('assets operation', () => { - it('has Galleries as a dependency', () => { - expect(DOWNLOAD_OPERATIONS.assets.dependencies).toContain('Galleries'); + describe("assets operation", () => { + it("has Galleries as a dependency", () => { + expect(DOWNLOAD_OPERATIONS.assets.dependencies).toContain("Galleries"); }); }); }); // ─── DownloadOperationsRegistry.getOperationsForElements ────────────────────── -describe('DownloadOperationsRegistry.getOperationsForElements', () => { - it('returns all operations when fromPush is true', () => { +describe("DownloadOperationsRegistry.getOperationsForElements", () => { + it("returns all operations when fromPush is true", () => { const ops = DownloadOperationsRegistry.getOperationsForElements(true); expect(ops.length).toBe(Object.keys(DOWNLOAD_OPERATIONS).length); }); - it('returns only operations matching state.elements when fromPush is false', () => { - setState({ elements: 'Models' }); + it("returns only operations matching state.elements when fromPush is false", () => { + setState({ elements: "Models" }); const ops = DownloadOperationsRegistry.getOperationsForElements(false); const names = ops.map((o: OperationConfig) => o.name); - expect(names).toContain('downloadAllModels'); + expect(names).toContain("downloadAllModels"); }); - it('auto-includes dependency operations when fromPush is false', () => { + it("auto-includes dependency operations when fromPush is false", () => { // Requesting Content triggers Models and Containers auto-inclusion - setState({ elements: 'Content' }); + setState({ elements: "Content" }); const ops = DownloadOperationsRegistry.getOperationsForElements(false); const names = ops.map((o: OperationConfig) => o.name); - expect(names).toContain('downloadAllModels'); - expect(names).toContain('downloadAllContainers'); + expect(names).toContain("downloadAllModels"); + expect(names).toContain("downloadAllContainers"); }); - it('returns all operations when no state.elements is set (full default list)', () => { + it("returns all operations when no state.elements is set (full default list)", () => { // resetState sets elements to full default string const ops = DownloadOperationsRegistry.getOperationsForElements(false); expect(ops.length).toBeGreaterThan(0); }); - it('each returned operation conforms to the OperationConfig shape', () => { + it("each returned operation conforms to the OperationConfig shape", () => { const ops = DownloadOperationsRegistry.getOperationsForElements(false); for (const op of ops) { - expect(typeof op.name).toBe('string'); - expect(typeof op.handler).toBe('function'); + expect(typeof op.name).toBe("string"); + expect(typeof op.handler).toBe("function"); expect(Array.isArray(op.elements)).toBe(true); } }); - it('returns empty array when state.elements requests only unknown elements', () => { - setState({ elements: 'NonExistentElement' }); + it("returns empty array when state.elements requests only unknown elements", () => { + setState({ elements: "NonExistentElement" }); const ops = DownloadOperationsRegistry.getOperationsForElements(false); expect(ops).toHaveLength(0); }); @@ -123,19 +123,19 @@ describe('DownloadOperationsRegistry.getOperationsForElements', () => { // ─── DownloadOperationsRegistry dependency resolution (private via public API) ─ -describe('DownloadOperationsRegistry dependency resolution', () => { - it('does not duplicate operations when element already has its dependency in elements list', () => { +describe("DownloadOperationsRegistry dependency resolution", () => { + it("does not duplicate operations when element already has its dependency in elements list", () => { // Both Content and Models are listed — Models should appear only once - setState({ elements: 'Content,Models' }); + setState({ elements: "Content,Models" }); const ops = DownloadOperationsRegistry.getOperationsForElements(false); - const modelOps = ops.filter((o: OperationConfig) => o.name === 'downloadAllModels'); + const modelOps = ops.filter((o: OperationConfig) => o.name === "downloadAllModels"); expect(modelOps.length).toBe(1); }); - it('resolves multiple levels of dependencies (Assets → Galleries)', () => { - setState({ elements: 'Assets' }); + it("resolves multiple levels of dependencies (Assets → Galleries)", () => { + setState({ elements: "Assets" }); const ops = DownloadOperationsRegistry.getOperationsForElements(false); const names = ops.map((o: OperationConfig) => o.name); - expect(names).toContain('downloadAllGalleries'); + expect(names).toContain("downloadAllGalleries"); }); }); diff --git a/src/lib/downloaders/tests/download-sitemaps.test.ts b/src/lib/downloaders/tests/download-sitemaps.test.ts index 85416c5..d7da54e 100644 --- a/src/lib/downloaders/tests/download-sitemaps.test.ts +++ b/src/lib/downloaders/tests/download-sitemaps.test.ts @@ -1,26 +1,26 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, state } from 'core/state'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, state } from "core/state"; -jest.mock('core/fileOperations', () => ({ +jest.mock("core/fileOperations", () => ({ fileOperations: jest.fn().mockImplementation(() => ({ createFolder: jest.fn(), exportFiles: jest.fn(), - getDataFolderPath: jest.fn().mockReturnValue('/tmp/agility-mock-sitemaps/sitemap.json'), + getDataFolderPath: jest.fn().mockReturnValue("/tmp/agility-mock-sitemaps/sitemap.json"), })), })); -jest.mock('lib/shared/get-all-channels', () => ({ - getAllChannels: jest.fn().mockResolvedValue([{ channel: 'website' }]), +jest.mock("lib/shared/get-all-channels", () => ({ + getAllChannels: jest.fn().mockResolvedValue([{ channel: "website" }]), })); -import { downloadAllSitemaps } from 'lib/downloaders/download-sitemaps'; +import { downloadAllSitemaps } from "lib/downloaders/download-sitemaps"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-test-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-test-")); }); afterAll(() => { @@ -40,9 +40,9 @@ function makeMockLogger() { beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -51,84 +51,82 @@ afterEach(() => { // ─── downloadAllSitemaps guard clause ───────────────────────────────────────── -describe('downloadAllSitemaps', () => { - describe('guard clause: no logger for GUID', () => { - it('returns early without throwing when getLoggerForGuid returns null', async () => { - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(null); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ +describe("downloadAllSitemaps", () => { + describe("guard clause: no logger for GUID", () => { + it("returns early without throwing when getLoggerForGuid returns null", async () => { + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(null); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ pageMethods: { getSitemap: jest.fn() }, }); - state.guidLocaleMap.set('test-guid-u', ['en-us']); + state.guidLocaleMap.set("test-guid-u", ["en-us"]); - await expect(downloadAllSitemaps('test-guid-u')).resolves.toBeUndefined(); + await expect(downloadAllSitemaps("test-guid-u")).resolves.toBeUndefined(); }); - it('logs a warning when no logger is found', async () => { - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(null); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + it("logs a warning when no logger is found", async () => { + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(null); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ pageMethods: { getSitemap: jest.fn() }, }); - state.guidLocaleMap.set('test-guid-u', ['en-us']); + state.guidLocaleMap.set("test-guid-u", ["en-us"]); - await downloadAllSitemaps('test-guid-u'); + await downloadAllSitemaps("test-guid-u"); - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining('No logger found for GUID test-guid-u') - ); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("No logger found for GUID test-guid-u")); }); }); - describe('guard clause: API error propagates', () => { - it('throws when getSitemap rejects', async () => { - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(makeMockLogger()); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + describe("guard clause: API error propagates", () => { + it("throws when getSitemap rejects", async () => { + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(makeMockLogger()); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ pageMethods: { - getSitemap: jest.fn().mockRejectedValue(new Error('Sitemap API error')), + getSitemap: jest.fn().mockRejectedValue(new Error("Sitemap API error")), }, }); - state.guidLocaleMap.set('test-guid-u', ['en-us']); + state.guidLocaleMap.set("test-guid-u", ["en-us"]); - await expect(downloadAllSitemaps('test-guid-u')).rejects.toThrow('Sitemap API error'); + await expect(downloadAllSitemaps("test-guid-u")).rejects.toThrow("Sitemap API error"); }); }); - describe('empty sitemap', () => { - it('returns without error and calls sitemap.skipped when getSitemap returns null', async () => { + describe("empty sitemap", () => { + it("returns without error and calls sitemap.skipped when getSitemap returns null", async () => { const mockLogger = makeMockLogger(); - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(mockLogger); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(mockLogger); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ pageMethods: { getSitemap: jest.fn().mockResolvedValue(null), }, }); - state.guidLocaleMap.set('test-guid-u', ['en-us']); + state.guidLocaleMap.set("test-guid-u", ["en-us"]); - await expect(downloadAllSitemaps('test-guid-u')).resolves.toBeUndefined(); + await expect(downloadAllSitemaps("test-guid-u")).resolves.toBeUndefined(); expect(mockLogger.sitemap.skipped).toHaveBeenCalled(); }); - it('returns without error and calls sitemap.skipped when getSitemap returns an empty array', async () => { + it("returns without error and calls sitemap.skipped when getSitemap returns an empty array", async () => { const mockLogger = makeMockLogger(); - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(mockLogger); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(mockLogger); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ pageMethods: { getSitemap: jest.fn().mockResolvedValue([]), }, }); - state.guidLocaleMap.set('test-guid-u', ['en-us']); + state.guidLocaleMap.set("test-guid-u", ["en-us"]); - await expect(downloadAllSitemaps('test-guid-u')).resolves.toBeUndefined(); + await expect(downloadAllSitemaps("test-guid-u")).resolves.toBeUndefined(); expect(mockLogger.sitemap.skipped).toHaveBeenCalled(); }); }); - describe('download decision: no local file', () => { - it('calls sitemap.downloaded when no local file exists', async () => { + describe("download decision: no local file", () => { + it("calls sitemap.downloaded when no local file exists", async () => { const mockLogger = makeMockLogger(); - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(mockLogger); + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(mockLogger); - const sitemapFile = path.join(tmpDir, 'sitemap-new.json'); - const { fileOperations } = require('core/fileOperations'); + const sitemapFile = path.join(tmpDir, "sitemap-new.json"); + const { fileOperations } = require("core/fileOperations"); fileOperations.mockImplementation(() => ({ createFolder: jest.fn(), exportFiles: jest.fn(), @@ -136,50 +134,50 @@ describe('downloadAllSitemaps', () => { getDataFolderPath: jest.fn().mockReturnValue(sitemapFile), })); - const mockSitemap = [{ lastModified: '2025-01-01', name: 'website' }]; - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + const mockSitemap = [{ lastModified: "2025-01-01", name: "website" }]; + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ pageMethods: { getSitemap: jest.fn().mockResolvedValue(mockSitemap), }, }); - state.guidLocaleMap.set('test-guid-u', ['en-us']); + state.guidLocaleMap.set("test-guid-u", ["en-us"]); - await downloadAllSitemaps('test-guid-u'); + await downloadAllSitemaps("test-guid-u"); expect(mockLogger.sitemap.downloaded).toHaveBeenCalled(); - expect(mockLogger.summary).toHaveBeenCalledWith('pull', 1, 0, 0); + expect(mockLogger.summary).toHaveBeenCalledWith("pull", 1, 0, 0); }); }); - describe('download decision: local file is up to date', () => { - it('calls sitemap.skipped when local file has same lastModified as remote', async () => { + describe("download decision: local file is up to date", () => { + it("calls sitemap.skipped when local file has same lastModified as remote", async () => { const mockLogger = makeMockLogger(); - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(mockLogger); + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(mockLogger); - const sameDate = '2025-01-01T00:00:00Z'; - const sitemapFile = path.join(tmpDir, 'sitemap-uptodate.json'); + const sameDate = "2025-01-01T00:00:00Z"; + const sitemapFile = path.join(tmpDir, "sitemap-uptodate.json"); // Write a local sitemap file with the same date fs.writeFileSync(sitemapFile, JSON.stringify({ lastModified: sameDate })); - const { fileOperations } = require('core/fileOperations'); + const { fileOperations } = require("core/fileOperations"); fileOperations.mockImplementation(() => ({ createFolder: jest.fn(), exportFiles: jest.fn(), getDataFolderPath: jest.fn().mockReturnValue(sitemapFile), })); - const mockSitemap = [{ lastModified: sameDate, name: 'website' }]; - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + const mockSitemap = [{ lastModified: sameDate, name: "website" }]; + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ pageMethods: { getSitemap: jest.fn().mockResolvedValue(mockSitemap), }, }); - state.guidLocaleMap.set('test-guid-u', ['en-us']); + state.guidLocaleMap.set("test-guid-u", ["en-us"]); - await downloadAllSitemaps('test-guid-u'); + await downloadAllSitemaps("test-guid-u"); expect(mockLogger.sitemap.skipped).toHaveBeenCalled(); - expect(mockLogger.summary).toHaveBeenCalledWith('pull', 0, 1, 0); + expect(mockLogger.summary).toHaveBeenCalledWith("pull", 0, 1, 0); }); }); }); diff --git a/src/lib/downloaders/tests/download-sync-sdk.test.ts b/src/lib/downloaders/tests/download-sync-sdk.test.ts index 1c2cbd6..766db08 100644 --- a/src/lib/downloaders/tests/download-sync-sdk.test.ts +++ b/src/lib/downloaders/tests/download-sync-sdk.test.ts @@ -1,58 +1,62 @@ -import { resetState, setState, state, getApiKeysForGuid, getLoggerForGuid } from 'core/state'; +import { resetState, setState, state, getApiKeysForGuid, getLoggerForGuid } from "core/state"; // Mock only the functions that hit the network or keychain -jest.mock('lib/shared/get-all-channels', () => ({ - getAllChannels: jest.fn().mockResolvedValue([{ channel: 'website' }]), +jest.mock("lib/shared/get-all-channels", () => ({ + getAllChannels: jest.fn().mockResolvedValue([{ channel: "website" }]), })); -jest.mock('lib/downloaders/sync-token-handler', () => ({ +jest.mock("lib/downloaders/sync-token-handler", () => ({ handleSyncToken: jest.fn().mockResolvedValue(false), })); -jest.mock('core/auth', () => ({ +jest.mock("core/auth", () => ({ Auth: jest.fn().mockImplementation(() => ({ - determineFetchUrl: jest.fn().mockReturnValue('https://api.aglty.io'), + determineFetchUrl: jest.fn().mockReturnValue("https://api.aglty.io"), })), })); -jest.mock('@agility/content-sync', () => ({ +jest.mock("@agility/content-sync", () => ({ getSyncClient: jest.fn().mockReturnValue({ runSync: jest.fn().mockResolvedValue(undefined), }), })); // Mock the store-interface-filesystem (CJS require'd inside source) -jest.mock('lib/downloaders/store-interface-filesystem', () => ({ - initializeProgress: jest.fn(), - getAndClearSavedItemStats: jest.fn().mockReturnValue({ - summary: { totalItems: 0 }, - itemsByType: {}, - recentActivity: [], +jest.mock( + "lib/downloaders/store-interface-filesystem", + () => ({ + initializeProgress: jest.fn(), + getAndClearSavedItemStats: jest.fn().mockReturnValue({ + summary: { totalItems: 0 }, + itemsByType: {}, + recentActivity: [], + }), }), -}), { virtual: true }); + { virtual: true } +); -jest.mock('core/fileOperations', () => ({ +jest.mock("core/fileOperations", () => ({ fileOperations: jest.fn().mockImplementation(() => ({ createFolder: jest.fn(), exportFiles: jest.fn(), - getDataFolderPath: jest.fn().mockReturnValue('/tmp/agility-mock-sync'), - getDataFilePath: jest.fn().mockReturnValue('/tmp/agility-mock-sync/state/sync.json'), + getDataFolderPath: jest.fn().mockReturnValue("/tmp/agility-mock-sync"), + getDataFilePath: jest.fn().mockReturnValue("/tmp/agility-mock-sync/state/sync.json"), })), })); // Spy on getApiKeysForGuid and getLoggerForGuid from actual state module -jest.spyOn(require('core/state'), 'getApiKeysForGuid').mockReturnValue({ - previewKey: 'mock-preview-key', - fetchKey: 'mock-fetch-key', +jest.spyOn(require("core/state"), "getApiKeysForGuid").mockReturnValue({ + previewKey: "mock-preview-key", + fetchKey: "mock-fetch-key", }); -import { downloadAllSyncSDK, downloadSyncSDKByLocaleAndChannel } from 'lib/downloaders/download-sync-sdk'; +import { downloadAllSyncSDK, downloadSyncSDKByLocaleAndChannel } from "lib/downloaders/download-sync-sdk"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -61,109 +65,96 @@ afterEach(() => { // ─── downloadSyncSDKByLocaleAndChannel ──────────────────────────────────────── -describe('downloadSyncSDKByLocaleAndChannel', () => { +describe("downloadSyncSDKByLocaleAndChannel", () => { beforeEach(() => { - state.guidLocaleMap.set('test-guid-u', ['en-us']); + state.guidLocaleMap.set("test-guid-u", ["en-us"]); // Re-apply API key mock after restoreAllMocks - jest.spyOn(require('core/state'), 'getApiKeysForGuid').mockReturnValue({ - previewKey: 'mock-preview-key', - fetchKey: 'mock-fetch-key', + jest.spyOn(require("core/state"), "getApiKeysForGuid").mockReturnValue({ + previewKey: "mock-preview-key", + fetchKey: "mock-fetch-key", }); }); - it('completes without throwing given valid guid, channel, and locale', async () => { - await expect( - downloadSyncSDKByLocaleAndChannel('test-guid-u', 'website', 'en-us') - ).resolves.not.toThrow(); + it("completes without throwing given valid guid, channel, and locale", async () => { + await expect(downloadSyncSDKByLocaleAndChannel("test-guid-u", "website", "en-us")).resolves.not.toThrow(); }); - it('calls getSyncClient with the expected guid', async () => { - const agilitySync = require('@agility/content-sync'); + it("calls getSyncClient with the expected guid", async () => { + const agilitySync = require("@agility/content-sync"); agilitySync.getSyncClient.mockClear(); - await downloadSyncSDKByLocaleAndChannel('test-guid-u', 'website', 'en-us'); + await downloadSyncSDKByLocaleAndChannel("test-guid-u", "website", "en-us"); - expect(agilitySync.getSyncClient).toHaveBeenCalledWith( - expect.objectContaining({ guid: 'test-guid-u' }) - ); + expect(agilitySync.getSyncClient).toHaveBeenCalledWith(expect.objectContaining({ guid: "test-guid-u" })); }); - it('calls syncClient.runSync()', async () => { - const agilitySync = require('@agility/content-sync'); + it("calls syncClient.runSync()", async () => { + const agilitySync = require("@agility/content-sync"); const mockRunSync = jest.fn().mockResolvedValue(undefined); agilitySync.getSyncClient.mockReturnValue({ runSync: mockRunSync }); - await downloadSyncSDKByLocaleAndChannel('test-guid-u', 'website', 'en-us'); + await downloadSyncSDKByLocaleAndChannel("test-guid-u", "website", "en-us"); expect(mockRunSync).toHaveBeenCalledTimes(1); }); - it('passes isPreview=true in the agilityConfig', async () => { - const agilitySync = require('@agility/content-sync'); + it("passes isPreview=true in the agilityConfig", async () => { + const agilitySync = require("@agility/content-sync"); agilitySync.getSyncClient.mockClear(); - await downloadSyncSDKByLocaleAndChannel('test-guid-u', 'website', 'en-us'); + await downloadSyncSDKByLocaleAndChannel("test-guid-u", "website", "en-us"); - expect(agilitySync.getSyncClient).toHaveBeenCalledWith( - expect.objectContaining({ isPreview: true }) - ); + expect(agilitySync.getSyncClient).toHaveBeenCalledWith(expect.objectContaining({ isPreview: true })); }); - it('configures the store with an interface and options', async () => { - const agilitySync = require('@agility/content-sync'); + it("configures the store with an interface and options", async () => { + const agilitySync = require("@agility/content-sync"); agilitySync.getSyncClient.mockClear(); - await downloadSyncSDKByLocaleAndChannel('test-guid-u', 'website', 'en-us'); + await downloadSyncSDKByLocaleAndChannel("test-guid-u", "website", "en-us"); - const calledConfig = agilitySync.getSyncClient.mock.calls[ - agilitySync.getSyncClient.mock.calls.length - 1 - ][0]; - expect(calledConfig).toHaveProperty('store'); - expect(calledConfig.store).toHaveProperty('interface'); - expect(calledConfig.store).toHaveProperty('options'); + const calledConfig = agilitySync.getSyncClient.mock.calls[agilitySync.getSyncClient.mock.calls.length - 1][0]; + expect(calledConfig).toHaveProperty("store"); + expect(calledConfig.store).toHaveProperty("interface"); + expect(calledConfig.store).toHaveProperty("options"); }); - it('propagates errors from runSync', async () => { - const agilitySync = require('@agility/content-sync'); + it("propagates errors from runSync", async () => { + const agilitySync = require("@agility/content-sync"); agilitySync.getSyncClient.mockReturnValue({ - runSync: jest.fn().mockRejectedValue(new Error('sync failed')), + runSync: jest.fn().mockRejectedValue(new Error("sync failed")), }); - await expect( - downloadSyncSDKByLocaleAndChannel('test-guid-u', 'website', 'en-us') - ).rejects.toThrow('sync failed'); + await expect(downloadSyncSDKByLocaleAndChannel("test-guid-u", "website", "en-us")).rejects.toThrow("sync failed"); }); }); // ─── downloadAllSyncSDK ─────────────────────────────────────────────────────── -describe('downloadAllSyncSDK', () => { +describe("downloadAllSyncSDK", () => { beforeEach(() => { - jest.spyOn(require('core/state'), 'getApiKeysForGuid').mockReturnValue({ - previewKey: 'mock-preview-key', - fetchKey: 'mock-fetch-key', + jest.spyOn(require("core/state"), "getApiKeysForGuid").mockReturnValue({ + previewKey: "mock-preview-key", + fetchKey: "mock-fetch-key", }); }); - it('completes without throwing when guidLocaleMap has entries', async () => { - state.guidLocaleMap.set('test-guid-u', ['en-us']); + it("completes without throwing when guidLocaleMap has entries", async () => { + state.guidLocaleMap.set("test-guid-u", ["en-us"]); - await expect(downloadAllSyncSDK('test-guid-u')).resolves.not.toThrow(); + await expect(downloadAllSyncSDK("test-guid-u")).resolves.not.toThrow(); }); - it('launches one download per channel×locale combination', async () => { - const { getAllChannels } = require('lib/shared/get-all-channels'); - getAllChannels.mockResolvedValue([ - { channel: 'website' }, - { channel: 'mobile' }, - ]); - state.guidLocaleMap.set('test-guid-u', ['en-us', 'fr-fr']); + it("launches one download per channel×locale combination", async () => { + const { getAllChannels } = require("lib/shared/get-all-channels"); + getAllChannels.mockResolvedValue([{ channel: "website" }, { channel: "mobile" }]); + state.guidLocaleMap.set("test-guid-u", ["en-us", "fr-fr"]); - const agilitySync = require('@agility/content-sync'); + const agilitySync = require("@agility/content-sync"); const mockRunSync = jest.fn().mockResolvedValue(undefined); agilitySync.getSyncClient.mockReturnValue({ runSync: mockRunSync }); - await downloadAllSyncSDK('test-guid-u'); + await downloadAllSyncSDK("test-guid-u"); // 2 channels × 2 locales = 4 runSync calls expect(mockRunSync).toHaveBeenCalledTimes(4); diff --git a/src/lib/downloaders/tests/download-templates.test.ts b/src/lib/downloaders/tests/download-templates.test.ts index eb9550d..4dbbee2 100644 --- a/src/lib/downloaders/tests/download-templates.test.ts +++ b/src/lib/downloaders/tests/download-templates.test.ts @@ -1,14 +1,14 @@ -import { resetState, state } from 'core/state'; +import { resetState, state } from "core/state"; -jest.mock('core/fileOperations', () => ({ +jest.mock("core/fileOperations", () => ({ fileOperations: jest.fn().mockImplementation(() => ({ createFolder: jest.fn(), exportFiles: jest.fn(), - getDataFolderPath: jest.fn().mockReturnValue('/tmp/agility-mock-templates'), + getDataFolderPath: jest.fn().mockReturnValue("/tmp/agility-mock-templates"), })), })); -import { downloadAllTemplates } from 'lib/downloaders/download-templates'; +import { downloadAllTemplates } from "lib/downloaders/download-templates"; function makeMockLogger() { return { @@ -23,9 +23,9 @@ function makeMockLogger() { beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -34,104 +34,104 @@ afterEach(() => { // ─── downloadAllTemplates ───────────────────────────────────────────────────── -describe('downloadAllTemplates', () => { - describe('guard clause: API error propagates', () => { - it('throws when getPageTemplates rejects', async () => { - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(makeMockLogger()); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ +describe("downloadAllTemplates", () => { + describe("guard clause: API error propagates", () => { + it("throws when getPageTemplates rejects", async () => { + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(makeMockLogger()); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ pageMethods: { - getPageTemplates: jest.fn().mockRejectedValue(new Error('Templates API error')), + getPageTemplates: jest.fn().mockRejectedValue(new Error("Templates API error")), }, }); - state.guidLocaleMap.set('test-guid-u', ['en-us']); + state.guidLocaleMap.set("test-guid-u", ["en-us"]); - await expect(downloadAllTemplates('test-guid-u')).rejects.toThrow('Templates API error'); + await expect(downloadAllTemplates("test-guid-u")).rejects.toThrow("Templates API error"); }); }); - describe('empty templates list', () => { - it('calls template.skipped and returns when API returns empty array', async () => { + describe("empty templates list", () => { + it("calls template.skipped and returns when API returns empty array", async () => { const mockLogger = makeMockLogger(); - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(mockLogger); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(mockLogger); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ pageMethods: { getPageTemplates: jest.fn().mockResolvedValue([]), }, }); - state.guidLocaleMap.set('test-guid-u', ['en-us']); + state.guidLocaleMap.set("test-guid-u", ["en-us"]); - await expect(downloadAllTemplates('test-guid-u')).resolves.toBeUndefined(); + await expect(downloadAllTemplates("test-guid-u")).resolves.toBeUndefined(); expect(mockLogger.template.skipped).toHaveBeenCalledWith( null, - expect.stringContaining('No page templates found') + expect.stringContaining("No page templates found") ); }); }); - describe('download flow', () => { - it('exports each template and calls template.downloaded', async () => { - const { fileOperations } = require('core/fileOperations'); + describe("download flow", () => { + it("exports each template and calls template.downloaded", async () => { + const { fileOperations } = require("core/fileOperations"); const mockLogger = makeMockLogger(); - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(mockLogger); + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(mockLogger); const mockExportFiles = jest.fn(); fileOperations.mockImplementation(() => ({ createFolder: jest.fn(), exportFiles: mockExportFiles, - getDataFolderPath: jest.fn().mockReturnValue('/tmp/agility-mock-templates'), + getDataFolderPath: jest.fn().mockReturnValue("/tmp/agility-mock-templates"), })); const templates = [ - { pageTemplateID: 1, name: 'Default' }, - { pageTemplateID: 2, name: 'Landing' }, + { pageTemplateID: 1, name: "Default" }, + { pageTemplateID: 2, name: "Landing" }, ]; - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ pageMethods: { getPageTemplates: jest.fn().mockResolvedValue(templates), }, }); - state.guidLocaleMap.set('test-guid-u', ['en-us']); + state.guidLocaleMap.set("test-guid-u", ["en-us"]); - await downloadAllTemplates('test-guid-u'); + await downloadAllTemplates("test-guid-u"); expect(mockExportFiles).toHaveBeenCalledTimes(2); expect(mockLogger.template.downloaded).toHaveBeenCalledTimes(2); }); - it('calls endTimer and summary after processing templates', async () => { + it("calls endTimer and summary after processing templates", async () => { const mockLogger = makeMockLogger(); - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(mockLogger); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(mockLogger); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ pageMethods: { - getPageTemplates: jest.fn().mockResolvedValue([{ pageTemplateID: 99, name: 'Test' }]), + getPageTemplates: jest.fn().mockResolvedValue([{ pageTemplateID: 99, name: "Test" }]), }, }); - state.guidLocaleMap.set('test-guid-u', ['en-us']); + state.guidLocaleMap.set("test-guid-u", ["en-us"]); - await downloadAllTemplates('test-guid-u'); + await downloadAllTemplates("test-guid-u"); expect(mockLogger.endTimer).toHaveBeenCalled(); - expect(mockLogger.summary).toHaveBeenCalledWith('pull', 1, 0, 0); + expect(mockLogger.summary).toHaveBeenCalledWith("pull", 1, 0, 0); }); it.each([ - { count: 1, label: 'one template' }, - { count: 3, label: 'three templates' }, - ])('calls template.downloaded $count time(s) for $label', async ({ count }) => { + { count: 1, label: "one template" }, + { count: 3, label: "three templates" }, + ])("calls template.downloaded $count time(s) for $label", async ({ count }) => { const mockLogger = makeMockLogger(); - jest.spyOn(require('core/state'), 'getLoggerForGuid').mockReturnValue(mockLogger); + jest.spyOn(require("core/state"), "getLoggerForGuid").mockReturnValue(mockLogger); const templates = Array.from({ length: count }, (_, i) => ({ pageTemplateID: i + 1, name: `Template ${i + 1}`, })); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ pageMethods: { getPageTemplates: jest.fn().mockResolvedValue(templates), }, }); - state.guidLocaleMap.set('test-guid-u', ['en-us']); + state.guidLocaleMap.set("test-guid-u", ["en-us"]); - await downloadAllTemplates('test-guid-u'); + await downloadAllTemplates("test-guid-u"); expect(mockLogger.template.downloaded).toHaveBeenCalledTimes(count); }); diff --git a/src/lib/downloaders/tests/orchestrate-downloaders.test.ts b/src/lib/downloaders/tests/orchestrate-downloaders.test.ts index 7d905d1..b1507da 100644 --- a/src/lib/downloaders/tests/orchestrate-downloaders.test.ts +++ b/src/lib/downloaders/tests/orchestrate-downloaders.test.ts @@ -1,8 +1,8 @@ -import { resetState, setState, state } from 'core/state'; -import { Downloader, DownloadResults, DownloaderConfig } from 'lib/downloaders/orchestrate-downloaders'; +import { resetState, setState, state } from "core/state"; +import { Downloader, DownloadResults, DownloaderConfig } from "lib/downloaders/orchestrate-downloaders"; // Mock the operations registry to prevent real API calls -jest.mock('lib/downloaders/download-operations-config', () => ({ +jest.mock("lib/downloaders/download-operations-config", () => ({ DownloadOperationsRegistry: { getOperationsForElements: jest.fn().mockReturnValue([]), }, @@ -10,21 +10,21 @@ jest.mock('lib/downloaders/download-operations-config', () => ({ beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); // Mock logger functions that are called inside guidDownloader - jest.spyOn(require('core/state'), 'initializeGuidLogger').mockReturnValue({ + jest.spyOn(require("core/state"), "initializeGuidLogger").mockReturnValue({ logOperationHeader: jest.fn(), info: jest.fn(), error: jest.fn(), endTimer: jest.fn(), }); - jest.spyOn(require('core/state'), 'finalizeGuidLogger').mockReturnValue(null); + jest.spyOn(require("core/state"), "finalizeGuidLogger").mockReturnValue(null); // Reset mock to return no operations by default - const { DownloadOperationsRegistry } = require('lib/downloaders/download-operations-config'); + const { DownloadOperationsRegistry } = require("lib/downloaders/download-operations-config"); DownloadOperationsRegistry.getOperationsForElements.mockReturnValue([]); }); @@ -34,16 +34,16 @@ afterEach(() => { // ─── Downloader constructor ──────────────────────────────────────────────────── -describe('Downloader constructor', () => { - it('constructs without throwing when no config is supplied', () => { +describe("Downloader constructor", () => { + it("constructs without throwing when no config is supplied", () => { expect(() => new Downloader()).not.toThrow(); }); - it('constructs without throwing when an empty config object is supplied', () => { + it("constructs without throwing when an empty config object is supplied", () => { expect(() => new Downloader({})).not.toThrow(); }); - it('constructs without throwing when callbacks are provided', () => { + it("constructs without throwing when callbacks are provided", () => { const config: DownloaderConfig = { onOperationStart: jest.fn(), onOperationComplete: jest.fn(), @@ -54,8 +54,8 @@ describe('Downloader constructor', () => { // ─── Downloader.reset ───────────────────────────────────────────────────────── -describe('Downloader.reset', () => { - it('does not throw', () => { +describe("Downloader.reset", () => { + it("does not throw", () => { const downloader = new Downloader(); expect(() => downloader.reset()).not.toThrow(); }); @@ -63,13 +63,13 @@ describe('Downloader.reset', () => { // ─── Downloader.updateConfig ────────────────────────────────────────────────── -describe('Downloader.updateConfig', () => { - it('accepts a partial config without throwing', () => { +describe("Downloader.updateConfig", () => { + it("accepts a partial config without throwing", () => { const downloader = new Downloader(); expect(() => downloader.updateConfig({ onOperationStart: jest.fn() })).not.toThrow(); }); - it('accepts an empty object without throwing', () => { + it("accepts an empty object without throwing", () => { const downloader = new Downloader(); expect(() => downloader.updateConfig({})).not.toThrow(); }); @@ -77,84 +77,82 @@ describe('Downloader.updateConfig', () => { // ─── Downloader.instanceOrchestrator — guard clause ────────────────────────── -describe('Downloader.instanceOrchestrator guard clause', () => { - it('throws when no GUIDs are in state (both sourceGuid and targetGuid empty)', async () => { +describe("Downloader.instanceOrchestrator guard clause", () => { + it("throws when no GUIDs are in state (both sourceGuid and targetGuid empty)", async () => { const downloader = new Downloader(); - await expect(downloader.instanceOrchestrator(false)).rejects.toThrow( - 'No GUIDs available for download operation' - ); + await expect(downloader.instanceOrchestrator(false)).rejects.toThrow("No GUIDs available for download operation"); }); }); // ─── Downloader.guidDownloader ──────────────────────────────────────────────── -describe('Downloader.guidDownloader', () => { - it('returns a DownloadResults object with the correct guidProcessed', async () => { +describe("Downloader.guidDownloader", () => { + it("returns a DownloadResults object with the correct guidProcessed", async () => { const downloader = new Downloader(); - const result = await downloader.guidDownloader('test-guid-u', false); + const result = await downloader.guidDownloader("test-guid-u", false); - expect(result).toHaveProperty('guidProcessed', 'test-guid-u'); + expect(result).toHaveProperty("guidProcessed", "test-guid-u"); }); - it('returns empty successful and failed arrays when no operations are registered', async () => { + it("returns empty successful and failed arrays when no operations are registered", async () => { const downloader = new Downloader(); - const result = await downloader.guidDownloader('test-guid-u', false); + const result = await downloader.guidDownloader("test-guid-u", false); expect(result.successful).toHaveLength(0); expect(result.failed).toHaveLength(0); }); - it('returns a non-negative totalDuration', async () => { + it("returns a non-negative totalDuration", async () => { const downloader = new Downloader(); - const result = await downloader.guidDownloader('test-guid-u', false); + const result = await downloader.guidDownloader("test-guid-u", false); expect(result.totalDuration).toBeGreaterThanOrEqual(0); }); - it('records successful operations in results.successful', async () => { - const { DownloadOperationsRegistry } = require('lib/downloaders/download-operations-config'); + it("records successful operations in results.successful", async () => { + const { DownloadOperationsRegistry } = require("lib/downloaders/download-operations-config"); DownloadOperationsRegistry.getOperationsForElements.mockReturnValue([ { - name: 'mockOp', - description: 'test op', - elements: ['Models'], + name: "mockOp", + description: "test op", + elements: ["Models"], handler: jest.fn().mockResolvedValue(undefined), }, ]); const downloader = new Downloader(); - const result = await downloader.guidDownloader('test-guid-u', false); + const result = await downloader.guidDownloader("test-guid-u", false); expect(result.successful).toHaveLength(1); - expect(result.successful[0]).toContain('mockOp'); + expect(result.successful[0]).toContain("mockOp"); }); - it('records failed operations in results.failed when handler throws', async () => { - const { DownloadOperationsRegistry } = require('lib/downloaders/download-operations-config'); + it("records failed operations in results.failed when handler throws", async () => { + const { DownloadOperationsRegistry } = require("lib/downloaders/download-operations-config"); DownloadOperationsRegistry.getOperationsForElements.mockReturnValue([ { - name: 'failOp', - description: 'failing op', - elements: ['Models'], - handler: jest.fn().mockRejectedValue(new Error('handler exploded')), + name: "failOp", + description: "failing op", + elements: ["Models"], + handler: jest.fn().mockRejectedValue(new Error("handler exploded")), }, ]); const downloader = new Downloader(); - const result = await downloader.guidDownloader('test-guid-u', false); + const result = await downloader.guidDownloader("test-guid-u", false); expect(result.failed).toHaveLength(1); - expect(result.failed[0].operation).toBe('failOp'); - expect(result.failed[0].error).toBe('handler exploded'); + expect(result.failed[0].operation).toBe("failOp"); + expect(result.failed[0].error).toBe("handler exploded"); }); - it('calls onOperationStart and onOperationComplete callbacks', async () => { - const { DownloadOperationsRegistry } = require('lib/downloaders/download-operations-config'); + it("calls onOperationStart and onOperationComplete callbacks", async () => { + const { DownloadOperationsRegistry } = require("lib/downloaders/download-operations-config"); DownloadOperationsRegistry.getOperationsForElements.mockReturnValue([ { - name: 'callbackOp', - description: 'callback test', - elements: ['Models'], + name: "callbackOp", + description: "callback test", + elements: ["Models"], handler: jest.fn().mockResolvedValue(undefined), }, ]); @@ -163,69 +161,69 @@ describe('Downloader.guidDownloader', () => { const onComplete = jest.fn(); const downloader = new Downloader({ onOperationStart: onStart, onOperationComplete: onComplete }); - await downloader.guidDownloader('test-guid-u', false); + await downloader.guidDownloader("test-guid-u", false); - expect(onStart).toHaveBeenCalledWith('callbackOp', 'test-guid-u'); - expect(onComplete).toHaveBeenCalledWith('callbackOp', 'test-guid-u', true); + expect(onStart).toHaveBeenCalledWith("callbackOp", "test-guid-u"); + expect(onComplete).toHaveBeenCalledWith("callbackOp", "test-guid-u", true); }); - it('calls onOperationComplete with false on handler failure', async () => { - const { DownloadOperationsRegistry } = require('lib/downloaders/download-operations-config'); + it("calls onOperationComplete with false on handler failure", async () => { + const { DownloadOperationsRegistry } = require("lib/downloaders/download-operations-config"); DownloadOperationsRegistry.getOperationsForElements.mockReturnValue([ { - name: 'badOp', - description: 'bad op', - elements: ['Models'], - handler: jest.fn().mockRejectedValue(new Error('fail')), + name: "badOp", + description: "bad op", + elements: ["Models"], + handler: jest.fn().mockRejectedValue(new Error("fail")), }, ]); const onComplete = jest.fn(); const downloader = new Downloader({ onOperationComplete: onComplete }); - await downloader.guidDownloader('test-guid-u', false); + await downloader.guidDownloader("test-guid-u", false); - expect(onComplete).toHaveBeenCalledWith('badOp', 'test-guid-u', false); + expect(onComplete).toHaveBeenCalledWith("badOp", "test-guid-u", false); }); }); // ─── Downloader.instanceOrchestrator — parallel execution ───────────────────── -describe('Downloader.instanceOrchestrator with GUIDs set', () => { - it('processes all GUIDs and returns one result per GUID', async () => { - setState({ sourceGuid: 'guid-a-u,guid-b-u' }); +describe("Downloader.instanceOrchestrator with GUIDs set", () => { + it("processes all GUIDs and returns one result per GUID", async () => { + setState({ sourceGuid: "guid-a-u,guid-b-u" }); const downloader = new Downloader(); const results = await downloader.instanceOrchestrator(false); expect(results).toHaveLength(2); const processedGuids = results.map((r: DownloadResults) => r.guidProcessed); - expect(processedGuids).toContain('guid-a-u'); - expect(processedGuids).toContain('guid-b-u'); + expect(processedGuids).toContain("guid-a-u"); + expect(processedGuids).toContain("guid-b-u"); }); - it('uses sequential mode when state.local is true', async () => { - setState({ sourceGuid: 'guid-local-u', local: true }); + it("uses sequential mode when state.local is true", async () => { + setState({ sourceGuid: "guid-local-u", local: true }); const downloader = new Downloader(); const results = await downloader.instanceOrchestrator(false); expect(results).toHaveLength(1); - expect(results[0].guidProcessed).toBe('guid-local-u'); + expect(results[0].guidProcessed).toBe("guid-local-u"); }); - it('includes targetGuid in the GUIDs to process', async () => { - setState({ targetGuid: 'target-guid-u' }); + it("includes targetGuid in the GUIDs to process", async () => { + setState({ targetGuid: "target-guid-u" }); const downloader = new Downloader(); const results = await downloader.instanceOrchestrator(false); expect(results).toHaveLength(1); - expect(results[0].guidProcessed).toBe('target-guid-u'); + expect(results[0].guidProcessed).toBe("target-guid-u"); }); - it('combines sourceGuid and targetGuid', async () => { - setState({ sourceGuid: 'src-u', targetGuid: 'tgt-u' }); + it("combines sourceGuid and targetGuid", async () => { + setState({ sourceGuid: "src-u", targetGuid: "tgt-u" }); const downloader = new Downloader(); const results = await downloader.instanceOrchestrator(false); diff --git a/src/lib/downloaders/tests/store-interface-filesystem.test.ts b/src/lib/downloaders/tests/store-interface-filesystem.test.ts index d1f7229..cd6c8d5 100644 --- a/src/lib/downloaders/tests/store-interface-filesystem.test.ts +++ b/src/lib/downloaders/tests/store-interface-filesystem.test.ts @@ -1,15 +1,15 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState } from 'core/state'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState } from "core/state"; // store-interface-filesystem uses require() and module.exports so we import it that way -const storeInterface = require('lib/downloaders/store-interface-filesystem'); +const storeInterface = require("lib/downloaders/store-interface-filesystem"); let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-test-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-test-")); }); afterAll(() => { @@ -18,9 +18,9 @@ afterAll(() => { beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -29,7 +29,7 @@ afterEach(() => { // ─── helpers ────────────────────────────────────────────────────────────────── -function makeOptions(subDir = 'default') { +function makeOptions(subDir = "default") { const rootPath = path.join(tmpDir, subDir); fs.mkdirSync(rootPath, { recursive: true }); return { rootPath, logger: null }; @@ -37,18 +37,18 @@ function makeOptions(subDir = 'default') { // ─── initializeProgress ─────────────────────────────────────────────────────── -describe('initializeProgress', () => { - it('does not throw when called with a rootPath', () => { - expect(() => storeInterface.initializeProgress(path.join(tmpDir, 'init1'))).not.toThrow(); +describe("initializeProgress", () => { + it("does not throw when called with a rootPath", () => { + expect(() => storeInterface.initializeProgress(path.join(tmpDir, "init1"))).not.toThrow(); }); - it('does not throw when called without a rootPath', () => { + it("does not throw when called without a rootPath", () => { expect(() => storeInterface.initializeProgress()).not.toThrow(); }); - it('resets progress stats so getProgressStats returns zero items', () => { - const rootPath = path.join(tmpDir, 'init-reset'); - storeInterface.updateProgress('item', 1, rootPath); + it("resets progress stats so getProgressStats returns zero items", () => { + const rootPath = path.join(tmpDir, "init-reset"); + storeInterface.updateProgress("item", 1, rootPath); storeInterface.initializeProgress(rootPath); const stats = storeInterface.getCurrentProgress(rootPath); expect(stats.totalItems).toBe(0); @@ -57,40 +57,40 @@ describe('initializeProgress', () => { // ─── updateProgress ─────────────────────────────────────────────────────────── -describe('updateProgress', () => { - it('increments totalItems after each call', () => { - const rootPath = path.join(tmpDir, 'up1'); +describe("updateProgress", () => { + it("increments totalItems after each call", () => { + const rootPath = path.join(tmpDir, "up1"); storeInterface.initializeProgress(rootPath); - storeInterface.updateProgress('item', 1, rootPath); - storeInterface.updateProgress('item', 2, rootPath); + storeInterface.updateProgress("item", 1, rootPath); + storeInterface.updateProgress("item", 2, rootPath); const stats = storeInterface.getCurrentProgress(rootPath); expect(stats.totalItems).toBe(2); }); - it('tracks counts per item type', () => { - const rootPath = path.join(tmpDir, 'up2'); + it("tracks counts per item type", () => { + const rootPath = path.join(tmpDir, "up2"); storeInterface.initializeProgress(rootPath); - storeInterface.updateProgress('item', 1, rootPath); - storeInterface.updateProgress('item', 2, rootPath); - storeInterface.updateProgress('page', 3, rootPath); + storeInterface.updateProgress("item", 1, rootPath); + storeInterface.updateProgress("item", 2, rootPath); + storeInterface.updateProgress("page", 3, rootPath); const stats = storeInterface.getCurrentProgress(rootPath); - expect(stats.itemsByType['item']).toBe(2); - expect(stats.itemsByType['page']).toBe(1); + expect(stats.itemsByType["item"]).toBe(2); + expect(stats.itemsByType["page"]).toBe(1); }); - it('instances are isolated by rootPath', () => { - const rootPathA = path.join(tmpDir, 'up-a'); - const rootPathB = path.join(tmpDir, 'up-b'); + it("instances are isolated by rootPath", () => { + const rootPathA = path.join(tmpDir, "up-a"); + const rootPathB = path.join(tmpDir, "up-b"); storeInterface.initializeProgress(rootPathA); storeInterface.initializeProgress(rootPathB); - storeInterface.updateProgress('item', 1, rootPathA); - storeInterface.updateProgress('item', 2, rootPathA); - storeInterface.updateProgress('item', 3, rootPathB); + storeInterface.updateProgress("item", 1, rootPathA); + storeInterface.updateProgress("item", 2, rootPathA); + storeInterface.updateProgress("item", 3, rootPathB); const statsA = storeInterface.getCurrentProgress(rootPathA); const statsB = storeInterface.getCurrentProgress(rootPathB); @@ -101,26 +101,26 @@ describe('updateProgress', () => { // ─── getCurrentProgress (alias for getProgressStats) ───────────────────────── -describe('getCurrentProgress', () => { - it('returns zero totalItems for a fresh rootPath', () => { - const rootPath = path.join(tmpDir, 'gp-fresh'); +describe("getCurrentProgress", () => { + it("returns zero totalItems for a fresh rootPath", () => { + const rootPath = path.join(tmpDir, "gp-fresh"); storeInterface.initializeProgress(rootPath); const stats = storeInterface.getCurrentProgress(rootPath); expect(stats.totalItems).toBe(0); }); - it('returns non-negative elapsedTime', () => { - const rootPath = path.join(tmpDir, 'gp-elapsed'); + it("returns non-negative elapsedTime", () => { + const rootPath = path.join(tmpDir, "gp-elapsed"); storeInterface.initializeProgress(rootPath); const stats = storeInterface.getCurrentProgress(rootPath); expect(stats.elapsedTime).toBeGreaterThanOrEqual(0); }); - it('recentActivity contains at most 10 entries', () => { - const rootPath = path.join(tmpDir, 'gp-recent'); + it("recentActivity contains at most 10 entries", () => { + const rootPath = path.join(tmpDir, "gp-recent"); storeInterface.initializeProgress(rootPath); for (let i = 0; i < 15; i++) { - storeInterface.updateProgress('item', i, rootPath); + storeInterface.updateProgress("item", i, rootPath); } const stats = storeInterface.getCurrentProgress(rootPath); expect(stats.recentActivity.length).toBeLessThanOrEqual(10); @@ -129,22 +129,22 @@ describe('getCurrentProgress', () => { // ─── setProgressCallback ────────────────────────────────────────────────────── -describe('setProgressCallback', () => { - it('does not throw when callback is null', () => { - expect(() => storeInterface.setProgressCallback(null, path.join(tmpDir, 'cb-null'))).not.toThrow(); +describe("setProgressCallback", () => { + it("does not throw when callback is null", () => { + expect(() => storeInterface.setProgressCallback(null, path.join(tmpDir, "cb-null"))).not.toThrow(); }); - it('invokes the callback when an item is updated', () => { - const rootPath = path.join(tmpDir, 'cb-invoke'); + it("invokes the callback when an item is updated", () => { + const rootPath = path.join(tmpDir, "cb-invoke"); storeInterface.initializeProgress(rootPath); const cb = jest.fn(); storeInterface.setProgressCallback(cb, rootPath); - storeInterface.updateProgress('item', 42, rootPath); + storeInterface.updateProgress("item", 42, rootPath); expect(cb).toHaveBeenCalledTimes(1); const calledWith = cb.mock.calls[0][0]; - expect(calledWith).toHaveProperty('totalItems', 1); + expect(calledWith).toHaveProperty("totalItems", 1); // Clean up storeInterface.setProgressCallback(null, rootPath); @@ -153,104 +153,104 @@ describe('setProgressCallback', () => { // ─── cleanupProgressData ────────────────────────────────────────────────────── -describe('cleanupProgressData', () => { - it('does not throw on an empty stats store', () => { - const rootPath = path.join(tmpDir, 'cleanup1'); +describe("cleanupProgressData", () => { + it("does not throw on an empty stats store", () => { + const rootPath = path.join(tmpDir, "cleanup1"); expect(() => storeInterface.cleanupProgressData(rootPath)).not.toThrow(); }); }); // ─── getAndClearSavedItemStats ──────────────────────────────────────────────── -describe('getAndClearSavedItemStats', () => { - it('returns a summary with totalItems', () => { - const rootPath = path.join(tmpDir, 'gac-1'); +describe("getAndClearSavedItemStats", () => { + it("returns a summary with totalItems", () => { + const rootPath = path.join(tmpDir, "gac-1"); storeInterface.initializeProgress(rootPath); - storeInterface.updateProgress('item', 1, rootPath); + storeInterface.updateProgress("item", 1, rootPath); const result = storeInterface.getAndClearSavedItemStats(rootPath); - expect(result).toHaveProperty('summary'); - expect(result.summary).toHaveProperty('totalItems'); + expect(result).toHaveProperty("summary"); + expect(result.summary).toHaveProperty("totalItems"); }); - it('clears progress after retrieval', () => { - const rootPath = path.join(tmpDir, 'gac-2'); + it("clears progress after retrieval", () => { + const rootPath = path.join(tmpDir, "gac-2"); storeInterface.initializeProgress(rootPath); - storeInterface.updateProgress('item', 1, rootPath); + storeInterface.updateProgress("item", 1, rootPath); storeInterface.getAndClearSavedItemStats(rootPath); const stats = storeInterface.getCurrentProgress(rootPath); expect(stats.totalItems).toBe(0); }); - it('returns itemsByType breakdown', () => { - const rootPath = path.join(tmpDir, 'gac-3'); + it("returns itemsByType breakdown", () => { + const rootPath = path.join(tmpDir, "gac-3"); storeInterface.initializeProgress(rootPath); - storeInterface.updateProgress('page', 10, rootPath); + storeInterface.updateProgress("page", 10, rootPath); const result = storeInterface.getAndClearSavedItemStats(rootPath); - expect(result).toHaveProperty('itemsByType'); + expect(result).toHaveProperty("itemsByType"); }); }); // ─── saveItem ───────────────────────────────────────────────────────────────── -describe('saveItem', () => { - it('writes a JSON file at the expected path', async () => { - const rootPath = path.join(tmpDir, 'save-item-1'); +describe("saveItem", () => { + it("writes a JSON file at the expected path", async () => { + const rootPath = path.join(tmpDir, "save-item-1"); fs.mkdirSync(rootPath, { recursive: true }); const options = { rootPath, logger: null }; - const item = { contentID: 99, title: 'test' }; + const item = { contentID: 99, title: "test" }; - await storeInterface.saveItem({ options, item, itemType: 'item', languageCode: 'en-us', itemID: 99 }); + await storeInterface.saveItem({ options, item, itemType: "item", languageCode: "en-us", itemID: 99 }); - const expectedPath = path.join(rootPath, 'item', '99.json'); + const expectedPath = path.join(rootPath, "item", "99.json"); expect(fs.existsSync(expectedPath)).toBe(true); - const written = JSON.parse(fs.readFileSync(expectedPath, 'utf8')); + const written = JSON.parse(fs.readFileSync(expectedPath, "utf8")); expect(written.contentID).toBe(99); }); - it('does not throw when item is null (skips write)', async () => { - const rootPath = path.join(tmpDir, 'save-null'); + it("does not throw when item is null (skips write)", async () => { + const rootPath = path.join(tmpDir, "save-null"); fs.mkdirSync(rootPath, { recursive: true }); const options = { rootPath, logger: null }; await expect( - storeInterface.saveItem({ options, item: null, itemType: 'item', languageCode: 'en-us', itemID: 1 }) + storeInterface.saveItem({ options, item: null, itemType: "item", languageCode: "en-us", itemID: 1 }) ).resolves.not.toThrow(); }); - it('creates parent directories when they do not exist', async () => { - const rootPath = path.join(tmpDir, 'save-mkdir'); + it("creates parent directories when they do not exist", async () => { + const rootPath = path.join(tmpDir, "save-mkdir"); const options = { rootPath, logger: null }; const item = { pageID: 5 }; - await storeInterface.saveItem({ options, item, itemType: 'page', languageCode: 'en-us', itemID: 5 }); + await storeInterface.saveItem({ options, item, itemType: "page", languageCode: "en-us", itemID: 5 }); - const expectedPath = path.join(rootPath, 'page', '5.json'); + const expectedPath = path.join(rootPath, "page", "5.json"); expect(fs.existsSync(expectedPath)).toBe(true); }); }); // ─── getItem ────────────────────────────────────────────────────────────────── -describe('getItem', () => { - it('returns null when the file does not exist', async () => { - const options = makeOptions('get-item-missing'); - const result = await storeInterface.getItem({ options, itemType: 'item', languageCode: 'en-us', itemID: 999 }); +describe("getItem", () => { + it("returns null when the file does not exist", async () => { + const options = makeOptions("get-item-missing"); + const result = await storeInterface.getItem({ options, itemType: "item", languageCode: "en-us", itemID: 999 }); expect(result).toBeNull(); }); - it('returns the parsed JSON content of an existing file', async () => { - const rootPath = path.join(tmpDir, 'get-item-exists'); - fs.mkdirSync(path.join(rootPath, 'item'), { recursive: true }); - const item = { contentID: 7, title: 'hello' }; - fs.writeFileSync(path.join(rootPath, 'item', '7.json'), JSON.stringify(item)); + it("returns the parsed JSON content of an existing file", async () => { + const rootPath = path.join(tmpDir, "get-item-exists"); + fs.mkdirSync(path.join(rootPath, "item"), { recursive: true }); + const item = { contentID: 7, title: "hello" }; + fs.writeFileSync(path.join(rootPath, "item", "7.json"), JSON.stringify(item)); const options = { rootPath, logger: null }; - const result = await storeInterface.getItem({ options, itemType: 'item', languageCode: 'en-us', itemID: 7 }); + const result = await storeInterface.getItem({ options, itemType: "item", languageCode: "en-us", itemID: 7 }); expect(result).toEqual(item); }); @@ -258,117 +258,117 @@ describe('getItem', () => { // ─── deleteItem ─────────────────────────────────────────────────────────────── -describe('deleteItem', () => { - it('removes the file when it exists', async () => { - const rootPath = path.join(tmpDir, 'delete-item'); - fs.mkdirSync(path.join(rootPath, 'item'), { recursive: true }); - const filePath = path.join(rootPath, 'item', '55.json'); - fs.writeFileSync(filePath, '{}'); +describe("deleteItem", () => { + it("removes the file when it exists", async () => { + const rootPath = path.join(tmpDir, "delete-item"); + fs.mkdirSync(path.join(rootPath, "item"), { recursive: true }); + const filePath = path.join(rootPath, "item", "55.json"); + fs.writeFileSync(filePath, "{}"); const options = { rootPath, logger: null }; - await storeInterface.deleteItem({ options, itemType: 'item', languageCode: 'en-us', itemID: 55 }); + await storeInterface.deleteItem({ options, itemType: "item", languageCode: "en-us", itemID: 55 }); expect(fs.existsSync(filePath)).toBe(false); }); - it('does not throw when the file does not exist', async () => { - const options = makeOptions('delete-missing'); + it("does not throw when the file does not exist", async () => { + const options = makeOptions("delete-missing"); await expect( - storeInterface.deleteItem({ options, itemType: 'item', languageCode: 'en-us', itemID: 12345 }) + storeInterface.deleteItem({ options, itemType: "item", languageCode: "en-us", itemID: 12345 }) ).resolves.not.toThrow(); }); }); // ─── mergeItemToList ────────────────────────────────────────────────────────── -describe('mergeItemToList', () => { - it('creates a new list when no existing list file is present', async () => { - const rootPath = path.join(tmpDir, 'merge-new'); +describe("mergeItemToList", () => { + it("creates a new list when no existing list file is present", async () => { + const rootPath = path.join(tmpDir, "merge-new"); fs.mkdirSync(rootPath, { recursive: true }); const options = { rootPath, logger: null }; - const item = { contentID: 1, properties: { state: 1 }, title: 'First' }; + const item = { contentID: 1, properties: { state: 1 }, title: "First" }; await storeInterface.mergeItemToList({ options, item, - languageCode: 'en-us', + languageCode: "en-us", itemID: 1, - referenceName: 'blogposts', - definitionName: 'BlogPost', + referenceName: "blogposts", + definitionName: "BlogPost", }); - const listPath = path.join(rootPath, 'list', 'blogposts.json'); + const listPath = path.join(rootPath, "list", "blogposts.json"); expect(fs.existsSync(listPath)).toBe(true); - const list = JSON.parse(fs.readFileSync(listPath, 'utf8')); + const list = JSON.parse(fs.readFileSync(listPath, "utf8")); expect(list).toHaveLength(1); expect(list[0].contentID).toBe(1); }); - it('appends a new item to an existing list', async () => { - const rootPath = path.join(tmpDir, 'merge-append'); - fs.mkdirSync(path.join(rootPath, 'list'), { recursive: true }); - const existingItem = { contentID: 1, properties: { state: 1 }, title: 'Existing' }; - fs.writeFileSync(path.join(rootPath, 'list', 'articles.json'), JSON.stringify([existingItem])); + it("appends a new item to an existing list", async () => { + const rootPath = path.join(tmpDir, "merge-append"); + fs.mkdirSync(path.join(rootPath, "list"), { recursive: true }); + const existingItem = { contentID: 1, properties: { state: 1 }, title: "Existing" }; + fs.writeFileSync(path.join(rootPath, "list", "articles.json"), JSON.stringify([existingItem])); const options = { rootPath, logger: null }; - const newItem = { contentID: 2, properties: { state: 1 }, title: 'New' }; + const newItem = { contentID: 2, properties: { state: 1 }, title: "New" }; await storeInterface.mergeItemToList({ options, item: newItem, - languageCode: 'en-us', + languageCode: "en-us", itemID: 2, - referenceName: 'articles', - definitionName: 'Article', + referenceName: "articles", + definitionName: "Article", }); - const list = JSON.parse(fs.readFileSync(path.join(rootPath, 'list', 'articles.json'), 'utf8')); + const list = JSON.parse(fs.readFileSync(path.join(rootPath, "list", "articles.json"), "utf8")); expect(list).toHaveLength(2); }); - it('replaces an existing item with the same contentID', async () => { - const rootPath = path.join(tmpDir, 'merge-replace'); - fs.mkdirSync(path.join(rootPath, 'list'), { recursive: true }); - const oldItem = { contentID: 5, properties: { state: 1 }, title: 'Old' }; - fs.writeFileSync(path.join(rootPath, 'list', 'things.json'), JSON.stringify([oldItem])); + it("replaces an existing item with the same contentID", async () => { + const rootPath = path.join(tmpDir, "merge-replace"); + fs.mkdirSync(path.join(rootPath, "list"), { recursive: true }); + const oldItem = { contentID: 5, properties: { state: 1 }, title: "Old" }; + fs.writeFileSync(path.join(rootPath, "list", "things.json"), JSON.stringify([oldItem])); const options = { rootPath, logger: null }; - const updatedItem = { contentID: 5, properties: { state: 1 }, title: 'Updated' }; + const updatedItem = { contentID: 5, properties: { state: 1 }, title: "Updated" }; await storeInterface.mergeItemToList({ options, item: updatedItem, - languageCode: 'en-us', + languageCode: "en-us", itemID: 5, - referenceName: 'things', - definitionName: 'Thing', + referenceName: "things", + definitionName: "Thing", }); - const list = JSON.parse(fs.readFileSync(path.join(rootPath, 'list', 'things.json'), 'utf8')); + const list = JSON.parse(fs.readFileSync(path.join(rootPath, "list", "things.json"), "utf8")); expect(list).toHaveLength(1); - expect(list[0].title).toBe('Updated'); + expect(list[0].title).toBe("Updated"); }); - it('removes an item from the list when state is 3 (deleted)', async () => { - const rootPath = path.join(tmpDir, 'merge-delete'); - fs.mkdirSync(path.join(rootPath, 'list'), { recursive: true }); - const item1 = { contentID: 10, properties: { state: 1 }, title: 'Keep' }; - const item2 = { contentID: 11, properties: { state: 1 }, title: 'Remove' }; - fs.writeFileSync(path.join(rootPath, 'list', 'products.json'), JSON.stringify([item1, item2])); + it("removes an item from the list when state is 3 (deleted)", async () => { + const rootPath = path.join(tmpDir, "merge-delete"); + fs.mkdirSync(path.join(rootPath, "list"), { recursive: true }); + const item1 = { contentID: 10, properties: { state: 1 }, title: "Keep" }; + const item2 = { contentID: 11, properties: { state: 1 }, title: "Remove" }; + fs.writeFileSync(path.join(rootPath, "list", "products.json"), JSON.stringify([item1, item2])); const options = { rootPath, logger: null }; - const deletedItem = { contentID: 11, properties: { state: 3 }, title: 'Remove' }; + const deletedItem = { contentID: 11, properties: { state: 3 }, title: "Remove" }; await storeInterface.mergeItemToList({ options, item: deletedItem, - languageCode: 'en-us', + languageCode: "en-us", itemID: 11, - referenceName: 'products', - definitionName: 'Product', + referenceName: "products", + definitionName: "Product", }); - const list = JSON.parse(fs.readFileSync(path.join(rootPath, 'list', 'products.json'), 'utf8')); + const list = JSON.parse(fs.readFileSync(path.join(rootPath, "list", "products.json"), "utf8")); expect(list).toHaveLength(1); expect(list[0].contentID).toBe(10); }); diff --git a/src/lib/downloaders/tests/sync-token-handler.test.ts b/src/lib/downloaders/tests/sync-token-handler.test.ts index bfc35b3..50e1021 100644 --- a/src/lib/downloaders/tests/sync-token-handler.test.ts +++ b/src/lib/downloaders/tests/sync-token-handler.test.ts @@ -1,13 +1,13 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState } from 'core/state'; -import { handleSyncToken } from 'lib/downloaders/sync-token-handler'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState } from "core/state"; +import { handleSyncToken } from "lib/downloaders/sync-token-handler"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-test-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-test-")); }); afterAll(() => { @@ -16,28 +16,28 @@ afterAll(() => { beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { jest.restoreAllMocks(); }); -describe('handleSyncToken', () => { - describe('reset=false', () => { - it('returns true (incremental sync) when token file exists and reset is false', async () => { - const tokenPath = path.join(tmpDir, 'sync-exists.json'); - fs.writeFileSync(tokenPath, JSON.stringify({ token: 'abc' })); +describe("handleSyncToken", () => { + describe("reset=false", () => { + it("returns true (incremental sync) when token file exists and reset is false", async () => { + const tokenPath = path.join(tmpDir, "sync-exists.json"); + fs.writeFileSync(tokenPath, JSON.stringify({ token: "abc" })); const result = await handleSyncToken(tokenPath, false); expect(result).toBe(true); }); - it('returns false (full sync) when token file does not exist and reset is false', async () => { - const tokenPath = path.join(tmpDir, 'sync-missing.json'); + it("returns false (full sync) when token file does not exist and reset is false", async () => { + const tokenPath = path.join(tmpDir, "sync-missing.json"); const result = await handleSyncToken(tokenPath, false); @@ -45,50 +45,50 @@ describe('handleSyncToken', () => { }); }); - describe('reset=true', () => { - it('returns false (full sync) when token file exists and reset is true', async () => { - const tokenPath = path.join(tmpDir, 'sync-to-delete.json'); - fs.writeFileSync(tokenPath, JSON.stringify({ token: 'abc' })); + describe("reset=true", () => { + it("returns false (full sync) when token file exists and reset is true", async () => { + const tokenPath = path.join(tmpDir, "sync-to-delete.json"); + fs.writeFileSync(tokenPath, JSON.stringify({ token: "abc" })); const result = await handleSyncToken(tokenPath, true); expect(result).toBe(false); }); - it('deletes the sync token file when reset is true and file exists', async () => { - const tokenPath = path.join(tmpDir, 'sync-delete-check.json'); - fs.writeFileSync(tokenPath, JSON.stringify({ token: 'abc' })); + it("deletes the sync token file when reset is true and file exists", async () => { + const tokenPath = path.join(tmpDir, "sync-delete-check.json"); + fs.writeFileSync(tokenPath, JSON.stringify({ token: "abc" })); await handleSyncToken(tokenPath, true); expect(fs.existsSync(tokenPath)).toBe(false); }); - it('returns false (full sync) when token file does not exist and reset is true', async () => { - const tokenPath = path.join(tmpDir, 'sync-nonexistent.json'); + it("returns false (full sync) when token file does not exist and reset is true", async () => { + const tokenPath = path.join(tmpDir, "sync-nonexistent.json"); const result = await handleSyncToken(tokenPath, true); expect(result).toBe(false); }); - it('does not throw when the token file does not exist and reset is true', async () => { - const tokenPath = path.join(tmpDir, 'sync-nonexistent2.json'); + it("does not throw when the token file does not exist and reset is true", async () => { + const tokenPath = path.join(tmpDir, "sync-nonexistent2.json"); await expect(handleSyncToken(tokenPath, true)).resolves.not.toThrow(); }); }); - describe('return value semantics', () => { + describe("return value semantics", () => { it.each([ - { reset: false, fileExists: true, expected: true, label: 'no-reset + file → incremental' }, - { reset: false, fileExists: false, expected: false, label: 'no-reset + no file → full' }, - { reset: true, fileExists: true, expected: false, label: 'reset + file → full' }, - { reset: true, fileExists: false, expected: false, label: 'reset + no file → full' }, - ])('$label', async ({ reset, fileExists, expected }) => { + { reset: false, fileExists: true, expected: true, label: "no-reset + file → incremental" }, + { reset: false, fileExists: false, expected: false, label: "no-reset + no file → full" }, + { reset: true, fileExists: true, expected: false, label: "reset + file → full" }, + { reset: true, fileExists: false, expected: false, label: "reset + no file → full" }, + ])("$label", async ({ reset, fileExists, expected }) => { const tokenPath = path.join(tmpDir, `sync-table-${Date.now()}-${Math.random()}.json`); if (fileExists) { - fs.writeFileSync(tokenPath, '{}'); + fs.writeFileSync(tokenPath, "{}"); } const result = await handleSyncToken(tokenPath, reset); diff --git a/src/lib/getters/filesystem/get-assets.ts b/src/lib/getters/filesystem/get-assets.ts index ed76492..652c2c0 100644 --- a/src/lib/getters/filesystem/get-assets.ts +++ b/src/lib/getters/filesystem/get-assets.ts @@ -1,25 +1,21 @@ -import * as mgmtApi from '@agility/management-sdk'; -import { fileOperations } from '../../../core'; +import * as mgmtApi from "@agility/management-sdk"; +import { fileOperations } from "../../../core"; /** * Get assets from filesystem without side effects * Pure function - no filesystem operations, delegates to fileOperations */ -export function getAssetsFromFileSystem( - fileOps: fileOperations -): mgmtApi.Media[] { - // Load assets from JSON files in assets/json directory - const assetData = fileOps.readJsonFilesFromFolder('assets/json'); - const allAssets: mgmtApi.Media[] = []; - - // Extract assetMedias array from each JSON file - for (const data of assetData) { - if (data.assetMedias && Array.isArray(data.assetMedias)) { - allAssets.push(...data.assetMedias); - } - } - - return allAssets; -} +export function getAssetsFromFileSystem(fileOps: fileOperations): mgmtApi.Media[] { + // Load assets from JSON files in assets/json directory + const assetData = fileOps.readJsonFilesFromFolder("assets/json"); + const allAssets: mgmtApi.Media[] = []; + // Extract assetMedias array from each JSON file + for (const data of assetData) { + if (data.assetMedias && Array.isArray(data.assetMedias)) { + allAssets.push(...data.assetMedias); + } + } + return allAssets; +} diff --git a/src/lib/getters/filesystem/get-containers-from-list.ts b/src/lib/getters/filesystem/get-containers-from-list.ts index c29f068..28b6b57 100644 --- a/src/lib/getters/filesystem/get-containers-from-list.ts +++ b/src/lib/getters/filesystem/get-containers-from-list.ts @@ -1,6 +1,6 @@ -import * as mgmtApi from '@agility/management-sdk'; -import * as fs from 'fs'; -import * as path from 'path'; +import * as mgmtApi from "@agility/management-sdk"; +import * as fs from "fs"; +import * as path from "path"; /** * Get containers from Content Sync SDK /list directory @@ -8,101 +8,101 @@ import * as path from 'path'; * This is the REAL source of container data (not the obsolete /containers directory) */ export function getContainersFromFileSystem( - guid: string, - locale: string, - isPreview: boolean, - rootPath?: string, - legacyFolders?: boolean + guid: string, + locale: string, + isPreview: boolean, + rootPath?: string, + legacyFolders?: boolean ): mgmtApi.Container[] { - const baseFolder = rootPath || 'agility-files'; - let listPath: string; + const baseFolder = rootPath || "agility-files"; + let listPath: string; - if (legacyFolders) { - listPath = `${baseFolder}/list`; - } else { - listPath = `${baseFolder}/${guid}/${locale}/${isPreview ? 'preview':'live'}/list`; - } + if (legacyFolders) { + listPath = `${baseFolder}/list`; + } else { + listPath = `${baseFolder}/${guid}/${locale}/${isPreview ? "preview" : "live"}/list`; + } - if (!fs.existsSync(listPath)) { - console.warn(`[Containers] List directory not found: ${listPath}`); - return []; - } + if (!fs.existsSync(listPath)) { + console.warn(`[Containers] List directory not found: ${listPath}`); + return []; + } + + const listFiles = fs.readdirSync(listPath).filter((file) => file.endsWith(".json")); + const containers: mgmtApi.Container[] = []; + + // Also load models to resolve definitionName to model ID (like chain-data-loader does) + const modelsPath = legacyFolders + ? `${baseFolder}/models` + : `${baseFolder}/${guid}/${locale}/${isPreview ? "preview" : "live"}/models`; + + const models = loadModels(modelsPath); + + for (let index = 0; index < listFiles.length; index++) { + const file = listFiles[index]; + const filePath = path.join(listPath, file); - const listFiles = fs.readdirSync(listPath).filter(file => file.endsWith('.json')); - const containers: mgmtApi.Container[] = []; - - // Also load models to resolve definitionName to model ID (like chain-data-loader does) - const modelsPath = legacyFolders - ? `${baseFolder}/models` - : `${baseFolder}/${guid}/${locale}/${isPreview ? 'preview':'live'}/models`; - - const models = loadModels(modelsPath); - - for (let index = 0; index < listFiles.length; index++) { - const file = listFiles[index]; - const filePath = path.join(listPath, file); - - try { - const contentList = JSON.parse(fs.readFileSync(filePath, 'utf8')); - - if (Array.isArray(contentList) && contentList.length > 0) { - // Get container metadata from the first content item's properties - const firstItem = contentList[0]; - if (firstItem.properties) { - // Find the model ID by matching definitionName with model referenceName - const matchingModel = models.find((model: any) => - model.referenceName === firstItem.properties.definitionName - ); - - const container: mgmtApi.Container = { - // Use referenceName as the container identifier - referenceName: firstItem.properties.referenceName, - contentViewID: index + 1000, // Generate unique ID for consistency with chain-data-loader - contentDefinitionID: matchingModel ? matchingModel.id : null, // Proper model ID reference - contentCount: contentList.length, - // Standard container properties - displayName: firstItem.properties.referenceName, - isSystemContainer: false, - containerID: index + 1000, - containerType: 'content', - // Store additional metadata - _sourceFile: file, - _contentItems: contentList // Store the list contents for reference - } as any; - - // Add source container to reference mapper - // referenceMapper.addRecord('container', container, null); - containers.push(container); - } - } - } catch (error: any) { - console.warn(`[Containers] Error processing list file ${file}: ${error.message}`); + try { + const contentList = JSON.parse(fs.readFileSync(filePath, "utf8")); + + if (Array.isArray(contentList) && contentList.length > 0) { + // Get container metadata from the first content item's properties + const firstItem = contentList[0]; + if (firstItem.properties) { + // Find the model ID by matching definitionName with model referenceName + const matchingModel = models.find( + (model: any) => model.referenceName === firstItem.properties.definitionName + ); + + const container: mgmtApi.Container = { + // Use referenceName as the container identifier + referenceName: firstItem.properties.referenceName, + contentViewID: index + 1000, // Generate unique ID for consistency with chain-data-loader + contentDefinitionID: matchingModel ? matchingModel.id : null, // Proper model ID reference + contentCount: contentList.length, + // Standard container properties + displayName: firstItem.properties.referenceName, + isSystemContainer: false, + containerID: index + 1000, + containerType: "content", + // Store additional metadata + _sourceFile: file, + _contentItems: contentList, // Store the list contents for reference + } as any; + + // Add source container to reference mapper + // referenceMapper.addRecord('container', container, null); + containers.push(container); } + } + } catch (error: any) { + console.warn(`[Containers] Error processing list file ${file}: ${error.message}`); } + } - console.log(`[Containers] Loaded ${containers.length} containers from /list directory`); - return containers; + console.log(`[Containers] Loaded ${containers.length} containers from /list directory`); + return containers; } /** * Load models to resolve definitionName to model ID */ function loadModels(modelsPath: string): any[] { - if (!fs.existsSync(modelsPath)) { - return []; - } + if (!fs.existsSync(modelsPath)) { + return []; + } - const modelFiles = fs.readdirSync(modelsPath).filter(file => file.endsWith('.json')); - const models: any[] = []; + const modelFiles = fs.readdirSync(modelsPath).filter((file) => file.endsWith(".json")); + const models: any[] = []; - for (const file of modelFiles) { - try { - const modelData = JSON.parse(fs.readFileSync(path.join(modelsPath, file), 'utf8')); - models.push(modelData); - } catch (error: any) { - console.warn(`[Containers] Error loading model file ${file}: ${error.message}`); - } + for (const file of modelFiles) { + try { + const modelData = JSON.parse(fs.readFileSync(path.join(modelsPath, file), "utf8")); + models.push(modelData); + } catch (error: any) { + console.warn(`[Containers] Error loading model file ${file}: ${error.message}`); } + } - return models; -} \ No newline at end of file + return models; +} diff --git a/src/lib/getters/filesystem/get-content-items.ts b/src/lib/getters/filesystem/get-content-items.ts index b0d9fed..602043e 100644 --- a/src/lib/getters/filesystem/get-content-items.ts +++ b/src/lib/getters/filesystem/get-content-items.ts @@ -1,28 +1,26 @@ -import * as mgmtApi from '@agility/management-sdk'; -import { fileOperations } from '../../../core'; +import * as mgmtApi from "@agility/management-sdk"; +import { fileOperations } from "../../../core"; /** * Get content items from filesystem without side effects * Loads ONLY from /item directory (individual content items) * Pure function - no filesystem operations, delegates to fileOperations */ -export function getContentItemsFromFileSystem( - fileOps: fileOperations -): mgmtApi.ContentItem[] { - const allContent: any[] = []; - const processedContentIds = new Set(); +export function getContentItemsFromFileSystem(fileOps: fileOperations): mgmtApi.ContentItem[] { + const allContent: any[] = []; + const processedContentIds = new Set(); - // Load content from /item directory (individual content items) - const itemContent = fileOps.readJsonFilesFromFolder('item'); - for (const contentData of itemContent) { - // if (contentData.contentID && !processedContentIds.has(contentData.contentID)) { - allContent.push(contentData); - // processedContentIds.add(contentData.contentID); - // } - } + // Load content from /item directory (individual content items) + const itemContent = fileOps.readJsonFilesFromFolder("item"); + for (const contentData of itemContent) { + // if (contentData.contentID && !processedContentIds.has(contentData.contentID)) { + allContent.push(contentData); + // processedContentIds.add(contentData.contentID); + // } + } - // REMOVED: /list directory loading - should only load from /item - // User confirmed we should ONLY load from /item directory + // REMOVED: /list directory loading - should only load from /item + // User confirmed we should ONLY load from /item directory - return allContent; + return allContent; } diff --git a/src/lib/getters/filesystem/get-galleries.ts b/src/lib/getters/filesystem/get-galleries.ts index 518df4f..cb5324f 100644 --- a/src/lib/getters/filesystem/get-galleries.ts +++ b/src/lib/getters/filesystem/get-galleries.ts @@ -1,31 +1,26 @@ -import * as mgmtApi from '@agility/management-sdk'; -import { fileOperations } from '../../../core'; -import ansiColors from 'ansi-colors'; +import * as mgmtApi from "@agility/management-sdk"; +import { fileOperations } from "../../../core"; +import ansiColors from "ansi-colors"; /** * Get galleries from filesystem without side effects * Includes flattening of assetMediaGroupings arrays (from ChainDataLoader logic) * Pure function - no filesystem operations, delegates to fileOperations */ -export function getGalleriesFromFileSystem( - fileOps: fileOperations -): mgmtApi.assetMediaGrouping[] { +export function getGalleriesFromFileSystem(fileOps: fileOperations): mgmtApi.assetMediaGrouping[] { + const galleryFolder = fileOps.getDataFolderPath("galleries"); + const galleryFiles = fileOps.getFolderContents(galleryFolder); + const galleries = []; + for (const galleryFile of galleryFiles) { + const gallery = fileOps.readJsonFile(`galleries/${galleryFile}`); + if (gallery) galleries.push(gallery); + } - const galleryFolder = fileOps.getDataFolderPath('galleries'); - const galleryFiles = fileOps.getFolderContents(galleryFolder); - - const galleries = []; - for(const galleryFile of galleryFiles){ - const gallery = fileOps.readJsonFile(`galleries/${galleryFile}`); - if (gallery) galleries.push(gallery); - } + // Deduplicate galleries by mediaGroupingID to prevent double processing + const uniqueGalleries = galleries.filter( + (gallery, index, array) => array.findIndex((g) => g.mediaGroupingID === gallery.mediaGroupingID) === index + ); - - // Deduplicate galleries by mediaGroupingID to prevent double processing - const uniqueGalleries = galleries.filter((gallery, index, array) => - array.findIndex(g => g.mediaGroupingID === gallery.mediaGroupingID) === index - ); - - return uniqueGalleries; + return uniqueGalleries; } diff --git a/src/lib/getters/filesystem/get-models.ts b/src/lib/getters/filesystem/get-models.ts index 7e23881..e728a10 100644 --- a/src/lib/getters/filesystem/get-models.ts +++ b/src/lib/getters/filesystem/get-models.ts @@ -1,16 +1,14 @@ -import * as mgmtApi from '@agility/management-sdk'; -import { fileOperations } from '../../../core'; +import * as mgmtApi from "@agility/management-sdk"; +import { fileOperations } from "../../../core"; /** * Get models from filesystem without side effects * Simplified - no unnecessary transformations * Pure function - no filesystem operations, delegates to fileOperations */ -export function getModelsFromFileSystem( - fileOps: fileOperations -): mgmtApi.Model[] { - const rawModels = fileOps.readJsonFilesFromFolder('models'); - - // Return models as-is - no transformation needed - return rawModels; +export function getModelsFromFileSystem(fileOps: fileOperations): mgmtApi.Model[] { + const rawModels = fileOps.readJsonFilesFromFolder("models"); + + // Return models as-is - no transformation needed + return rawModels; } diff --git a/src/lib/getters/filesystem/get-pages.ts b/src/lib/getters/filesystem/get-pages.ts index 51c8144..5369e9b 100644 --- a/src/lib/getters/filesystem/get-pages.ts +++ b/src/lib/getters/filesystem/get-pages.ts @@ -1,13 +1,11 @@ -import * as mgmtApi from '@agility/management-sdk'; -import { fileOperations } from '../../../core'; +import * as mgmtApi from "@agility/management-sdk"; +import { fileOperations } from "../../../core"; /** * Get pages from filesystem without side effects * Pure function - no filesystem operations, delegates to fileOperations */ -export function getPagesFromFileSystem( - fileOps: fileOperations -): mgmtApi.PageItem[] { - const pageData = fileOps.readJsonFilesFromFolder('page'); - return pageData.map(data => data as mgmtApi.PageItem); +export function getPagesFromFileSystem(fileOps: fileOperations): mgmtApi.PageItem[] { + const pageData = fileOps.readJsonFilesFromFolder("page"); + return pageData.map((data) => data as mgmtApi.PageItem); } diff --git a/src/lib/getters/filesystem/get-templates.ts b/src/lib/getters/filesystem/get-templates.ts index d1df08b..7f29d17 100644 --- a/src/lib/getters/filesystem/get-templates.ts +++ b/src/lib/getters/filesystem/get-templates.ts @@ -1,13 +1,11 @@ -import * as mgmtApi from '@agility/management-sdk'; -import { fileOperations } from '../../../core'; +import * as mgmtApi from "@agility/management-sdk"; +import { fileOperations } from "../../../core"; /** * Get templates from filesystem without side effects * Pure function - no filesystem operations, delegates to fileOperations */ -export function getTemplatesFromFileSystem( - fileOps: fileOperations -): mgmtApi.PageModel[] { - const templateData = fileOps.readJsonFilesFromFolder('templates'); - return templateData.map(data => data as mgmtApi.PageModel); +export function getTemplatesFromFileSystem(fileOps: fileOperations): mgmtApi.PageModel[] { + const templateData = fileOps.readJsonFilesFromFolder("templates"); + return templateData.map((data) => data as mgmtApi.PageModel); } diff --git a/src/lib/getters/filesystem/index.ts b/src/lib/getters/filesystem/index.ts index beca460..b698b18 100644 --- a/src/lib/getters/filesystem/index.ts +++ b/src/lib/getters/filesystem/index.ts @@ -4,4 +4,4 @@ export * from "./get-models"; export * from "./get-templates"; export * from "./get-pages"; export * from "./get-content-items"; -export * from "./get-assets"; \ No newline at end of file +export * from "./get-assets"; diff --git a/src/lib/getters/filesystem/tests/get-assets.test.ts b/src/lib/getters/filesystem/tests/get-assets.test.ts index 9e9a634..cdfe10c 100644 --- a/src/lib/getters/filesystem/tests/get-assets.test.ts +++ b/src/lib/getters/filesystem/tests/get-assets.test.ts @@ -1,14 +1,14 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, setState } from 'core/state'; -import { fileOperations } from 'core/fileOperations'; -import { getAssetsFromFileSystem } from '../get-assets'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState } from "core/state"; +import { fileOperations } from "core/fileOperations"; +import { getAssetsFromFileSystem } from "../get-assets"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-test-get-assets-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-test-get-assets-")); }); afterAll(() => { @@ -17,9 +17,9 @@ afterAll(() => { beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -30,55 +30,52 @@ function makeFileOps(subDir: string): fileOperations { const root = path.join(tmpDir, subDir); fs.mkdirSync(root, { recursive: true }); setState({ rootPath: root }); - return new fileOperations('test-guid', 'en-us'); + return new fileOperations("test-guid", "en-us"); } -describe('getAssetsFromFileSystem', () => { - it('returns an empty array when assets/json folder does not exist', () => { - const fileOps = makeFileOps('assets-empty'); +describe("getAssetsFromFileSystem", () => { + it("returns an empty array when assets/json folder does not exist", () => { + const fileOps = makeFileOps("assets-empty"); const result = getAssetsFromFileSystem(fileOps); expect(result).toEqual([]); }); - it('returns an empty array when assets/json folder has no JSON files', () => { - const fileOps = makeFileOps('assets-no-files'); - const jsonDir = path.join(fileOps.instancePath, 'assets', 'json'); + it("returns an empty array when assets/json folder has no JSON files", () => { + const fileOps = makeFileOps("assets-no-files"); + const jsonDir = path.join(fileOps.instancePath, "assets", "json"); fs.mkdirSync(jsonDir, { recursive: true }); const result = getAssetsFromFileSystem(fileOps); expect(result).toEqual([]); }); - it('returns assets from a single file with assetMedias array', () => { - const fileOps = makeFileOps('assets-single'); - const jsonDir = path.join(fileOps.instancePath, 'assets', 'json'); + it("returns assets from a single file with assetMedias array", () => { + const fileOps = makeFileOps("assets-single"); + const jsonDir = path.join(fileOps.instancePath, "assets", "json"); fs.mkdirSync(jsonDir, { recursive: true }); const assetData = { assetMedias: [ - { mediaID: 1, fileName: 'test.png' }, - { mediaID: 2, fileName: 'logo.jpg' }, + { mediaID: 1, fileName: "test.png" }, + { mediaID: 2, fileName: "logo.jpg" }, ], }; - fs.writeFileSync(path.join(jsonDir, 'page1.json'), JSON.stringify(assetData)); + fs.writeFileSync(path.join(jsonDir, "page1.json"), JSON.stringify(assetData)); const result = getAssetsFromFileSystem(fileOps); expect(result).toHaveLength(2); - expect(result[0]).toMatchObject({ mediaID: 1, fileName: 'test.png' }); - expect(result[1]).toMatchObject({ mediaID: 2, fileName: 'logo.jpg' }); + expect(result[0]).toMatchObject({ mediaID: 1, fileName: "test.png" }); + expect(result[1]).toMatchObject({ mediaID: 2, fileName: "logo.jpg" }); }); - it('returns combined assets from multiple JSON files', () => { - const fileOps = makeFileOps('assets-multi'); - const jsonDir = path.join(fileOps.instancePath, 'assets', 'json'); + it("returns combined assets from multiple JSON files", () => { + const fileOps = makeFileOps("assets-multi"); + const jsonDir = path.join(fileOps.instancePath, "assets", "json"); fs.mkdirSync(jsonDir, { recursive: true }); fs.writeFileSync( - path.join(jsonDir, 'file1.json'), + path.join(jsonDir, "file1.json"), JSON.stringify({ assetMedias: [{ mediaID: 10 }, { mediaID: 11 }] }) ); - fs.writeFileSync( - path.join(jsonDir, 'file2.json'), - JSON.stringify({ assetMedias: [{ mediaID: 20 }] }) - ); + fs.writeFileSync(path.join(jsonDir, "file2.json"), JSON.stringify({ assetMedias: [{ mediaID: 20 }] })); const result = getAssetsFromFileSystem(fileOps); @@ -89,15 +86,12 @@ describe('getAssetsFromFileSystem', () => { expect(ids).toContain(20); }); - it('skips files where assetMedias is absent', () => { - const fileOps = makeFileOps('assets-skip-no-prop'); - const jsonDir = path.join(fileOps.instancePath, 'assets', 'json'); + it("skips files where assetMedias is absent", () => { + const fileOps = makeFileOps("assets-skip-no-prop"); + const jsonDir = path.join(fileOps.instancePath, "assets", "json"); fs.mkdirSync(jsonDir, { recursive: true }); - fs.writeFileSync(path.join(jsonDir, 'no-medias.json'), JSON.stringify({ someOtherProp: [] })); - fs.writeFileSync( - path.join(jsonDir, 'with-medias.json'), - JSON.stringify({ assetMedias: [{ mediaID: 5 }] }) - ); + fs.writeFileSync(path.join(jsonDir, "no-medias.json"), JSON.stringify({ someOtherProp: [] })); + fs.writeFileSync(path.join(jsonDir, "with-medias.json"), JSON.stringify({ assetMedias: [{ mediaID: 5 }] })); const result = getAssetsFromFileSystem(fileOps); @@ -105,22 +99,22 @@ describe('getAssetsFromFileSystem', () => { expect((result[0] as any).mediaID).toBe(5); }); - it('skips files where assetMedias is not an array', () => { - const fileOps = makeFileOps('assets-skip-non-array'); - const jsonDir = path.join(fileOps.instancePath, 'assets', 'json'); + it("skips files where assetMedias is not an array", () => { + const fileOps = makeFileOps("assets-skip-non-array"); + const jsonDir = path.join(fileOps.instancePath, "assets", "json"); fs.mkdirSync(jsonDir, { recursive: true }); - fs.writeFileSync(path.join(jsonDir, 'bad.json'), JSON.stringify({ assetMedias: 'notAnArray' })); + fs.writeFileSync(path.join(jsonDir, "bad.json"), JSON.stringify({ assetMedias: "notAnArray" })); const result = getAssetsFromFileSystem(fileOps); expect(result).toEqual([]); }); - it('returns an empty array when assetMedias is an empty array', () => { - const fileOps = makeFileOps('assets-empty-array'); - const jsonDir = path.join(fileOps.instancePath, 'assets', 'json'); + it("returns an empty array when assetMedias is an empty array", () => { + const fileOps = makeFileOps("assets-empty-array"); + const jsonDir = path.join(fileOps.instancePath, "assets", "json"); fs.mkdirSync(jsonDir, { recursive: true }); - fs.writeFileSync(path.join(jsonDir, 'empty.json'), JSON.stringify({ assetMedias: [] })); + fs.writeFileSync(path.join(jsonDir, "empty.json"), JSON.stringify({ assetMedias: [] })); const result = getAssetsFromFileSystem(fileOps); diff --git a/src/lib/getters/filesystem/tests/get-containers-from-list.test.ts b/src/lib/getters/filesystem/tests/get-containers-from-list.test.ts index 255c749..86b0ce8 100644 --- a/src/lib/getters/filesystem/tests/get-containers-from-list.test.ts +++ b/src/lib/getters/filesystem/tests/get-containers-from-list.test.ts @@ -1,13 +1,13 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState } from 'core/state'; -import { getContainersFromFileSystem } from '../get-containers-from-list'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState } from "core/state"; +import { getContainersFromFileSystem } from "../get-containers-from-list"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-test-get-containers-from-list-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-test-get-containers-from-list-")); }); afterAll(() => { @@ -16,9 +16,9 @@ afterAll(() => { beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -27,244 +27,240 @@ afterEach(() => { // Helper: build the expected list directory path for non-legacy mode function listPath(root: string, guid: string, locale: string, isPreview: boolean): string { - return path.join(root, guid, locale, isPreview ? 'preview' : 'live', 'list'); + return path.join(root, guid, locale, isPreview ? "preview" : "live", "list"); } function modelsPath(root: string, guid: string, locale: string, isPreview: boolean): string { - return path.join(root, guid, locale, isPreview ? 'preview' : 'live', 'models'); + return path.join(root, guid, locale, isPreview ? "preview" : "live", "models"); } -describe('getContainersFromFileSystem (from-list)', () => { - describe('when list directory does not exist', () => { - it('returns an empty array and warns', () => { - const root = path.join(tmpDir, 'no-list-dir'); +describe("getContainersFromFileSystem (from-list)", () => { + describe("when list directory does not exist", () => { + it("returns an empty array and warns", () => { + const root = path.join(tmpDir, "no-list-dir"); fs.mkdirSync(root, { recursive: true }); - const result = getContainersFromFileSystem('g-001', 'en-us', false, root); + const result = getContainersFromFileSystem("g-001", "en-us", false, root); expect(result).toEqual([]); - expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('List directory not found')); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("List directory not found")); }); }); - describe('when list directory exists but is empty', () => { - it('returns an empty array', () => { - const root = path.join(tmpDir, 'empty-list'); - const lp = listPath(root, 'g-002', 'en-us', false); + describe("when list directory exists but is empty", () => { + it("returns an empty array", () => { + const root = path.join(tmpDir, "empty-list"); + const lp = listPath(root, "g-002", "en-us", false); fs.mkdirSync(lp, { recursive: true }); - const result = getContainersFromFileSystem('g-002', 'en-us', false, root); + const result = getContainersFromFileSystem("g-002", "en-us", false, root); expect(result).toEqual([]); }); }); - describe('with valid list files', () => { - it('builds a container from a list file with items that have properties', () => { - const root = path.join(tmpDir, 'valid-list'); - const lp = listPath(root, 'g-003', 'en-us', false); + describe("with valid list files", () => { + it("builds a container from a list file with items that have properties", () => { + const root = path.join(tmpDir, "valid-list"); + const lp = listPath(root, "g-003", "en-us", false); fs.mkdirSync(lp, { recursive: true }); const contentList = [ { contentID: 1, properties: { - referenceName: 'blogposts', - definitionName: 'BlogPost', + referenceName: "blogposts", + definitionName: "BlogPost", state: 1, }, }, { contentID: 2, properties: { - referenceName: 'blogposts', - definitionName: 'BlogPost', + referenceName: "blogposts", + definitionName: "BlogPost", state: 1, }, }, ]; - fs.writeFileSync(path.join(lp, 'blogposts.json'), JSON.stringify(contentList)); + fs.writeFileSync(path.join(lp, "blogposts.json"), JSON.stringify(contentList)); - const result = getContainersFromFileSystem('g-003', 'en-us', false, root); + const result = getContainersFromFileSystem("g-003", "en-us", false, root); expect(result).toHaveLength(1); const container = result[0] as any; - expect(container.referenceName).toBe('blogposts'); + expect(container.referenceName).toBe("blogposts"); expect(container.contentCount).toBe(2); }); - it('assigns a unique contentViewID for each container', () => { - const root = path.join(tmpDir, 'unique-ids'); - const lp = listPath(root, 'g-004', 'en-us', false); + it("assigns a unique contentViewID for each container", () => { + const root = path.join(tmpDir, "unique-ids"); + const lp = listPath(root, "g-004", "en-us", false); fs.mkdirSync(lp, { recursive: true }); const makeList = (refName: string) => [ { contentID: 1, properties: { referenceName: refName, definitionName: refName, state: 1 } }, ]; - fs.writeFileSync(path.join(lp, 'a.json'), JSON.stringify(makeList('aList'))); - fs.writeFileSync(path.join(lp, 'b.json'), JSON.stringify(makeList('bList'))); + fs.writeFileSync(path.join(lp, "a.json"), JSON.stringify(makeList("aList"))); + fs.writeFileSync(path.join(lp, "b.json"), JSON.stringify(makeList("bList"))); - const result = getContainersFromFileSystem('g-004', 'en-us', false, root); + const result = getContainersFromFileSystem("g-004", "en-us", false, root); expect(result).toHaveLength(2); const ids = result.map((c: any) => c.contentViewID); expect(new Set(ids).size).toBe(2); }); - it('resolves contentDefinitionID from matching model referenceName', () => { - const root = path.join(tmpDir, 'with-models'); - const lp = listPath(root, 'g-005', 'en-us', false); - const mp = modelsPath(root, 'g-005', 'en-us', false); + it("resolves contentDefinitionID from matching model referenceName", () => { + const root = path.join(tmpDir, "with-models"); + const lp = listPath(root, "g-005", "en-us", false); + const mp = modelsPath(root, "g-005", "en-us", false); fs.mkdirSync(lp, { recursive: true }); fs.mkdirSync(mp, { recursive: true }); - const model = { id: 42, referenceName: 'NewsPost' }; - fs.writeFileSync(path.join(mp, '42.json'), JSON.stringify(model)); + const model = { id: 42, referenceName: "NewsPost" }; + fs.writeFileSync(path.join(mp, "42.json"), JSON.stringify(model)); const contentList = [ - { contentID: 1, properties: { referenceName: 'news', definitionName: 'NewsPost', state: 1 } }, + { contentID: 1, properties: { referenceName: "news", definitionName: "NewsPost", state: 1 } }, ]; - fs.writeFileSync(path.join(lp, 'news.json'), JSON.stringify(contentList)); + fs.writeFileSync(path.join(lp, "news.json"), JSON.stringify(contentList)); - const result = getContainersFromFileSystem('g-005', 'en-us', false, root); + const result = getContainersFromFileSystem("g-005", "en-us", false, root); expect(result).toHaveLength(1); expect((result[0] as any).contentDefinitionID).toBe(42); }); - it('sets contentDefinitionID to null when no matching model is found', () => { - const root = path.join(tmpDir, 'no-matching-model'); - const lp = listPath(root, 'g-006', 'en-us', false); + it("sets contentDefinitionID to null when no matching model is found", () => { + const root = path.join(tmpDir, "no-matching-model"); + const lp = listPath(root, "g-006", "en-us", false); fs.mkdirSync(lp, { recursive: true }); const contentList = [ - { contentID: 1, properties: { referenceName: 'events', definitionName: 'Event', state: 1 } }, + { contentID: 1, properties: { referenceName: "events", definitionName: "Event", state: 1 } }, ]; - fs.writeFileSync(path.join(lp, 'events.json'), JSON.stringify(contentList)); + fs.writeFileSync(path.join(lp, "events.json"), JSON.stringify(contentList)); - const result = getContainersFromFileSystem('g-006', 'en-us', false, root); + const result = getContainersFromFileSystem("g-006", "en-us", false, root); expect(result).toHaveLength(1); expect((result[0] as any).contentDefinitionID).toBeNull(); }); - it('skips list files that contain non-array or empty data', () => { - const root = path.join(tmpDir, 'skip-bad-files'); - const lp = listPath(root, 'g-007', 'en-us', false); + it("skips list files that contain non-array or empty data", () => { + const root = path.join(tmpDir, "skip-bad-files"); + const lp = listPath(root, "g-007", "en-us", false); fs.mkdirSync(lp, { recursive: true }); // Empty array - should be skipped - fs.writeFileSync(path.join(lp, 'empty.json'), JSON.stringify([])); + fs.writeFileSync(path.join(lp, "empty.json"), JSON.stringify([])); // Valid content - const contentList = [ - { contentID: 1, properties: { referenceName: 'valid', definitionName: 'Valid', state: 1 } }, - ]; - fs.writeFileSync(path.join(lp, 'valid.json'), JSON.stringify(contentList)); + const contentList = [{ contentID: 1, properties: { referenceName: "valid", definitionName: "Valid", state: 1 } }]; + fs.writeFileSync(path.join(lp, "valid.json"), JSON.stringify(contentList)); - const result = getContainersFromFileSystem('g-007', 'en-us', false, root); + const result = getContainersFromFileSystem("g-007", "en-us", false, root); expect(result).toHaveLength(1); - expect((result[0] as any).referenceName).toBe('valid'); + expect((result[0] as any).referenceName).toBe("valid"); }); - it('skips list items that lack a properties object', () => { - const root = path.join(tmpDir, 'skip-no-properties'); - const lp = listPath(root, 'g-008', 'en-us', false); + it("skips list items that lack a properties object", () => { + const root = path.join(tmpDir, "skip-no-properties"); + const lp = listPath(root, "g-008", "en-us", false); fs.mkdirSync(lp, { recursive: true }); // First item has no properties → firstItem.properties is falsy → skip - const contentList = [{ contentID: 1, title: 'no props here' }]; - fs.writeFileSync(path.join(lp, 'noprops.json'), JSON.stringify(contentList)); + const contentList = [{ contentID: 1, title: "no props here" }]; + fs.writeFileSync(path.join(lp, "noprops.json"), JSON.stringify(contentList)); - const result = getContainersFromFileSystem('g-008', 'en-us', false, root); + const result = getContainersFromFileSystem("g-008", "en-us", false, root); expect(result).toHaveLength(0); }); - it('warns and skips malformed JSON files', () => { - const root = path.join(tmpDir, 'malformed-json'); - const lp = listPath(root, 'g-009', 'en-us', false); + it("warns and skips malformed JSON files", () => { + const root = path.join(tmpDir, "malformed-json"); + const lp = listPath(root, "g-009", "en-us", false); fs.mkdirSync(lp, { recursive: true }); - fs.writeFileSync(path.join(lp, 'bad.json'), 'NOT VALID JSON {{{}'); + fs.writeFileSync(path.join(lp, "bad.json"), "NOT VALID JSON {{{}"); // Also add a valid file - const contentList = [ - { contentID: 1, properties: { referenceName: 'good', definitionName: 'Good', state: 1 } }, - ]; - fs.writeFileSync(path.join(lp, 'good.json'), JSON.stringify(contentList)); + const contentList = [{ contentID: 1, properties: { referenceName: "good", definitionName: "Good", state: 1 } }]; + fs.writeFileSync(path.join(lp, "good.json"), JSON.stringify(contentList)); - const result = getContainersFromFileSystem('g-009', 'en-us', false, root); + const result = getContainersFromFileSystem("g-009", "en-us", false, root); // The bad file is skipped with a warning; the good file is processed expect(result).toHaveLength(1); - expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Error processing list file bad.json')); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("Error processing list file bad.json")); }); }); - describe('legacy folder mode', () => { - it('reads list from flat baseFolder/list path when legacyFolders is true', () => { - const root = path.join(tmpDir, 'legacy-list'); - const lp = path.join(root, 'list'); + describe("legacy folder mode", () => { + it("reads list from flat baseFolder/list path when legacyFolders is true", () => { + const root = path.join(tmpDir, "legacy-list"); + const lp = path.join(root, "list"); fs.mkdirSync(lp, { recursive: true }); const contentList = [ - { contentID: 1, properties: { referenceName: 'legacyRef', definitionName: 'LegacyModel', state: 1 } }, + { contentID: 1, properties: { referenceName: "legacyRef", definitionName: "LegacyModel", state: 1 } }, ]; - fs.writeFileSync(path.join(lp, 'legacyRef.json'), JSON.stringify(contentList)); + fs.writeFileSync(path.join(lp, "legacyRef.json"), JSON.stringify(contentList)); - const result = getContainersFromFileSystem('g-010', 'en-us', false, root, true); + const result = getContainersFromFileSystem("g-010", "en-us", false, root, true); expect(result).toHaveLength(1); - expect((result[0] as any).referenceName).toBe('legacyRef'); + expect((result[0] as any).referenceName).toBe("legacyRef"); }); - it('returns empty array when legacy list directory does not exist', () => { - const root = path.join(tmpDir, 'legacy-list-missing'); + it("returns empty array when legacy list directory does not exist", () => { + const root = path.join(tmpDir, "legacy-list-missing"); fs.mkdirSync(root, { recursive: true }); - const result = getContainersFromFileSystem('g-011', 'en-us', false, root, true); + const result = getContainersFromFileSystem("g-011", "en-us", false, root, true); expect(result).toEqual([]); }); }); - describe('preview vs live mode', () => { - it('reads from preview sub-directory when isPreview is true', () => { - const root = path.join(tmpDir, 'preview-mode'); - const lp = listPath(root, 'g-012', 'en-us', true); + describe("preview vs live mode", () => { + it("reads from preview sub-directory when isPreview is true", () => { + const root = path.join(tmpDir, "preview-mode"); + const lp = listPath(root, "g-012", "en-us", true); fs.mkdirSync(lp, { recursive: true }); const contentList = [ - { contentID: 1, properties: { referenceName: 'previewItem', definitionName: 'Model', state: 1 } }, + { contentID: 1, properties: { referenceName: "previewItem", definitionName: "Model", state: 1 } }, ]; - fs.writeFileSync(path.join(lp, 'previewItem.json'), JSON.stringify(contentList)); + fs.writeFileSync(path.join(lp, "previewItem.json"), JSON.stringify(contentList)); - const result = getContainersFromFileSystem('g-012', 'en-us', true, root); + const result = getContainersFromFileSystem("g-012", "en-us", true, root); expect(result).toHaveLength(1); }); - it('reads from live sub-directory when isPreview is false', () => { - const root = path.join(tmpDir, 'live-mode'); - const lp = listPath(root, 'g-013', 'en-us', false); + it("reads from live sub-directory when isPreview is false", () => { + const root = path.join(tmpDir, "live-mode"); + const lp = listPath(root, "g-013", "en-us", false); fs.mkdirSync(lp, { recursive: true }); const contentList = [ - { contentID: 1, properties: { referenceName: 'liveItem', definitionName: 'Model', state: 1 } }, + { contentID: 1, properties: { referenceName: "liveItem", definitionName: "Model", state: 1 } }, ]; - fs.writeFileSync(path.join(lp, 'liveItem.json'), JSON.stringify(contentList)); + fs.writeFileSync(path.join(lp, "liveItem.json"), JSON.stringify(contentList)); - const result = getContainersFromFileSystem('g-013', 'en-us', false, root); + const result = getContainersFromFileSystem("g-013", "en-us", false, root); expect(result).toHaveLength(1); }); }); - describe('default rootPath', () => { - it('uses agility-files as default when rootPath is not provided', () => { + describe("default rootPath", () => { + it("uses agility-files as default when rootPath is not provided", () => { // The function will warn that the list path doesn't exist, // which is correct because agility-files won't exist in the test environment. - const result = getContainersFromFileSystem('g-014', 'en-us', false); + const result = getContainersFromFileSystem("g-014", "en-us", false); expect(result).toEqual([]); expect(console.warn).toHaveBeenCalled(); }); diff --git a/src/lib/getters/filesystem/tests/get-containers.test.ts b/src/lib/getters/filesystem/tests/get-containers.test.ts index 9f31d40..7e33482 100644 --- a/src/lib/getters/filesystem/tests/get-containers.test.ts +++ b/src/lib/getters/filesystem/tests/get-containers.test.ts @@ -1,14 +1,14 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, setState } from 'core/state'; -import { fileOperations } from 'core/fileOperations'; -import { getListsFromFileSystem, getContainersFromFileSystem } from '../get-containers'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState } from "core/state"; +import { fileOperations } from "core/fileOperations"; +import { getListsFromFileSystem, getContainersFromFileSystem } from "../get-containers"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-test-get-containers-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-test-get-containers-")); }); afterAll(() => { @@ -17,9 +17,9 @@ afterAll(() => { beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -30,38 +30,38 @@ function makeFileOps(subDir: string): fileOperations { const root = path.join(tmpDir, subDir); fs.mkdirSync(root, { recursive: true }); setState({ rootPath: root }); - return new fileOperations('test-guid', 'en-us'); + return new fileOperations("test-guid", "en-us"); } // ─── getListsFromFileSystem ────────────────────────────────────────────────── -describe('getListsFromFileSystem', () => { - it('returns an empty array when list folder does not exist', async () => { - const fileOps = makeFileOps('lists-missing'); +describe("getListsFromFileSystem", () => { + it("returns an empty array when list folder does not exist", async () => { + const fileOps = makeFileOps("lists-missing"); const result = await getListsFromFileSystem(fileOps); expect(result).toEqual([]); }); - it('returns containers from all JSON files in list folder', async () => { - const fileOps = makeFileOps('lists-has-data'); - const listDir = path.join(fileOps.instancePath, 'list'); + it("returns containers from all JSON files in list folder", async () => { + const fileOps = makeFileOps("lists-has-data"); + const listDir = path.join(fileOps.instancePath, "list"); fs.mkdirSync(listDir, { recursive: true }); - const container1 = { referenceName: 'blogposts', contentCount: 5 }; - const container2 = { referenceName: 'articles', contentCount: 3 }; - fs.writeFileSync(path.join(listDir, 'blogposts.json'), JSON.stringify(container1)); - fs.writeFileSync(path.join(listDir, 'articles.json'), JSON.stringify(container2)); + const container1 = { referenceName: "blogposts", contentCount: 5 }; + const container2 = { referenceName: "articles", contentCount: 3 }; + fs.writeFileSync(path.join(listDir, "blogposts.json"), JSON.stringify(container1)); + fs.writeFileSync(path.join(listDir, "articles.json"), JSON.stringify(container2)); const result = await getListsFromFileSystem(fileOps); expect(result).toHaveLength(2); const refs = (result as any[]).map((c: any) => c.referenceName); - expect(refs).toContain('blogposts'); - expect(refs).toContain('articles'); + expect(refs).toContain("blogposts"); + expect(refs).toContain("articles"); }); - it('returns an empty array when list folder is empty', async () => { - const fileOps = makeFileOps('lists-empty-dir'); - const listDir = path.join(fileOps.instancePath, 'list'); + it("returns an empty array when list folder is empty", async () => { + const fileOps = makeFileOps("lists-empty-dir"); + const listDir = path.join(fileOps.instancePath, "list"); fs.mkdirSync(listDir, { recursive: true }); const result = await getListsFromFileSystem(fileOps); @@ -72,33 +72,33 @@ describe('getListsFromFileSystem', () => { // ─── getContainersFromFileSystem ───────────────────────────────────────────── -describe('getContainersFromFileSystem', () => { - it('returns an empty array when containers folder does not exist', () => { - const fileOps = makeFileOps('containers-missing'); +describe("getContainersFromFileSystem", () => { + it("returns an empty array when containers folder does not exist", () => { + const fileOps = makeFileOps("containers-missing"); const result = getContainersFromFileSystem(fileOps); expect(result).toEqual([]); }); - it('returns containers from all JSON files in containers folder', () => { - const fileOps = makeFileOps('containers-has-data'); - const containersDir = path.join(fileOps.instancePath, 'containers'); + it("returns containers from all JSON files in containers folder", () => { + const fileOps = makeFileOps("containers-has-data"); + const containersDir = path.join(fileOps.instancePath, "containers"); fs.mkdirSync(containersDir, { recursive: true }); - const c1 = { referenceName: 'news', contentViewID: 100 }; - const c2 = { referenceName: 'events', contentViewID: 101 }; - fs.writeFileSync(path.join(containersDir, '100.json'), JSON.stringify(c1)); - fs.writeFileSync(path.join(containersDir, '101.json'), JSON.stringify(c2)); + const c1 = { referenceName: "news", contentViewID: 100 }; + const c2 = { referenceName: "events", contentViewID: 101 }; + fs.writeFileSync(path.join(containersDir, "100.json"), JSON.stringify(c1)); + fs.writeFileSync(path.join(containersDir, "101.json"), JSON.stringify(c2)); const result = getContainersFromFileSystem(fileOps); expect(result).toHaveLength(2); const refs = result.map((c: any) => c.referenceName); - expect(refs).toContain('news'); - expect(refs).toContain('events'); + expect(refs).toContain("news"); + expect(refs).toContain("events"); }); - it('returns an empty array when containers folder is empty', () => { - const fileOps = makeFileOps('containers-empty-dir'); - const containersDir = path.join(fileOps.instancePath, 'containers'); + it("returns an empty array when containers folder is empty", () => { + const fileOps = makeFileOps("containers-empty-dir"); + const containersDir = path.join(fileOps.instancePath, "containers"); fs.mkdirSync(containersDir, { recursive: true }); const result = getContainersFromFileSystem(fileOps); diff --git a/src/lib/getters/filesystem/tests/get-content-items.test.ts b/src/lib/getters/filesystem/tests/get-content-items.test.ts index 6a4262a..369e658 100644 --- a/src/lib/getters/filesystem/tests/get-content-items.test.ts +++ b/src/lib/getters/filesystem/tests/get-content-items.test.ts @@ -1,14 +1,14 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, setState } from 'core/state'; -import { fileOperations } from 'core/fileOperations'; -import { getContentItemsFromFileSystem } from '../get-content-items'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState } from "core/state"; +import { fileOperations } from "core/fileOperations"; +import { getContentItemsFromFileSystem } from "../get-content-items"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-test-get-content-items-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-test-get-content-items-")); }); afterAll(() => { @@ -17,9 +17,9 @@ afterAll(() => { beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -30,19 +30,19 @@ function makeFileOps(subDir: string): fileOperations { const root = path.join(tmpDir, subDir); fs.mkdirSync(root, { recursive: true }); setState({ rootPath: root }); - return new fileOperations('test-guid', 'en-us'); + return new fileOperations("test-guid", "en-us"); } -describe('getContentItemsFromFileSystem', () => { - it('returns an empty array when item folder does not exist', () => { - const fileOps = makeFileOps('content-missing'); +describe("getContentItemsFromFileSystem", () => { + it("returns an empty array when item folder does not exist", () => { + const fileOps = makeFileOps("content-missing"); const result = getContentItemsFromFileSystem(fileOps); expect(result).toEqual([]); }); - it('returns an empty array when item folder has no JSON files', () => { - const fileOps = makeFileOps('content-empty-dir'); - const itemDir = path.join(fileOps.instancePath, 'item'); + it("returns an empty array when item folder has no JSON files", () => { + const fileOps = makeFileOps("content-empty-dir"); + const itemDir = path.join(fileOps.instancePath, "item"); fs.mkdirSync(itemDir, { recursive: true }); const result = getContentItemsFromFileSystem(fileOps); @@ -50,14 +50,14 @@ describe('getContentItemsFromFileSystem', () => { expect(result).toEqual([]); }); - it('returns content items from all JSON files in item folder', () => { - const fileOps = makeFileOps('content-has-data'); - const itemDir = path.join(fileOps.instancePath, 'item'); + it("returns content items from all JSON files in item folder", () => { + const fileOps = makeFileOps("content-has-data"); + const itemDir = path.join(fileOps.instancePath, "item"); fs.mkdirSync(itemDir, { recursive: true }); - const item1 = { contentID: 1, fields: { title: 'First Post' } }; - const item2 = { contentID: 2, fields: { title: 'Second Post' } }; - fs.writeFileSync(path.join(itemDir, '1.json'), JSON.stringify(item1)); - fs.writeFileSync(path.join(itemDir, '2.json'), JSON.stringify(item2)); + const item1 = { contentID: 1, fields: { title: "First Post" } }; + const item2 = { contentID: 2, fields: { title: "Second Post" } }; + fs.writeFileSync(path.join(itemDir, "1.json"), JSON.stringify(item1)); + fs.writeFileSync(path.join(itemDir, "2.json"), JSON.stringify(item2)); const result = getContentItemsFromFileSystem(fileOps); @@ -67,12 +67,12 @@ describe('getContentItemsFromFileSystem', () => { expect(ids).toContain(2); }); - it('returns a single content item when exactly one file exists', () => { - const fileOps = makeFileOps('content-single'); - const itemDir = path.join(fileOps.instancePath, 'item'); + it("returns a single content item when exactly one file exists", () => { + const fileOps = makeFileOps("content-single"); + const itemDir = path.join(fileOps.instancePath, "item"); fs.mkdirSync(itemDir, { recursive: true }); - const item = { contentID: 42, fields: { title: 'Only Item' } }; - fs.writeFileSync(path.join(itemDir, '42.json'), JSON.stringify(item)); + const item = { contentID: 42, fields: { title: "Only Item" } }; + fs.writeFileSync(path.join(itemDir, "42.json"), JSON.stringify(item)); const result = getContentItemsFromFileSystem(fileOps); @@ -80,32 +80,32 @@ describe('getContentItemsFromFileSystem', () => { expect((result[0] as any).contentID).toBe(42); }); - it('includes all content from item folder without deduplication', () => { - const fileOps = makeFileOps('content-no-dedup'); - const itemDir = path.join(fileOps.instancePath, 'item'); + it("includes all content from item folder without deduplication", () => { + const fileOps = makeFileOps("content-no-dedup"); + const itemDir = path.join(fileOps.instancePath, "item"); fs.mkdirSync(itemDir, { recursive: true }); const item1 = { contentID: 10 }; const item2 = { contentID: 20 }; const item3 = { contentID: 30 }; - fs.writeFileSync(path.join(itemDir, '10.json'), JSON.stringify(item1)); - fs.writeFileSync(path.join(itemDir, '20.json'), JSON.stringify(item2)); - fs.writeFileSync(path.join(itemDir, '30.json'), JSON.stringify(item3)); + fs.writeFileSync(path.join(itemDir, "10.json"), JSON.stringify(item1)); + fs.writeFileSync(path.join(itemDir, "20.json"), JSON.stringify(item2)); + fs.writeFileSync(path.join(itemDir, "30.json"), JSON.stringify(item3)); const result = getContentItemsFromFileSystem(fileOps); expect(result).toHaveLength(3); }); - it('does not load content from list folder', () => { - const fileOps = makeFileOps('content-no-list'); - const itemDir = path.join(fileOps.instancePath, 'item'); - const listDir = path.join(fileOps.instancePath, 'list'); + it("does not load content from list folder", () => { + const fileOps = makeFileOps("content-no-list"); + const itemDir = path.join(fileOps.instancePath, "item"); + const listDir = path.join(fileOps.instancePath, "list"); fs.mkdirSync(itemDir, { recursive: true }); fs.mkdirSync(listDir, { recursive: true }); const item = { contentID: 5 }; const listItems = [{ contentID: 100 }, { contentID: 101 }]; - fs.writeFileSync(path.join(itemDir, '5.json'), JSON.stringify(item)); - fs.writeFileSync(path.join(listDir, 'someList.json'), JSON.stringify(listItems)); + fs.writeFileSync(path.join(itemDir, "5.json"), JSON.stringify(item)); + fs.writeFileSync(path.join(listDir, "someList.json"), JSON.stringify(listItems)); const result = getContentItemsFromFileSystem(fileOps); diff --git a/src/lib/getters/filesystem/tests/get-galleries.test.ts b/src/lib/getters/filesystem/tests/get-galleries.test.ts index 3efb36a..c9b7c5b 100644 --- a/src/lib/getters/filesystem/tests/get-galleries.test.ts +++ b/src/lib/getters/filesystem/tests/get-galleries.test.ts @@ -1,14 +1,14 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, setState } from 'core/state'; -import { fileOperations } from 'core/fileOperations'; -import { getGalleriesFromFileSystem } from '../get-galleries'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState } from "core/state"; +import { fileOperations } from "core/fileOperations"; +import { getGalleriesFromFileSystem } from "../get-galleries"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-test-get-galleries-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-test-get-galleries-")); }); afterAll(() => { @@ -17,9 +17,9 @@ afterAll(() => { beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -30,19 +30,19 @@ function makeFileOps(subDir: string): fileOperations { const root = path.join(tmpDir, subDir); fs.mkdirSync(root, { recursive: true }); setState({ rootPath: root }); - return new fileOperations('test-guid', 'en-us'); + return new fileOperations("test-guid", "en-us"); } -describe('getGalleriesFromFileSystem', () => { - it('throws or returns empty when galleries folder does not exist', () => { - const fileOps = makeFileOps('galleries-missing'); +describe("getGalleriesFromFileSystem", () => { + it("throws or returns empty when galleries folder does not exist", () => { + const fileOps = makeFileOps("galleries-missing"); // getFolderContents (readdirSync) throws when directory does not exist expect(() => getGalleriesFromFileSystem(fileOps)).toThrow(); }); - it('returns an empty array when galleries folder is empty', () => { - const fileOps = makeFileOps('galleries-empty-dir'); - const galleriesDir = path.join(fileOps.instancePath, 'galleries'); + it("returns an empty array when galleries folder is empty", () => { + const fileOps = makeFileOps("galleries-empty-dir"); + const galleriesDir = path.join(fileOps.instancePath, "galleries"); fs.mkdirSync(galleriesDir, { recursive: true }); const result = getGalleriesFromFileSystem(fileOps); @@ -50,14 +50,14 @@ describe('getGalleriesFromFileSystem', () => { expect(result).toEqual([]); }); - it('returns galleries from all JSON files in the folder', () => { - const fileOps = makeFileOps('galleries-has-data'); - const galleriesDir = path.join(fileOps.instancePath, 'galleries'); + it("returns galleries from all JSON files in the folder", () => { + const fileOps = makeFileOps("galleries-has-data"); + const galleriesDir = path.join(fileOps.instancePath, "galleries"); fs.mkdirSync(galleriesDir, { recursive: true }); - const g1 = { mediaGroupingID: 1, name: 'Gallery One' }; - const g2 = { mediaGroupingID: 2, name: 'Gallery Two' }; - fs.writeFileSync(path.join(galleriesDir, '1.json'), JSON.stringify(g1)); - fs.writeFileSync(path.join(galleriesDir, '2.json'), JSON.stringify(g2)); + const g1 = { mediaGroupingID: 1, name: "Gallery One" }; + const g2 = { mediaGroupingID: 2, name: "Gallery Two" }; + fs.writeFileSync(path.join(galleriesDir, "1.json"), JSON.stringify(g1)); + fs.writeFileSync(path.join(galleriesDir, "2.json"), JSON.stringify(g2)); const result = getGalleriesFromFileSystem(fileOps); @@ -67,14 +67,14 @@ describe('getGalleriesFromFileSystem', () => { expect(ids).toContain(2); }); - it('deduplicates galleries with the same mediaGroupingID', () => { - const fileOps = makeFileOps('galleries-deduplicate'); - const galleriesDir = path.join(fileOps.instancePath, 'galleries'); + it("deduplicates galleries with the same mediaGroupingID", () => { + const fileOps = makeFileOps("galleries-deduplicate"); + const galleriesDir = path.join(fileOps.instancePath, "galleries"); fs.mkdirSync(galleriesDir, { recursive: true }); - const gallery = { mediaGroupingID: 5, name: 'Shared Gallery' }; + const gallery = { mediaGroupingID: 5, name: "Shared Gallery" }; // Write the same gallery data under two file names - fs.writeFileSync(path.join(galleriesDir, '5a.json'), JSON.stringify(gallery)); - fs.writeFileSync(path.join(galleriesDir, '5b.json'), JSON.stringify(gallery)); + fs.writeFileSync(path.join(galleriesDir, "5a.json"), JSON.stringify(gallery)); + fs.writeFileSync(path.join(galleriesDir, "5b.json"), JSON.stringify(gallery)); const result = getGalleriesFromFileSystem(fileOps); @@ -82,9 +82,9 @@ describe('getGalleriesFromFileSystem', () => { expect((result[0] as any).mediaGroupingID).toBe(5); }); - it('keeps all galleries when mediaGroupingIDs are unique', () => { - const fileOps = makeFileOps('galleries-no-dedup-needed'); - const galleriesDir = path.join(fileOps.instancePath, 'galleries'); + it("keeps all galleries when mediaGroupingIDs are unique", () => { + const fileOps = makeFileOps("galleries-no-dedup-needed"); + const galleriesDir = path.join(fileOps.instancePath, "galleries"); fs.mkdirSync(galleriesDir, { recursive: true }); [10, 11, 12].forEach((id) => { fs.writeFileSync( @@ -98,12 +98,12 @@ describe('getGalleriesFromFileSystem', () => { expect(result).toHaveLength(3); }); - it('skips malformed JSON files without throwing', () => { - const fileOps = makeFileOps('galleries-bad-json'); - const galleriesDir = path.join(fileOps.instancePath, 'galleries'); + it("skips malformed JSON files without throwing", () => { + const fileOps = makeFileOps("galleries-bad-json"); + const galleriesDir = path.join(fileOps.instancePath, "galleries"); fs.mkdirSync(galleriesDir, { recursive: true }); - fs.writeFileSync(path.join(galleriesDir, 'invalid.json'), 'NOT JSON AT ALL {{{'); - fs.writeFileSync(path.join(galleriesDir, '1.json'), JSON.stringify({ mediaGroupingID: 1, name: 'Good' })); + fs.writeFileSync(path.join(galleriesDir, "invalid.json"), "NOT JSON AT ALL {{{"); + fs.writeFileSync(path.join(galleriesDir, "1.json"), JSON.stringify({ mediaGroupingID: 1, name: "Good" })); const result = getGalleriesFromFileSystem(fileOps); diff --git a/src/lib/getters/filesystem/tests/get-models.test.ts b/src/lib/getters/filesystem/tests/get-models.test.ts index 022415e..8a8f39f 100644 --- a/src/lib/getters/filesystem/tests/get-models.test.ts +++ b/src/lib/getters/filesystem/tests/get-models.test.ts @@ -1,14 +1,14 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, setState } from 'core/state'; -import { fileOperations } from 'core/fileOperations'; -import { getModelsFromFileSystem } from '../get-models'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState } from "core/state"; +import { fileOperations } from "core/fileOperations"; +import { getModelsFromFileSystem } from "../get-models"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-test-get-models-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-test-get-models-")); }); afterAll(() => { @@ -17,9 +17,9 @@ afterAll(() => { beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -30,19 +30,19 @@ function makeFileOps(subDir: string): fileOperations { const root = path.join(tmpDir, subDir); fs.mkdirSync(root, { recursive: true }); setState({ rootPath: root }); - return new fileOperations('test-guid', 'en-us'); + return new fileOperations("test-guid", "en-us"); } -describe('getModelsFromFileSystem', () => { - it('returns an empty array when models folder does not exist', () => { - const fileOps = makeFileOps('models-missing'); +describe("getModelsFromFileSystem", () => { + it("returns an empty array when models folder does not exist", () => { + const fileOps = makeFileOps("models-missing"); const result = getModelsFromFileSystem(fileOps); expect(result).toEqual([]); }); - it('returns an empty array when models folder has no JSON files', () => { - const fileOps = makeFileOps('models-empty-dir'); - const modelsDir = path.join(fileOps.instancePath, 'models'); + it("returns an empty array when models folder has no JSON files", () => { + const fileOps = makeFileOps("models-empty-dir"); + const modelsDir = path.join(fileOps.instancePath, "models"); fs.mkdirSync(modelsDir, { recursive: true }); const result = getModelsFromFileSystem(fileOps); @@ -50,14 +50,14 @@ describe('getModelsFromFileSystem', () => { expect(result).toEqual([]); }); - it('returns models from all JSON files without transformation', () => { - const fileOps = makeFileOps('models-has-data'); - const modelsDir = path.join(fileOps.instancePath, 'models'); + it("returns models from all JSON files without transformation", () => { + const fileOps = makeFileOps("models-has-data"); + const modelsDir = path.join(fileOps.instancePath, "models"); fs.mkdirSync(modelsDir, { recursive: true }); - const model1 = { id: 1, referenceName: 'BlogPost', displayName: 'Blog Post' }; - const model2 = { id: 2, referenceName: 'Article', displayName: 'Article' }; - fs.writeFileSync(path.join(modelsDir, '1.json'), JSON.stringify(model1)); - fs.writeFileSync(path.join(modelsDir, '2.json'), JSON.stringify(model2)); + const model1 = { id: 1, referenceName: "BlogPost", displayName: "Blog Post" }; + const model2 = { id: 2, referenceName: "Article", displayName: "Article" }; + fs.writeFileSync(path.join(modelsDir, "1.json"), JSON.stringify(model1)); + fs.writeFileSync(path.join(modelsDir, "2.json"), JSON.stringify(model2)); const result = getModelsFromFileSystem(fileOps); @@ -66,17 +66,17 @@ describe('getModelsFromFileSystem', () => { expect(result[1]).toMatchObject(model2); }); - it('returns the raw model data as-is (no transformation)', () => { - const fileOps = makeFileOps('models-raw'); - const modelsDir = path.join(fileOps.instancePath, 'models'); + it("returns the raw model data as-is (no transformation)", () => { + const fileOps = makeFileOps("models-raw"); + const modelsDir = path.join(fileOps.instancePath, "models"); fs.mkdirSync(modelsDir, { recursive: true }); const model = { id: 99, - referenceName: 'TestModel', - fields: [{ name: 'title', type: 'Text' }], - lastModifiedDate: '2025-01-01', + referenceName: "TestModel", + fields: [{ name: "title", type: "Text" }], + lastModifiedDate: "2025-01-01", }; - fs.writeFileSync(path.join(modelsDir, '99.json'), JSON.stringify(model)); + fs.writeFileSync(path.join(modelsDir, "99.json"), JSON.stringify(model)); const result = getModelsFromFileSystem(fileOps); @@ -84,16 +84,16 @@ describe('getModelsFromFileSystem', () => { expect(result[0]).toEqual(model); }); - it('returns a single model when exactly one file exists', () => { - const fileOps = makeFileOps('models-single'); - const modelsDir = path.join(fileOps.instancePath, 'models'); + it("returns a single model when exactly one file exists", () => { + const fileOps = makeFileOps("models-single"); + const modelsDir = path.join(fileOps.instancePath, "models"); fs.mkdirSync(modelsDir, { recursive: true }); - const model = { id: 5, referenceName: 'SingleModel' }; - fs.writeFileSync(path.join(modelsDir, '5.json'), JSON.stringify(model)); + const model = { id: 5, referenceName: "SingleModel" }; + fs.writeFileSync(path.join(modelsDir, "5.json"), JSON.stringify(model)); const result = getModelsFromFileSystem(fileOps); expect(result).toHaveLength(1); - expect((result[0] as any).referenceName).toBe('SingleModel'); + expect((result[0] as any).referenceName).toBe("SingleModel"); }); }); diff --git a/src/lib/getters/filesystem/tests/get-pages.test.ts b/src/lib/getters/filesystem/tests/get-pages.test.ts index c8959c5..5159f44 100644 --- a/src/lib/getters/filesystem/tests/get-pages.test.ts +++ b/src/lib/getters/filesystem/tests/get-pages.test.ts @@ -1,14 +1,14 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, setState } from 'core/state'; -import { fileOperations } from 'core/fileOperations'; -import { getPagesFromFileSystem } from '../get-pages'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState } from "core/state"; +import { fileOperations } from "core/fileOperations"; +import { getPagesFromFileSystem } from "../get-pages"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-test-get-pages-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-test-get-pages-")); }); afterAll(() => { @@ -17,9 +17,9 @@ afterAll(() => { beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -30,19 +30,19 @@ function makeFileOps(subDir: string): fileOperations { const root = path.join(tmpDir, subDir); fs.mkdirSync(root, { recursive: true }); setState({ rootPath: root }); - return new fileOperations('test-guid', 'en-us'); + return new fileOperations("test-guid", "en-us"); } -describe('getPagesFromFileSystem', () => { - it('returns an empty array when page folder does not exist', () => { - const fileOps = makeFileOps('pages-missing'); +describe("getPagesFromFileSystem", () => { + it("returns an empty array when page folder does not exist", () => { + const fileOps = makeFileOps("pages-missing"); const result = getPagesFromFileSystem(fileOps); expect(result).toEqual([]); }); - it('returns an empty array when page folder has no JSON files', () => { - const fileOps = makeFileOps('pages-empty-dir'); - const pageDir = path.join(fileOps.instancePath, 'page'); + it("returns an empty array when page folder has no JSON files", () => { + const fileOps = makeFileOps("pages-empty-dir"); + const pageDir = path.join(fileOps.instancePath, "page"); fs.mkdirSync(pageDir, { recursive: true }); const result = getPagesFromFileSystem(fileOps); @@ -50,14 +50,14 @@ describe('getPagesFromFileSystem', () => { expect(result).toEqual([]); }); - it('returns page items from all JSON files', () => { - const fileOps = makeFileOps('pages-has-data'); - const pageDir = path.join(fileOps.instancePath, 'page'); + it("returns page items from all JSON files", () => { + const fileOps = makeFileOps("pages-has-data"); + const pageDir = path.join(fileOps.instancePath, "page"); fs.mkdirSync(pageDir, { recursive: true }); - const page1 = { pageID: 1, title: 'Home', path: '/' }; - const page2 = { pageID: 2, title: 'About', path: '/about' }; - fs.writeFileSync(path.join(pageDir, '1.json'), JSON.stringify(page1)); - fs.writeFileSync(path.join(pageDir, '2.json'), JSON.stringify(page2)); + const page1 = { pageID: 1, title: "Home", path: "/" }; + const page2 = { pageID: 2, title: "About", path: "/about" }; + fs.writeFileSync(path.join(pageDir, "1.json"), JSON.stringify(page1)); + fs.writeFileSync(path.join(pageDir, "2.json"), JSON.stringify(page2)); const result = getPagesFromFileSystem(fileOps); @@ -67,12 +67,12 @@ describe('getPagesFromFileSystem', () => { expect(ids).toContain(2); }); - it('returns page data cast as PageItem', () => { - const fileOps = makeFileOps('pages-cast'); - const pageDir = path.join(fileOps.instancePath, 'page'); + it("returns page data cast as PageItem", () => { + const fileOps = makeFileOps("pages-cast"); + const pageDir = path.join(fileOps.instancePath, "page"); fs.mkdirSync(pageDir, { recursive: true }); - const page = { pageID: 10, title: 'Contact', zones: {} }; - fs.writeFileSync(path.join(pageDir, '10.json'), JSON.stringify(page)); + const page = { pageID: 10, title: "Contact", zones: {} }; + fs.writeFileSync(path.join(pageDir, "10.json"), JSON.stringify(page)); const result = getPagesFromFileSystem(fileOps); @@ -80,12 +80,12 @@ describe('getPagesFromFileSystem', () => { expect(result[0]).toMatchObject(page); }); - it('returns a single page when exactly one file exists', () => { - const fileOps = makeFileOps('pages-single'); - const pageDir = path.join(fileOps.instancePath, 'page'); + it("returns a single page when exactly one file exists", () => { + const fileOps = makeFileOps("pages-single"); + const pageDir = path.join(fileOps.instancePath, "page"); fs.mkdirSync(pageDir, { recursive: true }); - const page = { pageID: 7, title: 'Blog' }; - fs.writeFileSync(path.join(pageDir, '7.json'), JSON.stringify(page)); + const page = { pageID: 7, title: "Blog" }; + fs.writeFileSync(path.join(pageDir, "7.json"), JSON.stringify(page)); const result = getPagesFromFileSystem(fileOps); diff --git a/src/lib/getters/filesystem/tests/get-templates.test.ts b/src/lib/getters/filesystem/tests/get-templates.test.ts index c795fb1..c57c2e1 100644 --- a/src/lib/getters/filesystem/tests/get-templates.test.ts +++ b/src/lib/getters/filesystem/tests/get-templates.test.ts @@ -1,14 +1,14 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, setState } from 'core/state'; -import { fileOperations } from 'core/fileOperations'; -import { getTemplatesFromFileSystem } from '../get-templates'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState } from "core/state"; +import { fileOperations } from "core/fileOperations"; +import { getTemplatesFromFileSystem } from "../get-templates"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-test-get-templates-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-test-get-templates-")); }); afterAll(() => { @@ -17,9 +17,9 @@ afterAll(() => { beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -30,19 +30,19 @@ function makeFileOps(subDir: string): fileOperations { const root = path.join(tmpDir, subDir); fs.mkdirSync(root, { recursive: true }); setState({ rootPath: root }); - return new fileOperations('test-guid', 'en-us'); + return new fileOperations("test-guid", "en-us"); } -describe('getTemplatesFromFileSystem', () => { - it('returns an empty array when templates folder does not exist', () => { - const fileOps = makeFileOps('templates-missing'); +describe("getTemplatesFromFileSystem", () => { + it("returns an empty array when templates folder does not exist", () => { + const fileOps = makeFileOps("templates-missing"); const result = getTemplatesFromFileSystem(fileOps); expect(result).toEqual([]); }); - it('returns an empty array when templates folder has no JSON files', () => { - const fileOps = makeFileOps('templates-empty-dir'); - const templatesDir = path.join(fileOps.instancePath, 'templates'); + it("returns an empty array when templates folder has no JSON files", () => { + const fileOps = makeFileOps("templates-empty-dir"); + const templatesDir = path.join(fileOps.instancePath, "templates"); fs.mkdirSync(templatesDir, { recursive: true }); const result = getTemplatesFromFileSystem(fileOps); @@ -50,29 +50,29 @@ describe('getTemplatesFromFileSystem', () => { expect(result).toEqual([]); }); - it('returns templates from all JSON files', () => { - const fileOps = makeFileOps('templates-has-data'); - const templatesDir = path.join(fileOps.instancePath, 'templates'); + it("returns templates from all JSON files", () => { + const fileOps = makeFileOps("templates-has-data"); + const templatesDir = path.join(fileOps.instancePath, "templates"); fs.mkdirSync(templatesDir, { recursive: true }); - const t1 = { pageModelID: 1, referenceName: 'FullWidthTemplate' }; - const t2 = { pageModelID: 2, referenceName: 'TwoColumnTemplate' }; - fs.writeFileSync(path.join(templatesDir, '1.json'), JSON.stringify(t1)); - fs.writeFileSync(path.join(templatesDir, '2.json'), JSON.stringify(t2)); + const t1 = { pageModelID: 1, referenceName: "FullWidthTemplate" }; + const t2 = { pageModelID: 2, referenceName: "TwoColumnTemplate" }; + fs.writeFileSync(path.join(templatesDir, "1.json"), JSON.stringify(t1)); + fs.writeFileSync(path.join(templatesDir, "2.json"), JSON.stringify(t2)); const result = getTemplatesFromFileSystem(fileOps); expect(result).toHaveLength(2); const refs = result.map((t: any) => t.referenceName); - expect(refs).toContain('FullWidthTemplate'); - expect(refs).toContain('TwoColumnTemplate'); + expect(refs).toContain("FullWidthTemplate"); + expect(refs).toContain("TwoColumnTemplate"); }); - it('returns template data cast as PageModel', () => { - const fileOps = makeFileOps('templates-cast'); - const templatesDir = path.join(fileOps.instancePath, 'templates'); + it("returns template data cast as PageModel", () => { + const fileOps = makeFileOps("templates-cast"); + const templatesDir = path.join(fileOps.instancePath, "templates"); fs.mkdirSync(templatesDir, { recursive: true }); - const template = { pageModelID: 5, referenceName: 'LandingPage', zones: ['main', 'sidebar'] }; - fs.writeFileSync(path.join(templatesDir, '5.json'), JSON.stringify(template)); + const template = { pageModelID: 5, referenceName: "LandingPage", zones: ["main", "sidebar"] }; + fs.writeFileSync(path.join(templatesDir, "5.json"), JSON.stringify(template)); const result = getTemplatesFromFileSystem(fileOps); @@ -80,16 +80,16 @@ describe('getTemplatesFromFileSystem', () => { expect(result[0]).toMatchObject(template); }); - it('returns a single template when exactly one file exists', () => { - const fileOps = makeFileOps('templates-single'); - const templatesDir = path.join(fileOps.instancePath, 'templates'); + it("returns a single template when exactly one file exists", () => { + const fileOps = makeFileOps("templates-single"); + const templatesDir = path.join(fileOps.instancePath, "templates"); fs.mkdirSync(templatesDir, { recursive: true }); - const template = { pageModelID: 3, referenceName: 'BlogPost' }; - fs.writeFileSync(path.join(templatesDir, '3.json'), JSON.stringify(template)); + const template = { pageModelID: 3, referenceName: "BlogPost" }; + fs.writeFileSync(path.join(templatesDir, "3.json"), JSON.stringify(template)); const result = getTemplatesFromFileSystem(fileOps); expect(result).toHaveLength(1); - expect((result[0] as any).referenceName).toBe('BlogPost'); + expect((result[0] as any).referenceName).toBe("BlogPost"); }); }); diff --git a/src/lib/incremental/date-extractors.ts b/src/lib/incremental/date-extractors.ts index 590067e..2cf05b9 100644 --- a/src/lib/incremental/date-extractors.ts +++ b/src/lib/incremental/date-extractors.ts @@ -1,6 +1,6 @@ /** * Entity-specific modified date extractors for incremental pull operations - * + * * Based on analysis of all 7 entity types from Task 26.3: * - Models: lastModifiedDate (ISO 8601) * - Containers: lastModifiedDate (Human-readable: "03/05/2025 08:11AM") @@ -18,7 +18,7 @@ */ export function extractModelModifiedDate(model: any): string | null { try { - if (model?.lastModifiedDate && typeof model.lastModifiedDate === 'string') { + if (model?.lastModifiedDate && typeof model.lastModifiedDate === "string") { // Already in ISO 8601 format: "2025-06-24T15:23:26.07" return normalizeToISO8601(model.lastModifiedDate); } @@ -36,7 +36,7 @@ export function extractModelModifiedDate(model: any): string | null { */ export function extractContainerModifiedDate(container: any): string | null { try { - if (container?.lastModifiedDate && typeof container.lastModifiedDate === 'string') { + if (container?.lastModifiedDate && typeof container.lastModifiedDate === "string") { // Human-readable format: "03/05/2025 08:11AM" - needs parsing return parseHumanReadableDate(container.lastModifiedDate); } @@ -54,7 +54,7 @@ export function extractContainerModifiedDate(container: any): string | null { */ export function extractContentItemModifiedDate(contentItem: any): string | null { try { - if (contentItem?.properties?.modified && typeof contentItem.properties.modified === 'string') { + if (contentItem?.properties?.modified && typeof contentItem.properties.modified === "string") { // Already in ISO 8601 format: "2025-06-20T06:45:38.203" return normalizeToISO8601(contentItem.properties.modified); } @@ -72,7 +72,7 @@ export function extractContentItemModifiedDate(contentItem: any): string | null */ export function extractAssetModifiedDate(asset: any): string | null { try { - if (asset?.dateModified && typeof asset.dateModified === 'string') { + if (asset?.dateModified && typeof asset.dateModified === "string") { // Already in ISO 8601 format: "2025-03-06T03:38:21.25" return normalizeToISO8601(asset.dateModified); } @@ -90,7 +90,7 @@ export function extractAssetModifiedDate(asset: any): string | null { */ export function extractPageModifiedDate(page: any): string | null { try { - if (page?.properties?.modified && typeof page.properties.modified === 'string') { + if (page?.properties?.modified && typeof page.properties.modified === "string") { // Already in ISO 8601 format: "2025-06-19T09:09:45.413" return normalizeToISO8601(page.properties.modified); } @@ -108,7 +108,7 @@ export function extractPageModifiedDate(page: any): string | null { */ export function extractGalleryModifiedDate(gallery: any): string | null { try { - if (gallery?.modifiedOn && typeof gallery.modifiedOn === 'string') { + if (gallery?.modifiedOn && typeof gallery.modifiedOn === "string") { // Already in ISO 8601 format: "2025-04-28T08:54:50.773" return normalizeToISO8601(gallery.modifiedOn); } @@ -139,16 +139,16 @@ export function extractTemplateModifiedDate(template: any): string | null { function parseHumanReadableDate(humanDate: string): string | null { try { // Import date-fns parse function for proper date parsing - const { parse } = require('date-fns'); - + const { parse } = require("date-fns"); + // Format: "MM/dd/yyyy hh:mma" (e.g., "08/25/2025 02:01AM") const parsed = parse(humanDate, "MM/dd/yyyy hh:mma", new Date()); - + if (isNaN(parsed.getTime())) { console.warn(`Failed to parse human date format: ${humanDate}`); return null; } - + return parsed.toISOString(); } catch (error) { console.warn(`Error parsing human date format "${humanDate}":`, error); @@ -164,12 +164,12 @@ function parseHumanReadableDate(humanDate: string): string | null { function normalizeToISO8601(isoDate: string): string | null { try { const parsed = new Date(isoDate); - + if (isNaN(parsed.getTime())) { console.warn(`Failed to parse ISO date: ${isoDate}`); return null; } - + return parsed.toISOString(); } catch (error) { console.warn(`Error normalizing ISO date "${isoDate}":`, error); @@ -184,20 +184,20 @@ function normalizeToISO8601(isoDate: string): string | null { */ export function getDateExtractorForEntityType(entityType: string): ((entity: any) => string | null) | null { switch (entityType.toLowerCase()) { - case 'models': + case "models": return extractModelModifiedDate; - case 'containers': + case "containers": return extractContainerModifiedDate; - case 'content': - case 'items': + case "content": + case "items": return extractContentItemModifiedDate; - case 'assets': + case "assets": return extractAssetModifiedDate; - case 'pages': + case "pages": return extractPageModifiedDate; - case 'galleries': + case "galleries": return extractGalleryModifiedDate; - case 'templates': + case "templates": return extractTemplateModifiedDate; // Always returns null default: console.warn(`Unknown entity type for date extraction: ${entityType}`); @@ -208,18 +208,9 @@ export function getDateExtractorForEntityType(entityType: string): ((entity: any /** * Entity types that support incremental pulling (have modified dates) */ -export const INCREMENTAL_SUPPORTED_TYPES = [ - 'models', - 'containers', - 'content', - 'assets', - 'pages', - 'galleries' -]; +export const INCREMENTAL_SUPPORTED_TYPES = ["models", "containers", "content", "assets", "pages", "galleries"]; /** * Entity types that require full refresh (no modified dates) */ -export const FULL_REFRESH_REQUIRED_TYPES = [ - 'templates' -]; \ No newline at end of file +export const FULL_REFRESH_REQUIRED_TYPES = ["templates"]; diff --git a/src/lib/incremental/index.ts b/src/lib/incremental/index.ts index f64b50b..ce8de50 100644 --- a/src/lib/incremental/index.ts +++ b/src/lib/incremental/index.ts @@ -1,6 +1,6 @@ /** * Incremental Pull Utilities - * + * * Exports all utilities needed for incremental pull operations: * - Entity-specific modified date extractors * - Timestamp tracking system @@ -18,8 +18,8 @@ export { extractTemplateModifiedDate, getDateExtractorForEntityType, INCREMENTAL_SUPPORTED_TYPES, - FULL_REFRESH_REQUIRED_TYPES -} from './date-extractors'; + FULL_REFRESH_REQUIRED_TYPES, +} from "./date-extractors"; // Timestamp tracking system export { @@ -32,5 +32,5 @@ export { markPullStart, markPushStart, clearTimestamps, - getIncrementalPullDecision -} from './timestamp-tracker'; \ No newline at end of file + getIncrementalPullDecision, +} from "./timestamp-tracker"; diff --git a/src/lib/incremental/tests/date-extractors.test.ts b/src/lib/incremental/tests/date-extractors.test.ts index 7883509..c1fc1f6 100644 --- a/src/lib/incremental/tests/date-extractors.test.ts +++ b/src/lib/incremental/tests/date-extractors.test.ts @@ -1,4 +1,4 @@ -import { resetState } from 'core/state'; +import { resetState } from "core/state"; import { extractModelModifiedDate, extractContainerModifiedDate, @@ -10,13 +10,13 @@ import { getDateExtractorForEntityType, INCREMENTAL_SUPPORTED_TYPES, FULL_REFRESH_REQUIRED_TYPES, -} from '../date-extractors'; +} from "../date-extractors"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -26,27 +26,27 @@ afterEach(() => { // --------------------------------------------------------------------------- // extractModelModifiedDate // --------------------------------------------------------------------------- -describe('extractModelModifiedDate', () => { - it('returns ISO 8601 string for a valid lastModifiedDate', () => { - const result = extractModelModifiedDate({ lastModifiedDate: '2025-06-24T15:23:26.07' }); +describe("extractModelModifiedDate", () => { + it("returns ISO 8601 string for a valid lastModifiedDate", () => { + const result = extractModelModifiedDate({ lastModifiedDate: "2025-06-24T15:23:26.07" }); expect(result).not.toBeNull(); expect(() => new Date(result!)).not.toThrow(); expect(new Date(result!).getFullYear()).toBe(2025); }); - it('returns null when lastModifiedDate is absent', () => { + it("returns null when lastModifiedDate is absent", () => { expect(extractModelModifiedDate({})).toBeNull(); expect(extractModelModifiedDate(null)).toBeNull(); expect(extractModelModifiedDate(undefined)).toBeNull(); }); - it('returns null when lastModifiedDate is not a string', () => { + it("returns null when lastModifiedDate is not a string", () => { expect(extractModelModifiedDate({ lastModifiedDate: 12345 })).toBeNull(); expect(extractModelModifiedDate({ lastModifiedDate: null })).toBeNull(); }); - it('returns null for an unparseable string', () => { - const result = extractModelModifiedDate({ lastModifiedDate: 'not-a-date' }); + it("returns null for an unparseable string", () => { + const result = extractModelModifiedDate({ lastModifiedDate: "not-a-date" }); expect(result).toBeNull(); }); }); @@ -54,33 +54,33 @@ describe('extractModelModifiedDate', () => { // --------------------------------------------------------------------------- // extractContainerModifiedDate // --------------------------------------------------------------------------- -describe('extractContainerModifiedDate', () => { +describe("extractContainerModifiedDate", () => { it('parses the human-readable "MM/dd/yyyy hh:mma" format', () => { - const result = extractContainerModifiedDate({ lastModifiedDate: '03/05/2025 08:11AM' }); + const result = extractContainerModifiedDate({ lastModifiedDate: "03/05/2025 08:11AM" }); expect(result).not.toBeNull(); const parsed = new Date(result!); expect(isNaN(parsed.getTime())).toBe(false); expect(parsed.getFullYear()).toBe(2025); }); - it('parses a PM time correctly', () => { - const result = extractContainerModifiedDate({ lastModifiedDate: '08/25/2025 02:01PM' }); + it("parses a PM time correctly", () => { + const result = extractContainerModifiedDate({ lastModifiedDate: "08/25/2025 02:01PM" }); expect(result).not.toBeNull(); const parsed = new Date(result!); expect(parsed.getFullYear()).toBe(2025); }); - it('returns null when lastModifiedDate is absent', () => { + it("returns null when lastModifiedDate is absent", () => { expect(extractContainerModifiedDate({})).toBeNull(); expect(extractContainerModifiedDate(null)).toBeNull(); }); - it('returns null when lastModifiedDate is not a string', () => { + it("returns null when lastModifiedDate is not a string", () => { expect(extractContainerModifiedDate({ lastModifiedDate: 42 })).toBeNull(); }); - it('returns null (and warns) for an unparseable date string', () => { - const result = extractContainerModifiedDate({ lastModifiedDate: 'garbage-date' }); + it("returns null (and warns) for an unparseable date string", () => { + const result = extractContainerModifiedDate({ lastModifiedDate: "garbage-date" }); expect(result).toBeNull(); }); }); @@ -88,29 +88,29 @@ describe('extractContainerModifiedDate', () => { // --------------------------------------------------------------------------- // extractContentItemModifiedDate // --------------------------------------------------------------------------- -describe('extractContentItemModifiedDate', () => { - it('returns ISO 8601 string for a valid properties.modified', () => { - const item = { properties: { modified: '2025-06-20T06:45:38.203' } }; +describe("extractContentItemModifiedDate", () => { + it("returns ISO 8601 string for a valid properties.modified", () => { + const item = { properties: { modified: "2025-06-20T06:45:38.203" } }; const result = extractContentItemModifiedDate(item); expect(result).not.toBeNull(); expect(new Date(result!).getFullYear()).toBe(2025); }); - it('returns null when properties is absent', () => { + it("returns null when properties is absent", () => { expect(extractContentItemModifiedDate({})).toBeNull(); expect(extractContentItemModifiedDate(null)).toBeNull(); }); - it('returns null when properties.modified is absent', () => { + it("returns null when properties.modified is absent", () => { expect(extractContentItemModifiedDate({ properties: {} })).toBeNull(); }); - it('returns null when properties.modified is not a string', () => { + it("returns null when properties.modified is not a string", () => { expect(extractContentItemModifiedDate({ properties: { modified: 99 } })).toBeNull(); }); - it('returns null for an unparseable date string in properties.modified', () => { - const result = extractContentItemModifiedDate({ properties: { modified: 'bad-date' } }); + it("returns null for an unparseable date string in properties.modified", () => { + const result = extractContentItemModifiedDate({ properties: { modified: "bad-date" } }); expect(result).toBeNull(); }); }); @@ -118,48 +118,48 @@ describe('extractContentItemModifiedDate', () => { // --------------------------------------------------------------------------- // extractAssetModifiedDate // --------------------------------------------------------------------------- -describe('extractAssetModifiedDate', () => { - it('returns ISO 8601 string for a valid dateModified', () => { - const result = extractAssetModifiedDate({ dateModified: '2025-03-06T03:38:21.25' }); +describe("extractAssetModifiedDate", () => { + it("returns ISO 8601 string for a valid dateModified", () => { + const result = extractAssetModifiedDate({ dateModified: "2025-03-06T03:38:21.25" }); expect(result).not.toBeNull(); expect(new Date(result!).getFullYear()).toBe(2025); }); - it('returns null when dateModified is absent', () => { + it("returns null when dateModified is absent", () => { expect(extractAssetModifiedDate({})).toBeNull(); expect(extractAssetModifiedDate(null)).toBeNull(); }); - it('returns null when dateModified is not a string', () => { + it("returns null when dateModified is not a string", () => { expect(extractAssetModifiedDate({ dateModified: true })).toBeNull(); }); - it('returns null for an unparseable date string', () => { - expect(extractAssetModifiedDate({ dateModified: 'not-a-date' })).toBeNull(); + it("returns null for an unparseable date string", () => { + expect(extractAssetModifiedDate({ dateModified: "not-a-date" })).toBeNull(); }); }); // --------------------------------------------------------------------------- // extractPageModifiedDate // --------------------------------------------------------------------------- -describe('extractPageModifiedDate', () => { - it('returns ISO 8601 string for a valid properties.modified', () => { - const page = { properties: { modified: '2025-06-19T09:09:45.413' } }; +describe("extractPageModifiedDate", () => { + it("returns ISO 8601 string for a valid properties.modified", () => { + const page = { properties: { modified: "2025-06-19T09:09:45.413" } }; const result = extractPageModifiedDate(page); expect(result).not.toBeNull(); expect(new Date(result!).getFullYear()).toBe(2025); }); - it('returns null when properties is absent', () => { + it("returns null when properties is absent", () => { expect(extractPageModifiedDate({})).toBeNull(); expect(extractPageModifiedDate(null)).toBeNull(); }); - it('returns null when properties.modified is absent', () => { + it("returns null when properties.modified is absent", () => { expect(extractPageModifiedDate({ properties: {} })).toBeNull(); }); - it('returns null when properties.modified is not a string', () => { + it("returns null when properties.modified is not a string", () => { expect(extractPageModifiedDate({ properties: { modified: [] } })).toBeNull(); }); }); @@ -167,34 +167,34 @@ describe('extractPageModifiedDate', () => { // --------------------------------------------------------------------------- // extractGalleryModifiedDate // --------------------------------------------------------------------------- -describe('extractGalleryModifiedDate', () => { - it('returns ISO 8601 string for a valid modifiedOn', () => { - const result = extractGalleryModifiedDate({ modifiedOn: '2025-04-28T08:54:50.773' }); +describe("extractGalleryModifiedDate", () => { + it("returns ISO 8601 string for a valid modifiedOn", () => { + const result = extractGalleryModifiedDate({ modifiedOn: "2025-04-28T08:54:50.773" }); expect(result).not.toBeNull(); expect(new Date(result!).getFullYear()).toBe(2025); }); - it('returns null when modifiedOn is absent', () => { + it("returns null when modifiedOn is absent", () => { expect(extractGalleryModifiedDate({})).toBeNull(); expect(extractGalleryModifiedDate(null)).toBeNull(); }); - it('returns null when modifiedOn is not a string', () => { + it("returns null when modifiedOn is not a string", () => { expect(extractGalleryModifiedDate({ modifiedOn: 0 })).toBeNull(); }); - it('returns null for an unparseable date string', () => { - expect(extractGalleryModifiedDate({ modifiedOn: 'bad' })).toBeNull(); + it("returns null for an unparseable date string", () => { + expect(extractGalleryModifiedDate({ modifiedOn: "bad" })).toBeNull(); }); }); // --------------------------------------------------------------------------- // extractTemplateModifiedDate // --------------------------------------------------------------------------- -describe('extractTemplateModifiedDate', () => { - it('always returns null regardless of input', () => { +describe("extractTemplateModifiedDate", () => { + it("always returns null regardless of input", () => { expect(extractTemplateModifiedDate({})).toBeNull(); - expect(extractTemplateModifiedDate({ lastModifiedDate: '2025-01-01T00:00:00Z' })).toBeNull(); + expect(extractTemplateModifiedDate({ lastModifiedDate: "2025-01-01T00:00:00Z" })).toBeNull(); expect(extractTemplateModifiedDate(null)).toBeNull(); expect(extractTemplateModifiedDate(undefined)).toBeNull(); }); @@ -203,37 +203,37 @@ describe('extractTemplateModifiedDate', () => { // --------------------------------------------------------------------------- // getDateExtractorForEntityType // --------------------------------------------------------------------------- -describe('getDateExtractorForEntityType', () => { +describe("getDateExtractorForEntityType", () => { it.each([ - ['models', extractModelModifiedDate], - ['containers', extractContainerModifiedDate], - ['content', extractContentItemModifiedDate], - ['items', extractContentItemModifiedDate], - ['assets', extractAssetModifiedDate], - ['pages', extractPageModifiedDate], - ['galleries', extractGalleryModifiedDate], - ['templates', extractTemplateModifiedDate], + ["models", extractModelModifiedDate], + ["containers", extractContainerModifiedDate], + ["content", extractContentItemModifiedDate], + ["items", extractContentItemModifiedDate], + ["assets", extractAssetModifiedDate], + ["pages", extractPageModifiedDate], + ["galleries", extractGalleryModifiedDate], + ["templates", extractTemplateModifiedDate], ])('returns the correct extractor for "%s"', (entityType, expectedFn) => { expect(getDateExtractorForEntityType(entityType)).toBe(expectedFn); }); - it('is case-insensitive', () => { - expect(getDateExtractorForEntityType('MODELS')).toBe(extractModelModifiedDate); - expect(getDateExtractorForEntityType('Pages')).toBe(extractPageModifiedDate); + it("is case-insensitive", () => { + expect(getDateExtractorForEntityType("MODELS")).toBe(extractModelModifiedDate); + expect(getDateExtractorForEntityType("Pages")).toBe(extractPageModifiedDate); }); - it('returns null for an unknown entity type', () => { - expect(getDateExtractorForEntityType('unknown-type')).toBeNull(); + it("returns null for an unknown entity type", () => { + expect(getDateExtractorForEntityType("unknown-type")).toBeNull(); }); - it('warns when entity type is unknown', () => { - getDateExtractorForEntityType('mystery'); + it("warns when entity type is unknown", () => { + getDateExtractorForEntityType("mystery"); expect(console.warn).toHaveBeenCalled(); }); it('returned extractor for "models" actually works on a model entity', () => { - const extractor = getDateExtractorForEntityType('models')!; - const result = extractor({ lastModifiedDate: '2025-01-15T10:00:00Z' }); + const extractor = getDateExtractorForEntityType("models")!; + const result = extractor({ lastModifiedDate: "2025-01-15T10:00:00Z" }); expect(result).not.toBeNull(); }); }); @@ -241,24 +241,22 @@ describe('getDateExtractorForEntityType', () => { // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- -describe('INCREMENTAL_SUPPORTED_TYPES', () => { - it('contains the six entity types that support incremental pull', () => { +describe("INCREMENTAL_SUPPORTED_TYPES", () => { + it("contains the six entity types that support incremental pull", () => { expect(INCREMENTAL_SUPPORTED_TYPES).toEqual( - expect.arrayContaining(['models', 'containers', 'content', 'assets', 'pages', 'galleries']) + expect.arrayContaining(["models", "containers", "content", "assets", "pages", "galleries"]) ); - expect(INCREMENTAL_SUPPORTED_TYPES).not.toContain('templates'); + expect(INCREMENTAL_SUPPORTED_TYPES).not.toContain("templates"); }); }); -describe('FULL_REFRESH_REQUIRED_TYPES', () => { +describe("FULL_REFRESH_REQUIRED_TYPES", () => { it('contains "templates"', () => { - expect(FULL_REFRESH_REQUIRED_TYPES).toContain('templates'); + expect(FULL_REFRESH_REQUIRED_TYPES).toContain("templates"); }); - it('does not overlap with INCREMENTAL_SUPPORTED_TYPES', () => { - const overlap = FULL_REFRESH_REQUIRED_TYPES.filter((t) => - INCREMENTAL_SUPPORTED_TYPES.includes(t) - ); + it("does not overlap with INCREMENTAL_SUPPORTED_TYPES", () => { + const overlap = FULL_REFRESH_REQUIRED_TYPES.filter((t) => INCREMENTAL_SUPPORTED_TYPES.includes(t)); expect(overlap).toHaveLength(0); }); }); diff --git a/src/lib/incremental/tests/timestamp-tracker.test.ts b/src/lib/incremental/tests/timestamp-tracker.test.ts index b3ea666..67a722c 100644 --- a/src/lib/incremental/tests/timestamp-tracker.test.ts +++ b/src/lib/incremental/tests/timestamp-tracker.test.ts @@ -1,7 +1,7 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState } from 'core/state'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState } from "core/state"; import { loadLastPullTimestamps, saveLastPullTimestamps, @@ -13,12 +13,12 @@ import { clearTimestamps, getIncrementalPullDecision, LastPullTimestamps, -} from '../timestamp-tracker'; +} from "../timestamp-tracker"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-test-ts-tracker-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-test-ts-tracker-")); }); afterAll(() => { @@ -27,9 +27,9 @@ afterAll(() => { beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -45,60 +45,56 @@ function makeSubDir(name: string): string { // Resolve the timestamp file path exactly as the module does. function timestampFilePath(rootPath: string, guid: string): string { - return path.resolve(rootPath, guid, '.last-pull-timestamps.json'); + return path.resolve(rootPath, guid, ".last-pull-timestamps.json"); } // --------------------------------------------------------------------------- // loadLastPullTimestamps // --------------------------------------------------------------------------- -describe('loadLastPullTimestamps', () => { - it('returns an empty object when no timestamp file exists', () => { - const rootPath = makeSubDir('load-missing'); - const result = loadLastPullTimestamps('test-guid', rootPath); +describe("loadLastPullTimestamps", () => { + it("returns an empty object when no timestamp file exists", () => { + const rootPath = makeSubDir("load-missing"); + const result = loadLastPullTimestamps("test-guid", rootPath); expect(result).toEqual({}); }); - it('returns parsed timestamps from a valid file', () => { - const rootPath = makeSubDir('load-valid'); - const guid = 'g1'; + it("returns parsed timestamps from a valid file", () => { + const rootPath = makeSubDir("load-valid"); + const guid = "g1"; const filePath = timestampFilePath(rootPath, guid); fs.mkdirSync(path.dirname(filePath), { recursive: true }); const data: LastPullTimestamps = { - models: '2025-01-01T00:00:00.000Z', - content: '2025-02-15T12:30:00.000Z', + models: "2025-01-01T00:00:00.000Z", + content: "2025-02-15T12:30:00.000Z", }; - fs.writeFileSync(filePath, JSON.stringify(data), 'utf-8'); + fs.writeFileSync(filePath, JSON.stringify(data), "utf-8"); const result = loadLastPullTimestamps(guid, rootPath); - expect(result.models).toBe('2025-01-01T00:00:00.000Z'); - expect(result.content).toBe('2025-02-15T12:30:00.000Z'); + expect(result.models).toBe("2025-01-01T00:00:00.000Z"); + expect(result.content).toBe("2025-02-15T12:30:00.000Z"); }); - it('skips (and warns about) invalid timestamp values', () => { - const rootPath = makeSubDir('load-invalid-ts'); - const guid = 'g2'; + it("skips (and warns about) invalid timestamp values", () => { + const rootPath = makeSubDir("load-invalid-ts"); + const guid = "g2"; const filePath = timestampFilePath(rootPath, guid); fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync( - filePath, - JSON.stringify({ models: 'not-a-date', assets: '2025-03-01T00:00:00.000Z' }), - 'utf-8' - ); + fs.writeFileSync(filePath, JSON.stringify({ models: "not-a-date", assets: "2025-03-01T00:00:00.000Z" }), "utf-8"); const result = loadLastPullTimestamps(guid, rootPath); expect(result.models).toBeUndefined(); - expect(result.assets).toBe('2025-03-01T00:00:00.000Z'); + expect(result.assets).toBe("2025-03-01T00:00:00.000Z"); expect(console.warn).toHaveBeenCalled(); }); - it('returns empty object and warns when file contains malformed JSON', () => { - const rootPath = makeSubDir('load-bad-json'); - const guid = 'g3'; + it("returns empty object and warns when file contains malformed JSON", () => { + const rootPath = makeSubDir("load-bad-json"); + const guid = "g3"; const filePath = timestampFilePath(rootPath, guid); fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, 'NOT { valid json }', 'utf-8'); + fs.writeFileSync(filePath, "NOT { valid json }", "utf-8"); const result = loadLastPullTimestamps(guid, rootPath); @@ -110,62 +106,62 @@ describe('loadLastPullTimestamps', () => { // --------------------------------------------------------------------------- // saveLastPullTimestamps // --------------------------------------------------------------------------- -describe('saveLastPullTimestamps', () => { - it('creates the directory and writes a valid JSON file', () => { - const rootPath = makeSubDir('save-creates-dir'); - const guid = 'sg1'; - const timestamps: LastPullTimestamps = { models: '2025-05-01T00:00:00.000Z' }; +describe("saveLastPullTimestamps", () => { + it("creates the directory and writes a valid JSON file", () => { + const rootPath = makeSubDir("save-creates-dir"); + const guid = "sg1"; + const timestamps: LastPullTimestamps = { models: "2025-05-01T00:00:00.000Z" }; saveLastPullTimestamps(guid, rootPath, timestamps); const filePath = timestampFilePath(rootPath, guid); expect(fs.existsSync(filePath)).toBe(true); - const content = JSON.parse(fs.readFileSync(filePath, 'utf-8')); - expect(content.models).toBe('2025-05-01T00:00:00.000Z'); + const content = JSON.parse(fs.readFileSync(filePath, "utf-8")); + expect(content.models).toBe("2025-05-01T00:00:00.000Z"); }); - it('sorts keys in canonical order', () => { - const rootPath = makeSubDir('save-sorted-keys'); - const guid = 'sg2'; + it("sorts keys in canonical order", () => { + const rootPath = makeSubDir("save-sorted-keys"); + const guid = "sg2"; const timestamps: LastPullTimestamps = { - galleries: '2025-01-06T00:00:00.000Z', - models: '2025-01-01T00:00:00.000Z', - assets: '2025-01-04T00:00:00.000Z', + galleries: "2025-01-06T00:00:00.000Z", + models: "2025-01-01T00:00:00.000Z", + assets: "2025-01-04T00:00:00.000Z", }; saveLastPullTimestamps(guid, rootPath, timestamps); const filePath = timestampFilePath(rootPath, guid); - const content = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + const content = JSON.parse(fs.readFileSync(filePath, "utf-8")); const keys = Object.keys(content); - expect(keys.indexOf('models')).toBeLessThan(keys.indexOf('assets')); - expect(keys.indexOf('assets')).toBeLessThan(keys.indexOf('galleries')); + expect(keys.indexOf("models")).toBeLessThan(keys.indexOf("assets")); + expect(keys.indexOf("assets")).toBeLessThan(keys.indexOf("galleries")); }); - it('omits entity types not present in timestamps', () => { - const rootPath = makeSubDir('save-omits-empty'); - const guid = 'sg3'; - saveLastPullTimestamps(guid, rootPath, { pages: '2025-04-01T00:00:00.000Z' }); + it("omits entity types not present in timestamps", () => { + const rootPath = makeSubDir("save-omits-empty"); + const guid = "sg3"; + saveLastPullTimestamps(guid, rootPath, { pages: "2025-04-01T00:00:00.000Z" }); const filePath = timestampFilePath(rootPath, guid); - const content = JSON.parse(fs.readFileSync(filePath, 'utf-8')); - expect(Object.keys(content)).toEqual(['pages']); + const content = JSON.parse(fs.readFileSync(filePath, "utf-8")); + expect(Object.keys(content)).toEqual(["pages"]); }); - it('overwrites an existing timestamp file', () => { - const rootPath = makeSubDir('save-overwrites'); - const guid = 'sg4'; - saveLastPullTimestamps(guid, rootPath, { models: '2025-01-01T00:00:00.000Z' }); - saveLastPullTimestamps(guid, rootPath, { models: '2025-06-01T00:00:00.000Z' }); + it("overwrites an existing timestamp file", () => { + const rootPath = makeSubDir("save-overwrites"); + const guid = "sg4"; + saveLastPullTimestamps(guid, rootPath, { models: "2025-01-01T00:00:00.000Z" }); + saveLastPullTimestamps(guid, rootPath, { models: "2025-06-01T00:00:00.000Z" }); const filePath = timestampFilePath(rootPath, guid); - const content = JSON.parse(fs.readFileSync(filePath, 'utf-8')); - expect(content.models).toBe('2025-06-01T00:00:00.000Z'); + const content = JSON.parse(fs.readFileSync(filePath, "utf-8")); + expect(content.models).toBe("2025-06-01T00:00:00.000Z"); }); - it('logs success after saving', () => { - const rootPath = makeSubDir('save-logs'); - saveLastPullTimestamps('lg1', rootPath, { assets: '2025-01-01T00:00:00.000Z' }); + it("logs success after saving", () => { + const rootPath = makeSubDir("save-logs"); + saveLastPullTimestamps("lg1", rootPath, { assets: "2025-01-01T00:00:00.000Z" }); expect(console.log).toHaveBeenCalled(); }); }); @@ -173,66 +169,66 @@ describe('saveLastPullTimestamps', () => { // --------------------------------------------------------------------------- // updateEntityTypeTimestamp // --------------------------------------------------------------------------- -describe('updateEntityTypeTimestamp', () => { - it('adds a new entity type timestamp to an existing file', () => { - const rootPath = makeSubDir('update-add-key'); - const guid = 'ug1'; - saveLastPullTimestamps(guid, rootPath, { models: '2025-01-01T00:00:00.000Z' }); +describe("updateEntityTypeTimestamp", () => { + it("adds a new entity type timestamp to an existing file", () => { + const rootPath = makeSubDir("update-add-key"); + const guid = "ug1"; + saveLastPullTimestamps(guid, rootPath, { models: "2025-01-01T00:00:00.000Z" }); - updateEntityTypeTimestamp(guid, rootPath, 'assets', '2025-03-01T00:00:00.000Z'); + updateEntityTypeTimestamp(guid, rootPath, "assets", "2025-03-01T00:00:00.000Z"); const result = loadLastPullTimestamps(guid, rootPath); - expect(result.models).toBe('2025-01-01T00:00:00.000Z'); - expect(result.assets).toBe('2025-03-01T00:00:00.000Z'); + expect(result.models).toBe("2025-01-01T00:00:00.000Z"); + expect(result.assets).toBe("2025-03-01T00:00:00.000Z"); }); - it('updates an existing entity type timestamp', () => { - const rootPath = makeSubDir('update-overwrite-key'); - const guid = 'ug2'; - saveLastPullTimestamps(guid, rootPath, { models: '2025-01-01T00:00:00.000Z' }); + it("updates an existing entity type timestamp", () => { + const rootPath = makeSubDir("update-overwrite-key"); + const guid = "ug2"; + saveLastPullTimestamps(guid, rootPath, { models: "2025-01-01T00:00:00.000Z" }); - updateEntityTypeTimestamp(guid, rootPath, 'models', '2025-07-01T00:00:00.000Z'); + updateEntityTypeTimestamp(guid, rootPath, "models", "2025-07-01T00:00:00.000Z"); const result = loadLastPullTimestamps(guid, rootPath); - expect(result.models).toBe('2025-07-01T00:00:00.000Z'); + expect(result.models).toBe("2025-07-01T00:00:00.000Z"); }); - it('creates the file when it does not yet exist', () => { - const rootPath = makeSubDir('update-creates-file'); - const guid = 'ug3'; + it("creates the file when it does not yet exist", () => { + const rootPath = makeSubDir("update-creates-file"); + const guid = "ug3"; - updateEntityTypeTimestamp(guid, rootPath, 'pages', '2025-05-01T00:00:00.000Z'); + updateEntityTypeTimestamp(guid, rootPath, "pages", "2025-05-01T00:00:00.000Z"); const result = loadLastPullTimestamps(guid, rootPath); - expect(result.pages).toBe('2025-05-01T00:00:00.000Z'); + expect(result.pages).toBe("2025-05-01T00:00:00.000Z"); }); }); // --------------------------------------------------------------------------- // getLastPullTimestamp // --------------------------------------------------------------------------- -describe('getLastPullTimestamp', () => { - it('returns the timestamp string for a known entity type', () => { - const rootPath = makeSubDir('getlast-found'); - const guid = 'gl1'; - saveLastPullTimestamps(guid, rootPath, { containers: '2025-04-10T08:00:00.000Z' }); - - const result = getLastPullTimestamp(guid, rootPath, 'containers'); - expect(result).toBe('2025-04-10T08:00:00.000Z'); +describe("getLastPullTimestamp", () => { + it("returns the timestamp string for a known entity type", () => { + const rootPath = makeSubDir("getlast-found"); + const guid = "gl1"; + saveLastPullTimestamps(guid, rootPath, { containers: "2025-04-10T08:00:00.000Z" }); + + const result = getLastPullTimestamp(guid, rootPath, "containers"); + expect(result).toBe("2025-04-10T08:00:00.000Z"); }); - it('returns null when entity type is not in the file', () => { - const rootPath = makeSubDir('getlast-missing-key'); - const guid = 'gl2'; - saveLastPullTimestamps(guid, rootPath, { models: '2025-01-01T00:00:00.000Z' }); + it("returns null when entity type is not in the file", () => { + const rootPath = makeSubDir("getlast-missing-key"); + const guid = "gl2"; + saveLastPullTimestamps(guid, rootPath, { models: "2025-01-01T00:00:00.000Z" }); - const result = getLastPullTimestamp(guid, rootPath, 'assets'); + const result = getLastPullTimestamp(guid, rootPath, "assets"); expect(result).toBeNull(); }); - it('returns null when no timestamp file exists', () => { - const rootPath = makeSubDir('getlast-no-file'); - const result = getLastPullTimestamp('no-guid', rootPath, 'pages'); + it("returns null when no timestamp file exists", () => { + const rootPath = makeSubDir("getlast-no-file"); + const result = getLastPullTimestamp("no-guid", rootPath, "pages"); expect(result).toBeNull(); }); }); @@ -240,39 +236,35 @@ describe('getLastPullTimestamp', () => { // --------------------------------------------------------------------------- // isEntityModifiedSinceLastPull // --------------------------------------------------------------------------- -describe('isEntityModifiedSinceLastPull', () => { - it('returns true when entityModifiedDate is null (default to modified)', () => { - expect(isEntityModifiedSinceLastPull(null, '2025-01-01T00:00:00.000Z')).toBe(true); +describe("isEntityModifiedSinceLastPull", () => { + it("returns true when entityModifiedDate is null (default to modified)", () => { + expect(isEntityModifiedSinceLastPull(null, "2025-01-01T00:00:00.000Z")).toBe(true); }); - it('returns true when lastPullTimestamp is null (first pull)', () => { - expect(isEntityModifiedSinceLastPull('2025-06-01T00:00:00.000Z', null)).toBe(true); + it("returns true when lastPullTimestamp is null (first pull)", () => { + expect(isEntityModifiedSinceLastPull("2025-06-01T00:00:00.000Z", null)).toBe(true); }); - it('returns true when entity was modified after last pull', () => { - expect( - isEntityModifiedSinceLastPull('2025-06-02T00:00:00.000Z', '2025-06-01T00:00:00.000Z') - ).toBe(true); + it("returns true when entity was modified after last pull", () => { + expect(isEntityModifiedSinceLastPull("2025-06-02T00:00:00.000Z", "2025-06-01T00:00:00.000Z")).toBe(true); }); - it('returns false when entity was modified before last pull', () => { - expect( - isEntityModifiedSinceLastPull('2025-05-31T00:00:00.000Z', '2025-06-01T00:00:00.000Z') - ).toBe(false); + it("returns false when entity was modified before last pull", () => { + expect(isEntityModifiedSinceLastPull("2025-05-31T00:00:00.000Z", "2025-06-01T00:00:00.000Z")).toBe(false); }); - it('returns false when entity modified date equals last pull timestamp', () => { - const ts = '2025-06-01T00:00:00.000Z'; + it("returns false when entity modified date equals last pull timestamp", () => { + const ts = "2025-06-01T00:00:00.000Z"; expect(isEntityModifiedSinceLastPull(ts, ts)).toBe(false); }); - it('returns true (and warns) when entityModifiedDate is an invalid date', () => { - expect(isEntityModifiedSinceLastPull('bad-date', '2025-06-01T00:00:00.000Z')).toBe(true); + it("returns true (and warns) when entityModifiedDate is an invalid date", () => { + expect(isEntityModifiedSinceLastPull("bad-date", "2025-06-01T00:00:00.000Z")).toBe(true); expect(console.warn).toHaveBeenCalled(); }); - it('returns true (and warns) when lastPullTimestamp is an invalid date', () => { - expect(isEntityModifiedSinceLastPull('2025-06-01T00:00:00.000Z', 'bad-date')).toBe(true); + it("returns true (and warns) when lastPullTimestamp is an invalid date", () => { + expect(isEntityModifiedSinceLastPull("2025-06-01T00:00:00.000Z", "bad-date")).toBe(true); expect(console.warn).toHaveBeenCalled(); }); }); @@ -280,26 +272,26 @@ describe('isEntityModifiedSinceLastPull', () => { // --------------------------------------------------------------------------- // markPullStart / markPushStart // --------------------------------------------------------------------------- -describe('markPullStart', () => { - it('returns a valid ISO 8601 timestamp close to now', () => { +describe("markPullStart", () => { + it("returns a valid ISO 8601 timestamp close to now", () => { const before = Date.now(); const ts = markPullStart(); const after = Date.now(); - expect(typeof ts).toBe('string'); + expect(typeof ts).toBe("string"); const parsed = new Date(ts).getTime(); expect(parsed).toBeGreaterThanOrEqual(before); expect(parsed).toBeLessThanOrEqual(after); }); }); -describe('markPushStart', () => { - it('returns a valid ISO 8601 timestamp close to now', () => { +describe("markPushStart", () => { + it("returns a valid ISO 8601 timestamp close to now", () => { const before = Date.now(); const ts = markPushStart(); const after = Date.now(); - expect(typeof ts).toBe('string'); + expect(typeof ts).toBe("string"); const parsed = new Date(ts).getTime(); expect(parsed).toBeGreaterThanOrEqual(before); expect(parsed).toBeLessThanOrEqual(after); @@ -309,11 +301,11 @@ describe('markPushStart', () => { // --------------------------------------------------------------------------- // clearTimestamps // --------------------------------------------------------------------------- -describe('clearTimestamps', () => { - it('removes the timestamp file when it exists', () => { - const rootPath = makeSubDir('clear-exists'); - const guid = 'cl1'; - saveLastPullTimestamps(guid, rootPath, { models: '2025-01-01T00:00:00.000Z' }); +describe("clearTimestamps", () => { + it("removes the timestamp file when it exists", () => { + const rootPath = makeSubDir("clear-exists"); + const guid = "cl1"; + saveLastPullTimestamps(guid, rootPath, { models: "2025-01-01T00:00:00.000Z" }); clearTimestamps(guid, rootPath); @@ -321,17 +313,17 @@ describe('clearTimestamps', () => { expect(fs.existsSync(filePath)).toBe(false); }); - it('does not throw when timestamp file does not exist', () => { - const rootPath = makeSubDir('clear-no-file'); - expect(() => clearTimestamps('no-guid', rootPath)).not.toThrow(); + it("does not throw when timestamp file does not exist", () => { + const rootPath = makeSubDir("clear-no-file"); + expect(() => clearTimestamps("no-guid", rootPath)).not.toThrow(); }); - it('logs when clearing an existing file', () => { - const rootPath = makeSubDir('clear-logs'); - const guid = 'cl2'; - saveLastPullTimestamps(guid, rootPath, { assets: '2025-01-01T00:00:00.000Z' }); + it("logs when clearing an existing file", () => { + const rootPath = makeSubDir("clear-logs"); + const guid = "cl2"; + saveLastPullTimestamps(guid, rootPath, { assets: "2025-01-01T00:00:00.000Z" }); jest.clearAllMocks(); - jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); clearTimestamps(guid, rootPath); @@ -342,39 +334,39 @@ describe('clearTimestamps', () => { // --------------------------------------------------------------------------- // getIncrementalPullDecision // --------------------------------------------------------------------------- -describe('getIncrementalPullDecision', () => { +describe("getIncrementalPullDecision", () => { it('returns "full" for "templates" regardless of stored timestamps', () => { - const rootPath = makeSubDir('decision-templates'); - const guid = 'pd1'; - saveLastPullTimestamps(guid, rootPath, { templates: '2025-01-01T00:00:00.000Z' }); + const rootPath = makeSubDir("decision-templates"); + const guid = "pd1"; + saveLastPullTimestamps(guid, rootPath, { templates: "2025-01-01T00:00:00.000Z" }); - expect(getIncrementalPullDecision(guid, rootPath, 'templates')).toBe('full'); + expect(getIncrementalPullDecision(guid, rootPath, "templates")).toBe("full"); }); it('returns "full" when no previous pull timestamp exists for entity type', () => { - const rootPath = makeSubDir('decision-full-no-ts'); - expect(getIncrementalPullDecision('no-guid', rootPath, 'models')).toBe('full'); + const rootPath = makeSubDir("decision-full-no-ts"); + expect(getIncrementalPullDecision("no-guid", rootPath, "models")).toBe("full"); }); it('returns "incremental" when a previous pull timestamp exists', () => { - const rootPath = makeSubDir('decision-incremental'); - const guid = 'pd2'; - saveLastPullTimestamps(guid, rootPath, { models: '2025-01-01T00:00:00.000Z' }); + const rootPath = makeSubDir("decision-incremental"); + const guid = "pd2"; + saveLastPullTimestamps(guid, rootPath, { models: "2025-01-01T00:00:00.000Z" }); - expect(getIncrementalPullDecision(guid, rootPath, 'models')).toBe('incremental'); + expect(getIncrementalPullDecision(guid, rootPath, "models")).toBe("incremental"); }); - it.each(['models', 'containers', 'content', 'assets', 'pages', 'galleries'])( + it.each(["models", "containers", "content", "assets", "pages", "galleries"])( 'returns "full" on first pull for entity type "%s"', (entityType) => { const rootPath = makeSubDir(`decision-first-pull-${entityType}`); - expect(getIncrementalPullDecision('fresh-guid', rootPath, entityType)).toBe('full'); + expect(getIncrementalPullDecision("fresh-guid", rootPath, entityType)).toBe("full"); } ); it('is case-insensitive for "templates"', () => { - const rootPath = makeSubDir('decision-templates-case'); - expect(getIncrementalPullDecision('any', rootPath, 'TEMPLATES')).toBe('full'); - expect(getIncrementalPullDecision('any', rootPath, 'Templates')).toBe('full'); + const rootPath = makeSubDir("decision-templates-case"); + expect(getIncrementalPullDecision("any", rootPath, "TEMPLATES")).toBe("full"); + expect(getIncrementalPullDecision("any", rootPath, "Templates")).toBe("full"); }); }); diff --git a/src/lib/incremental/timestamp-tracker.ts b/src/lib/incremental/timestamp-tracker.ts index 03566dd..dc29ae9 100644 --- a/src/lib/incremental/timestamp-tracker.ts +++ b/src/lib/incremental/timestamp-tracker.ts @@ -1,12 +1,12 @@ /** * Timestamp tracking system for incremental pull operations - * + * * Stores last successful pull timestamps per entity type to enable * incremental downloading of only changed entities. */ -import * as fs from 'fs'; -import * as path from 'path'; +import * as fs from "fs"; +import * as path from "path"; export interface LastPullTimestamps { models?: string; @@ -25,7 +25,7 @@ export interface LastPullTimestamps { * @returns Path to the .last-pull-timestamps.json file */ function getTimestampFilePath(guid: string, rootPath: string): string { - return path.resolve(rootPath, guid, '.last-pull-timestamps.json'); + return path.resolve(rootPath, guid, ".last-pull-timestamps.json"); } /** @@ -37,19 +37,19 @@ function getTimestampFilePath(guid: string, rootPath: string): string { export function loadLastPullTimestamps(guid: string, rootPath: string): LastPullTimestamps { try { const timestampFile = getTimestampFilePath(guid, rootPath); - + if (!fs.existsSync(timestampFile)) { // No timestamp file exists, return empty timestamps (will trigger full pull) return {}; } - - const content = fs.readFileSync(timestampFile, 'utf-8'); + + const content = fs.readFileSync(timestampFile, "utf-8"); const timestamps: LastPullTimestamps = JSON.parse(content); - + // Validate that all timestamps are valid ISO 8601 dates const validatedTimestamps: LastPullTimestamps = {}; for (const [entityType, timestamp] of Object.entries(timestamps)) { - if (timestamp && typeof timestamp === 'string') { + if (timestamp && typeof timestamp === "string") { const parsed = new Date(timestamp); if (!isNaN(parsed.getTime())) { validatedTimestamps[entityType as keyof LastPullTimestamps] = timestamp; @@ -58,7 +58,7 @@ export function loadLastPullTimestamps(guid: string, rootPath: string): LastPull } } } - + return validatedTimestamps; } catch (error) { console.warn(`Error loading last pull timestamps for ${guid}:`, error); @@ -76,26 +76,26 @@ export function saveLastPullTimestamps(guid: string, rootPath: string, timestamp try { const timestampFile = getTimestampFilePath(guid, rootPath); const instanceDir = path.dirname(timestampFile); - + // Ensure instance directory exists if (!fs.existsSync(instanceDir)) { fs.mkdirSync(instanceDir, { recursive: true }); } - + // Sort keys for consistent file format const sortedTimestamps: LastPullTimestamps = {}; - const entityTypes = ['models', 'containers', 'content', 'assets', 'pages', 'galleries', 'templates']; - + const entityTypes = ["models", "containers", "content", "assets", "pages", "galleries", "templates"]; + for (const entityType of entityTypes) { const timestamp = timestamps[entityType as keyof LastPullTimestamps]; if (timestamp) { sortedTimestamps[entityType as keyof LastPullTimestamps] = timestamp; } } - + const content = JSON.stringify(sortedTimestamps, null, 2); - fs.writeFileSync(timestampFile, content, 'utf-8'); - + fs.writeFileSync(timestampFile, content, "utf-8"); + console.log(`Saved last pull timestamps for ${guid}`); } catch (error) { console.error(`Error saving last pull timestamps for ${guid}:`, error); @@ -109,12 +109,7 @@ export function saveLastPullTimestamps(guid: string, rootPath: string, timestamp * @param entityType Entity type to update * @param timestamp ISO 8601 timestamp */ -export function updateEntityTypeTimestamp( - guid: string, - rootPath: string, - entityType: string, - timestamp: string -): void { +export function updateEntityTypeTimestamp(guid: string, rootPath: string, entityType: string, timestamp: string): void { try { const currentTimestamps = loadLastPullTimestamps(guid, rootPath); currentTimestamps[entityType as keyof LastPullTimestamps] = timestamp; @@ -143,28 +138,28 @@ export function getLastPullTimestamp(guid: string, rootPath: string, entityType: * @returns true if entity was modified since last pull, false otherwise */ export function isEntityModifiedSinceLastPull( - entityModifiedDate: string | null, + entityModifiedDate: string | null, lastPullTimestamp: string | null ): boolean { // If no entity modified date, we can't determine if it was modified if (!entityModifiedDate) { return true; // Default to "modified" to be safe } - + // If no last pull timestamp, this is the first pull if (!lastPullTimestamp) { return true; // First pull, consider everything "modified" } - + try { const entityDate = new Date(entityModifiedDate); const lastPullDate = new Date(lastPullTimestamp); - + if (isNaN(entityDate.getTime()) || isNaN(lastPullDate.getTime())) { console.warn(`Invalid dates for comparison: entity=${entityModifiedDate}, lastPull=${lastPullTimestamp}`); return true; // Default to "modified" on parsing errors } - + // Entity is modified if its modified date is after the last pull return entityDate > lastPullDate; } catch (error) { @@ -197,7 +192,7 @@ export function markPushStart(): string { export function clearTimestamps(guid: string, rootPath: string): void { try { const timestampFile = getTimestampFilePath(guid, rootPath); - + if (fs.existsSync(timestampFile)) { fs.unlinkSync(timestampFile); console.log(`Cleared timestamps for ${guid} (--reset mode)`); @@ -215,27 +210,27 @@ export function clearTimestamps(guid: string, rootPath: string): void { * @returns "incremental" | "full" | "skip" */ export function getIncrementalPullDecision( - guid: string, - rootPath: string, + guid: string, + rootPath: string, entityType: string ): "incremental" | "full" | "skip" { try { // Templates always require full refresh (no modified dates) - if (entityType.toLowerCase() === 'templates') { + if (entityType.toLowerCase() === "templates") { return "full"; } - + const lastPullTimestamp = getLastPullTimestamp(guid, rootPath, entityType); - + // No previous pull recorded if (!lastPullTimestamp) { return "full"; } - + // Previous pull recorded, can do incremental return "incremental"; } catch (error) { console.warn(`Error determining pull decision for ${entityType}:`, error); return "full"; // Default to full on errors } -} \ No newline at end of file +} diff --git a/src/lib/loggers/index.ts b/src/lib/loggers/index.ts index b4fb058..4c7fa5a 100644 --- a/src/lib/loggers/index.ts +++ b/src/lib/loggers/index.ts @@ -1 +1 @@ -export * from './model-diff-logger'; \ No newline at end of file +export * from "./model-diff-logger"; diff --git a/src/lib/loggers/model-diff-logger.ts b/src/lib/loggers/model-diff-logger.ts index 0de34e6..d0c8e48 100644 --- a/src/lib/loggers/model-diff-logger.ts +++ b/src/lib/loggers/model-diff-logger.ts @@ -87,4 +87,4 @@ export function logFieldArrayDifferences(sourceFields: mgmtApi.ModelField[], tar diffMessages.forEach((msg) => console.log(msg)); } }); -} \ No newline at end of file +} diff --git a/src/lib/loggers/tests/model-diff-logger.test.ts b/src/lib/loggers/tests/model-diff-logger.test.ts index cbde3ac..482a29c 100644 --- a/src/lib/loggers/tests/model-diff-logger.test.ts +++ b/src/lib/loggers/tests/model-diff-logger.test.ts @@ -1,11 +1,11 @@ -import { resetState } from 'core/state'; -import { logModelDifferences, logFieldArrayDifferences } from 'lib/loggers/model-diff-logger'; +import { resetState } from "core/state"; +import { logModelDifferences, logFieldArrayDifferences } from "lib/loggers/model-diff-logger"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -14,129 +14,122 @@ afterEach(() => { // ─── helpers ────────────────────────────────────────────────────────────────── -function makeField( - name: string, - label: string, - type: string, - settings: Record = {} -): any { +function makeField(name: string, label: string, type: string, settings: Record = {}): any { return { name, label, type, settings }; } // ─── logModelDifferences ────────────────────────────────────────────────────── -describe('logModelDifferences', () => { - describe('identical objects', () => { - it('logs the diff header but no property lines when source and target are equal', () => { - logModelDifferences({ title: 'Hello' }, { title: 'Hello' }, 'BlogPost'); +describe("logModelDifferences", () => { + describe("identical objects", () => { + it("logs the diff header but no property lines when source and target are equal", () => { + logModelDifferences({ title: "Hello" }, { title: "Hello" }, "BlogPost"); // Only the header line should have been logged (no diff lines) expect(console.log).toHaveBeenCalledTimes(1); }); - it('does not throw for empty objects', () => { - expect(() => logModelDifferences({}, {}, 'Empty')).not.toThrow(); + it("does not throw for empty objects", () => { + expect(() => logModelDifferences({}, {}, "Empty")).not.toThrow(); }); }); - describe('source-only keys', () => { - it('logs a source-only line for a key present in source but not target', () => { - logModelDifferences({ extra: 'data' }, {}, 'Model'); + describe("source-only keys", () => { + it("logs a source-only line for a key present in source but not target", () => { + logModelDifferences({ extra: "data" }, {}, "Model"); const calls = (console.log as jest.Mock).mock.calls.map((c) => c[0]); - const sourceOnlyLine = calls.find((msg: string) => msg.includes('Source only')); + const sourceOnlyLine = calls.find((msg: string) => msg.includes("Source only")); expect(sourceOnlyLine).toBeDefined(); - expect(sourceOnlyLine).toContain('extra'); + expect(sourceOnlyLine).toContain("extra"); }); - it('logs source-only lines for multiple missing target keys', () => { - logModelDifferences({ a: 1, b: 2 }, {}, 'Model'); + it("logs source-only lines for multiple missing target keys", () => { + logModelDifferences({ a: 1, b: 2 }, {}, "Model"); const calls = (console.log as jest.Mock).mock.calls.map((c) => c[0]); - const sourceOnlyLines = calls.filter((msg: string) => msg.includes('Source only')); + const sourceOnlyLines = calls.filter((msg: string) => msg.includes("Source only")); expect(sourceOnlyLines).toHaveLength(2); }); }); - describe('target-only keys', () => { - it('logs a target-only line for a key present in target but not source', () => { - logModelDifferences({}, { obsolete: 'value' }, 'Model'); + describe("target-only keys", () => { + it("logs a target-only line for a key present in target but not source", () => { + logModelDifferences({}, { obsolete: "value" }, "Model"); const calls = (console.log as jest.Mock).mock.calls.map((c) => c[0]); - const targetOnlyLine = calls.find((msg: string) => msg.includes('Target only')); + const targetOnlyLine = calls.find((msg: string) => msg.includes("Target only")); expect(targetOnlyLine).toBeDefined(); - expect(targetOnlyLine).toContain('obsolete'); + expect(targetOnlyLine).toContain("obsolete"); }); }); - describe('different scalar values', () => { - it('logs a different line and both source/target values for changed scalar', () => { - logModelDifferences({ count: 1 }, { count: 2 }, 'Model'); + describe("different scalar values", () => { + it("logs a different line and both source/target values for changed scalar", () => { + logModelDifferences({ count: 1 }, { count: 2 }, "Model"); const calls = (console.log as jest.Mock).mock.calls.map((c) => c[0]); - const differentLine = calls.find((msg: string) => msg.includes('Different')); + const differentLine = calls.find((msg: string) => msg.includes("Different")); expect(differentLine).toBeDefined(); - expect(differentLine).toContain('count'); - const sourceLine = calls.find((msg: string) => msg.includes('Source Value') && msg.includes('1')); + expect(differentLine).toContain("count"); + const sourceLine = calls.find((msg: string) => msg.includes("Source Value") && msg.includes("1")); expect(sourceLine).toBeDefined(); - const targetLine = calls.find((msg: string) => msg.includes('Target Value') && msg.includes('2')); + const targetLine = calls.find((msg: string) => msg.includes("Target Value") && msg.includes("2")); expect(targetLine).toBeDefined(); }); - it('includes the model name in the header line', () => { - logModelDifferences({ x: 1 }, { x: 2 }, 'MySpecialModel'); + it("includes the model name in the header line", () => { + logModelDifferences({ x: 1 }, { x: 2 }, "MySpecialModel"); const calls = (console.log as jest.Mock).mock.calls.map((c) => c[0]); - expect(calls[0]).toContain('MySpecialModel'); + expect(calls[0]).toContain("MySpecialModel"); }); }); - describe('different nested object values', () => { - it('logs source and target values for differing nested objects', () => { + describe("different nested object values", () => { + it("logs source and target values for differing nested objects", () => { const source = { meta: { version: 1 } }; const target = { meta: { version: 2 } }; - logModelDifferences(source, target, 'Model'); + logModelDifferences(source, target, "Model"); const calls = (console.log as jest.Mock).mock.calls.map((c) => c[0]); - const sourceLine = calls.find((msg: string) => msg.includes('Source Value')); - const targetLine = calls.find((msg: string) => msg.includes('Target Value')); + const sourceLine = calls.find((msg: string) => msg.includes("Source Value")); + const targetLine = calls.find((msg: string) => msg.includes("Target Value")); expect(sourceLine).toBeDefined(); expect(targetLine).toBeDefined(); }); }); - describe('fields key delegates to logFieldArrayDifferences', () => { - it('does not throw when fields arrays differ', () => { - const source = { fields: [makeField('Title', 'Title', 'Text')] }; - const target = { fields: [makeField('Body', 'Body', 'HTML')] }; - expect(() => logModelDifferences(source, target, 'Model')).not.toThrow(); + describe("fields key delegates to logFieldArrayDifferences", () => { + it("does not throw when fields arrays differ", () => { + const source = { fields: [makeField("Title", "Title", "Text")] }; + const target = { fields: [makeField("Body", "Body", "HTML")] }; + expect(() => logModelDifferences(source, target, "Model")).not.toThrow(); }); - it('logs source/target field-level differences when fields arrays differ', () => { - const source = { fields: [makeField('Title', 'Title', 'Text')] }; - const target = { fields: [makeField('Title', 'Heading', 'Text')] }; - logModelDifferences(source, target, 'Model'); + it("logs source/target field-level differences when fields arrays differ", () => { + const source = { fields: [makeField("Title", "Title", "Text")] }; + const target = { fields: [makeField("Title", "Heading", "Text")] }; + logModelDifferences(source, target, "Model"); const calls = (console.log as jest.Mock).mock.calls.map((c) => c[0]); // logFieldArrayDifferences emits a "differs" line for the shared field - const fieldDiffLine = calls.find( - (msg: string) => msg.includes('differs') || msg.includes('Label') - ); + const fieldDiffLine = calls.find((msg: string) => msg.includes("differs") || msg.includes("Label")); expect(fieldDiffLine).toBeDefined(); }); - it('treats fields key as plain objects (not arrays) and logs nested diff', () => { + it("treats fields key as plain objects (not arrays) and logs nested diff", () => { // When fields is not an array, the nested-object branch applies const source = { fields: { custom: true } }; const target = { fields: { custom: false } }; - logModelDifferences(source, target, 'Model'); + logModelDifferences(source, target, "Model"); const calls = (console.log as jest.Mock).mock.calls.map((c) => c[0]); - const sourceLine = calls.find((msg: string) => msg.includes('Source Value')); + const sourceLine = calls.find((msg: string) => msg.includes("Source Value")); expect(sourceLine).toBeDefined(); }); }); - describe('mixed keys', () => { - it('handles a mix of equal, source-only, target-only, and different keys', () => { - const source = { same: 'x', srcOnly: 1, changed: 'old' }; - const target = { same: 'x', tgtOnly: 2, changed: 'new' }; - expect(() => logModelDifferences(source, target, 'Mixed')).not.toThrow(); + describe("mixed keys", () => { + it("handles a mix of equal, source-only, target-only, and different keys", () => { + const source = { same: "x", srcOnly: 1, changed: "old" }; + const target = { same: "x", tgtOnly: 2, changed: "new" }; + expect(() => logModelDifferences(source, target, "Mixed")).not.toThrow(); const calls = (console.log as jest.Mock).mock.calls.map((c) => c[0]); - const sourceOnlyLine = calls.find((msg: string) => msg.includes('Source only')); - const targetOnlyLine = calls.find((msg: string) => msg.includes('Target only')); - const differentLine = calls.find((msg: string) => msg.includes('Different')); + const sourceOnlyLine = calls.find((msg: string) => msg.includes("Source only")); + const targetOnlyLine = calls.find((msg: string) => msg.includes("Target only")); + const differentLine = calls.find((msg: string) => msg.includes("Different")); expect(sourceOnlyLine).toBeDefined(); expect(targetOnlyLine).toBeDefined(); expect(differentLine).toBeDefined(); @@ -146,181 +139,176 @@ describe('logModelDifferences', () => { // ─── logFieldArrayDifferences ───────────────────────────────────────────────── -describe('logFieldArrayDifferences', () => { - describe('empty arrays', () => { - it('does not throw and logs nothing extra for two empty arrays', () => { +describe("logFieldArrayDifferences", () => { + describe("empty arrays", () => { + it("does not throw and logs nothing extra for two empty arrays", () => { logFieldArrayDifferences([], []); // console.log is not called (no differences found) expect(console.log).not.toHaveBeenCalled(); }); }); - describe('source-only fields', () => { - it('logs a source-only field line for a field not present in target', () => { - const sourceFields = [makeField('NewField', 'New Field', 'Text')]; + describe("source-only fields", () => { + it("logs a source-only field line for a field not present in target", () => { + const sourceFields = [makeField("NewField", "New Field", "Text")]; logFieldArrayDifferences(sourceFields, []); const calls = (console.log as jest.Mock).mock.calls.map((c) => c[0]); - const line = calls.find((msg: string) => msg.includes('Source Field only')); + const line = calls.find((msg: string) => msg.includes("Source Field only")); expect(line).toBeDefined(); - expect(line).toContain('NewField'); + expect(line).toContain("NewField"); }); - it('includes the field type in the source-only log line', () => { - const sourceFields = [makeField('ImageField', 'Image', 'Image')]; + it("includes the field type in the source-only log line", () => { + const sourceFields = [makeField("ImageField", "Image", "Image")]; logFieldArrayDifferences(sourceFields, []); const calls = (console.log as jest.Mock).mock.calls.map((c) => c[0]); - const line = calls.find((msg: string) => msg.includes('Source Field only')); - expect(line).toContain('Image'); + const line = calls.find((msg: string) => msg.includes("Source Field only")); + expect(line).toContain("Image"); }); - it('logs multiple source-only field lines when several are absent from target', () => { - const sourceFields = [ - makeField('Field1', 'F1', 'Text'), - makeField('Field2', 'F2', 'HTML'), - ]; + it("logs multiple source-only field lines when several are absent from target", () => { + const sourceFields = [makeField("Field1", "F1", "Text"), makeField("Field2", "F2", "HTML")]; logFieldArrayDifferences(sourceFields, []); const calls = (console.log as jest.Mock).mock.calls.map((c) => c[0]); - const lines = calls.filter((msg: string) => msg.includes('Source Field only')); + const lines = calls.filter((msg: string) => msg.includes("Source Field only")); expect(lines).toHaveLength(2); }); }); - describe('target-only fields', () => { - it('logs a target-only field line for a field not present in source', () => { - const targetFields = [makeField('OldField', 'Old Field', 'Text')]; + describe("target-only fields", () => { + it("logs a target-only field line for a field not present in source", () => { + const targetFields = [makeField("OldField", "Old Field", "Text")]; logFieldArrayDifferences([], targetFields); const calls = (console.log as jest.Mock).mock.calls.map((c) => c[0]); - const line = calls.find((msg: string) => msg.includes('Target Field only')); + const line = calls.find((msg: string) => msg.includes("Target Field only")); expect(line).toBeDefined(); - expect(line).toContain('OldField'); + expect(line).toContain("OldField"); }); - it('includes the field type in the target-only log line', () => { - const targetFields = [makeField('VideoField', 'Video', 'CustomField')]; + it("includes the field type in the target-only log line", () => { + const targetFields = [makeField("VideoField", "Video", "CustomField")]; logFieldArrayDifferences([], targetFields); const calls = (console.log as jest.Mock).mock.calls.map((c) => c[0]); - const line = calls.find((msg: string) => msg.includes('Target Field only')); - expect(line).toContain('CustomField'); + const line = calls.find((msg: string) => msg.includes("Target Field only")); + expect(line).toContain("CustomField"); }); }); - describe('shared fields with no differences', () => { - it('does not log a diff line when shared fields are identical', () => { - const field = makeField('Title', 'Title', 'Text', { Required: 'true' }); + describe("shared fields with no differences", () => { + it("does not log a diff line when shared fields are identical", () => { + const field = makeField("Title", "Title", "Text", { Required: "true" }); logFieldArrayDifferences([field], [field]); const calls = (console.log as jest.Mock).mock.calls.map((c) => c[0]); - const diffLine = calls.find((msg: string) => msg.includes('differs')); + const diffLine = calls.find((msg: string) => msg.includes("differs")); expect(diffLine).toBeUndefined(); }); }); - describe('shared fields with label differences', () => { - it('logs a field-differs line when labels differ', () => { - const src = makeField('Title', 'Title', 'Text'); - const tgt = makeField('Title', 'Heading', 'Text'); + describe("shared fields with label differences", () => { + it("logs a field-differs line when labels differ", () => { + const src = makeField("Title", "Title", "Text"); + const tgt = makeField("Title", "Heading", "Text"); logFieldArrayDifferences([src], [tgt]); const calls = (console.log as jest.Mock).mock.calls.map((c) => c[0]); - const diffHeader = calls.find((msg: string) => msg.includes('differs')); + const diffHeader = calls.find((msg: string) => msg.includes("differs")); expect(diffHeader).toBeDefined(); - const labelLine = calls.find((msg: string) => msg.includes('Label')); + const labelLine = calls.find((msg: string) => msg.includes("Label")); expect(labelLine).toBeDefined(); - expect(labelLine).toContain('Title'); - expect(labelLine).toContain('Heading'); + expect(labelLine).toContain("Title"); + expect(labelLine).toContain("Heading"); }); }); - describe('shared fields with type differences', () => { - it('logs a type diff line when field types differ', () => { - const src = makeField('Body', 'Body', 'Text'); - const tgt = makeField('Body', 'Body', 'HTML'); + describe("shared fields with type differences", () => { + it("logs a type diff line when field types differ", () => { + const src = makeField("Body", "Body", "Text"); + const tgt = makeField("Body", "Body", "HTML"); logFieldArrayDifferences([src], [tgt]); // The type info is emitted on a plain (un-coloured) message line const allArgs = (console.log as jest.Mock).mock.calls.map((c) => c[0]); // The header line contains "differs" and the detail line contains both type values - const diffHeader = allArgs.find((msg: string) => msg.includes('differs')); + const diffHeader = allArgs.find((msg: string) => msg.includes("differs")); expect(diffHeader).toBeDefined(); const typeLine = allArgs.find( - (msg: string) => msg.includes('Type') && msg.includes('Text') && msg.includes('HTML') + (msg: string) => msg.includes("Type") && msg.includes("Text") && msg.includes("HTML") ); expect(typeLine).toBeDefined(); }); }); - describe('shared fields with settings differences', () => { - it('logs a settings diff line when field settings differ', () => { - const src = makeField('Ref', 'Ref', 'Content', { ContentDefinition: 'Blog' }); - const tgt = makeField('Ref', 'Ref', 'Content', { ContentDefinition: 'News' }); + describe("shared fields with settings differences", () => { + it("logs a settings diff line when field settings differ", () => { + const src = makeField("Ref", "Ref", "Content", { ContentDefinition: "Blog" }); + const tgt = makeField("Ref", "Ref", "Content", { ContentDefinition: "News" }); logFieldArrayDifferences([src], [tgt]); const calls = (console.log as jest.Mock).mock.calls.map((c) => c[0]); - const settingsLine = calls.find((msg: string) => msg.includes('Settings')); + const settingsLine = calls.find((msg: string) => msg.includes("Settings")); expect(settingsLine).toBeDefined(); - expect(settingsLine).toContain('Blog'); - expect(settingsLine).toContain('News'); + expect(settingsLine).toContain("Blog"); + expect(settingsLine).toContain("News"); }); - it('does not log a settings diff line when settings are deeply equal', () => { - const settings = { Required: 'true', MaxLength: '255' }; - const src = makeField('Title', 'Title', 'Text', settings); - const tgt = makeField('Title', 'Title', 'Text', { ...settings }); + it("does not log a settings diff line when settings are deeply equal", () => { + const settings = { Required: "true", MaxLength: "255" }; + const src = makeField("Title", "Title", "Text", settings); + const tgt = makeField("Title", "Title", "Text", { ...settings }); logFieldArrayDifferences([src], [tgt]); const calls = (console.log as jest.Mock).mock.calls.map((c) => c[0]); - const settingsLine = calls.find((msg: string) => msg.includes('Settings')); + const settingsLine = calls.find((msg: string) => msg.includes("Settings")); expect(settingsLine).toBeUndefined(); }); }); - describe('shared fields with multiple differences', () => { - it('logs all differing properties for a single shared field', () => { - const src = makeField('Item', 'Item Label', 'Text', { Required: 'true' }); - const tgt = makeField('Item', 'Item Heading', 'HTML', { Required: 'false' }); + describe("shared fields with multiple differences", () => { + it("logs all differing properties for a single shared field", () => { + const src = makeField("Item", "Item Label", "Text", { Required: "true" }); + const tgt = makeField("Item", "Item Heading", "HTML", { Required: "false" }); logFieldArrayDifferences([src], [tgt]); const calls = (console.log as jest.Mock).mock.calls.map((c) => c[0]); - const labelLine = calls.find((msg: string) => msg.includes('Label')); - const typeLine = calls.find((msg: string) => msg.includes('Type')); - const settingsLine = calls.find((msg: string) => msg.includes('Settings')); + const labelLine = calls.find((msg: string) => msg.includes("Label")); + const typeLine = calls.find((msg: string) => msg.includes("Type")); + const settingsLine = calls.find((msg: string) => msg.includes("Settings")); expect(labelLine).toBeDefined(); expect(typeLine).toBeDefined(); expect(settingsLine).toBeDefined(); }); }); - describe('mixed field arrays', () => { - it('handles source-only, target-only, matching, and differing fields together', () => { + describe("mixed field arrays", () => { + it("handles source-only, target-only, matching, and differing fields together", () => { const srcFields = [ - makeField('Title', 'Title', 'Text'), - makeField('NewSrc', 'New', 'Text'), - makeField('Shared', 'Same', 'Text'), + makeField("Title", "Title", "Text"), + makeField("NewSrc", "New", "Text"), + makeField("Shared", "Same", "Text"), ]; const tgtFields = [ - makeField('Title', 'Title Changed', 'Text'), - makeField('OldTgt', 'Old', 'HTML'), - makeField('Shared', 'Same', 'Text'), + makeField("Title", "Title Changed", "Text"), + makeField("OldTgt", "Old", "HTML"), + makeField("Shared", "Same", "Text"), ]; expect(() => logFieldArrayDifferences(srcFields, tgtFields)).not.toThrow(); const calls = (console.log as jest.Mock).mock.calls.map((c) => c[0]); - expect(calls.find((msg: string) => msg.includes('Source Field only') && msg.includes('NewSrc'))).toBeDefined(); - expect(calls.find((msg: string) => msg.includes('Target Field only') && msg.includes('OldTgt'))).toBeDefined(); - expect(calls.find((msg: string) => msg.includes('differs') && msg.includes('Title'))).toBeDefined(); + expect(calls.find((msg: string) => msg.includes("Source Field only") && msg.includes("NewSrc"))).toBeDefined(); + expect(calls.find((msg: string) => msg.includes("Target Field only") && msg.includes("OldTgt"))).toBeDefined(); + expect(calls.find((msg: string) => msg.includes("differs") && msg.includes("Title"))).toBeDefined(); }); - it('does not emit a diff line for a field present in both with identical properties', () => { - const field = makeField('Stable', 'Stable', 'Text'); - const srcFields = [field, makeField('Changed', 'Old', 'Text')]; - const tgtFields = [field, makeField('Changed', 'New', 'Text')]; + it("does not emit a diff line for a field present in both with identical properties", () => { + const field = makeField("Stable", "Stable", "Text"); + const srcFields = [field, makeField("Changed", "Old", "Text")]; + const tgtFields = [field, makeField("Changed", "New", "Text")]; logFieldArrayDifferences(srcFields, tgtFields); const calls = (console.log as jest.Mock).mock.calls.map((c) => c[0]); - const stableDiffLine = calls.find( - (msg: string) => msg.includes('differs') && msg.includes('Stable') - ); + const stableDiffLine = calls.find((msg: string) => msg.includes("differs") && msg.includes("Stable")); expect(stableDiffLine).toBeUndefined(); }); }); - describe('table-driven: source/target combinations', () => { + describe("table-driven: source/target combinations", () => { it.each([ - ['only source fields', [makeField('A', 'A', 'Text')], [], 'Source Field only'], - ['only target fields', [], [makeField('B', 'B', 'Text')], 'Target Field only'], - ])('%s produces the expected log message', (_label, src, tgt, expected) => { + ["only source fields", [makeField("A", "A", "Text")], [], "Source Field only"], + ["only target fields", [], [makeField("B", "B", "Text")], "Target Field only"], + ])("%s produces the expected log message", (_label, src, tgt, expected) => { logFieldArrayDifferences(src, tgt); const calls = (console.log as jest.Mock).mock.calls.map((c: any[]) => c[0] as string); expect(calls.some((msg) => msg.includes(expected))).toBe(true); diff --git a/src/lib/mappers/asset-mapper.ts b/src/lib/mappers/asset-mapper.ts index 8f72872..3aca31e 100644 --- a/src/lib/mappers/asset-mapper.ts +++ b/src/lib/mappers/asset-mapper.ts @@ -2,189 +2,194 @@ import { fileOperations } from "../../core"; import * as mgmtApi from "@agility/management-sdk"; interface AssetMapping { - sourceGuid: string; - targetGuid: string; - sourceDateModified: string; - targetDateModified: string; - sourceMediaID: number; - targetMediaID: number; - sourceUrl?: string; - targetUrl?: string; - sourceContainerEdgeUrl?: string; - targetContainerEdgeUrl?: string; - sourceContainerOriginUrl?: string; - targetContainerOriginUrl?: string; + sourceGuid: string; + targetGuid: string; + sourceDateModified: string; + targetDateModified: string; + sourceMediaID: number; + targetMediaID: number; + sourceUrl?: string; + targetUrl?: string; + sourceContainerEdgeUrl?: string; + targetContainerEdgeUrl?: string; + sourceContainerOriginUrl?: string; + targetContainerOriginUrl?: string; } - export class AssetMapper { - private fileOps: fileOperations; - private sourceGuid: string; - private targetGuid: string; - private mappings: AssetMapping[]; - private directory: string; - - constructor(sourceGuid: string, targetGuid: string) { - this.sourceGuid = sourceGuid; - this.targetGuid = targetGuid; - this.directory = 'assets'; - - // this will provide access to the /agility-files/{GUID} folder - this.fileOps = new fileOperations(targetGuid) - this.mappings = this.loadMapping(); - + private fileOps: fileOperations; + private sourceGuid: string; + private targetGuid: string; + private mappings: AssetMapping[]; + private directory: string; + + constructor(sourceGuid: string, targetGuid: string) { + this.sourceGuid = sourceGuid; + this.targetGuid = targetGuid; + this.directory = "assets"; + + // this will provide access to the /agility-files/{GUID} folder + this.fileOps = new fileOperations(targetGuid); + this.mappings = this.loadMapping(); + } + + getAssetMapping(asset: mgmtApi.Media, type: "source" | "target"): AssetMapping | null { + const mapping = this.mappings.find((m: AssetMapping) => + type === "source" ? m.sourceMediaID === asset.mediaID : m.targetMediaID === asset.mediaID + ); + if (!mapping) return null; + return mapping; + } + + getAssetMappingByMediaID(mediaID: number, type: "source" | "target"): AssetMapping | null { + const mapping = this.mappings.find((m: AssetMapping) => + type === "source" ? m.sourceMediaID === mediaID : m.targetMediaID === mediaID + ); + if (!mapping) return null; + return mapping; + } + + getAssetMappingByMediaUrl(url: string, type: "source" | "target"): AssetMapping | null { + // Try exact match first + const exact = this.mappings.find((m: AssetMapping) => + type === "source" ? m.sourceUrl === url : m.targetUrl === url + ); + if (exact) return exact; + + // Fallback: match by container prefix (handles subfolder paths like /mobile/feature-carousel/) + return this.findMappingByContainerPrefix(url, type); + } + + /** + * Remap a URL from source container to target container, preserving any subfolder path. + * e.g. "cdn-usa2.aglty.io/brightstar-tns-cat/mobile/feature-carousel/file.png" + * → "cdn-usa2.aglty.io/2151a7f2/mobile/feature-carousel/file.png" + * + * Returns null if the URL doesn't match any mapping's container prefix. + */ + remapUrlByContainer(url: string, type: "source" | "target"): string | null { + const mapping = this.findMappingByContainerPrefix(url, type); + if (!mapping) return null; + + // Determine which container URLs to use based on whether this is an edge or origin URL + const sourceEdge = type === "source" ? mapping.sourceContainerEdgeUrl : mapping.targetContainerEdgeUrl; + const targetEdge = type === "source" ? mapping.targetContainerEdgeUrl : mapping.sourceContainerEdgeUrl; + const sourceOrigin = type === "source" ? mapping.sourceContainerOriginUrl : mapping.targetContainerOriginUrl; + const targetOrigin = type === "source" ? mapping.targetContainerOriginUrl : mapping.sourceContainerOriginUrl; + + // Try edge URL swap first, then origin URL swap + if (sourceEdge && targetEdge && url.startsWith(sourceEdge)) { + return url.replace(sourceEdge, targetEdge); } - - getAssetMapping(asset: mgmtApi.Media, type: 'source' | 'target'): AssetMapping | null { - const mapping = this.mappings.find((m: AssetMapping) => type === 'source' ? m.sourceMediaID === asset.mediaID : m.targetMediaID === asset.mediaID); - if (!mapping) return null; - return mapping; - } - - getAssetMappingByMediaID(mediaID: number, type: 'source' | 'target'): AssetMapping | null { - const mapping = this.mappings.find((m: AssetMapping) => type === 'source' ? m.sourceMediaID === mediaID : m.targetMediaID === mediaID); - if (!mapping) return null; - return mapping; + if (sourceOrigin && targetOrigin && url.startsWith(sourceOrigin)) { + return url.replace(sourceOrigin, targetOrigin); } - getAssetMappingByMediaUrl(url: string, type: 'source' | 'target'): AssetMapping | null { - // Try exact match first - const exact = this.mappings.find((m: AssetMapping) => type === 'source' ? m.sourceUrl === url : m.targetUrl === url); - if (exact) return exact; - - // Fallback: match by container prefix (handles subfolder paths like /mobile/feature-carousel/) - return this.findMappingByContainerPrefix(url, type); + return null; + } + + private findMappingByContainerPrefix(url: string, type: "source" | "target"): AssetMapping | null { + return ( + this.mappings.find((m: AssetMapping) => { + const edgeUrl = type === "source" ? m.sourceContainerEdgeUrl : m.targetContainerEdgeUrl; + const originUrl = type === "source" ? m.sourceContainerOriginUrl : m.targetContainerOriginUrl; + return (edgeUrl && url.startsWith(edgeUrl + "/")) || (originUrl && url.startsWith(originUrl + "/")); + }) || null + ); + } + + getMappedEntity(mapping: AssetMapping, type: "source" | "target"): mgmtApi.Media | null { + const guid = type === "source" ? mapping.sourceGuid : mapping.targetGuid; + const mediaID = type === "source" ? mapping.sourceMediaID : mapping.targetMediaID; + const fileOps = new fileOperations(guid); + const mediaFilePath = fileOps.getDataFilePath(`assets/${mediaID}.json`); + const mediaData = fileOps.readJsonFile(mediaFilePath); + if (!mediaData) return null; + return mediaData as mgmtApi.Media; + } + + addMapping(sourceAsset: mgmtApi.Media, targetAsset: mgmtApi.Media) { + const targetMapping = this.getAssetMapping(targetAsset, "target"); + const sourceMapping = this.getAssetMapping(sourceAsset, "source"); + + if (targetMapping && sourceMapping && targetMapping !== sourceMapping) { + throw new Error( + `Invalid Mappings detected! Source mediaID: ${sourceAsset.mediaID}, Target mediaID: ${targetAsset.mediaID}` + ); } - /** - * Remap a URL from source container to target container, preserving any subfolder path. - * e.g. "cdn-usa2.aglty.io/brightstar-tns-cat/mobile/feature-carousel/file.png" - * → "cdn-usa2.aglty.io/2151a7f2/mobile/feature-carousel/file.png" - * - * Returns null if the URL doesn't match any mapping's container prefix. - */ - remapUrlByContainer(url: string, type: 'source' | 'target'): string | null { - const mapping = this.findMappingByContainerPrefix(url, type); - if (!mapping) return null; - - // Determine which container URLs to use based on whether this is an edge or origin URL - const sourceEdge = type === 'source' ? mapping.sourceContainerEdgeUrl : mapping.targetContainerEdgeUrl; - const targetEdge = type === 'source' ? mapping.targetContainerEdgeUrl : mapping.sourceContainerEdgeUrl; - const sourceOrigin = type === 'source' ? mapping.sourceContainerOriginUrl : mapping.targetContainerOriginUrl; - const targetOrigin = type === 'source' ? mapping.targetContainerOriginUrl : mapping.sourceContainerOriginUrl; - - // Try edge URL swap first, then origin URL swap - if (sourceEdge && targetEdge && url.startsWith(sourceEdge)) { - return url.replace(sourceEdge, targetEdge); - } - if (sourceOrigin && targetOrigin && url.startsWith(sourceOrigin)) { - return url.replace(sourceOrigin, targetOrigin); - } - - return null; + if (targetMapping) { + this.updateMapping(sourceAsset, targetAsset, targetMapping); + } else { + const newMapping: AssetMapping = { + sourceGuid: this.sourceGuid, + targetGuid: this.targetGuid, + sourceDateModified: sourceAsset.dateModified, + targetDateModified: targetAsset.dateModified, + sourceMediaID: sourceAsset.mediaID, + targetMediaID: targetAsset.mediaID, + sourceUrl: sourceAsset.edgeUrl, + targetUrl: targetAsset.edgeUrl, + sourceContainerEdgeUrl: sourceAsset.containerEdgeUrl, + targetContainerEdgeUrl: targetAsset.containerEdgeUrl, + sourceContainerOriginUrl: sourceAsset.containerOriginUrl, + targetContainerOriginUrl: targetAsset.containerOriginUrl, + }; + + this.mappings.push(newMapping); } - private findMappingByContainerPrefix(url: string, type: 'source' | 'target'): AssetMapping | null { - return this.mappings.find((m: AssetMapping) => { - const edgeUrl = type === 'source' ? m.sourceContainerEdgeUrl : m.targetContainerEdgeUrl; - const originUrl = type === 'source' ? m.sourceContainerOriginUrl : m.targetContainerOriginUrl; - return (edgeUrl && url.startsWith(edgeUrl + '/')) || (originUrl && url.startsWith(originUrl + '/')); - }) || null; - } - - getMappedEntity(mapping: AssetMapping, type: 'source' | 'target'): mgmtApi.Media | null { - const guid = type === 'source' ? mapping.sourceGuid : mapping.targetGuid; - const mediaID = type === 'source' ? mapping.sourceMediaID : mapping.targetMediaID; - const fileOps = new fileOperations(guid); - const mediaFilePath = fileOps.getDataFilePath(`assets/${mediaID}.json`); - const mediaData = fileOps.readJsonFile(mediaFilePath); - if (!mediaData) return null; - return mediaData as mgmtApi.Media; - } - - addMapping(sourceAsset: mgmtApi.Media, targetAsset: mgmtApi.Media) { - const targetMapping = this.getAssetMapping(targetAsset, 'target'); - const sourceMapping = this.getAssetMapping(sourceAsset, 'source'); - - if (targetMapping && sourceMapping && targetMapping !== sourceMapping) { - throw new Error(`Invalid Mappings detected! Source mediaID: ${sourceAsset.mediaID}, Target mediaID: ${targetAsset.mediaID}`); - } - - if (targetMapping) { - this.updateMapping(sourceAsset, targetAsset, targetMapping); - } else { - - const newMapping: AssetMapping = { - sourceGuid: this.sourceGuid, - targetGuid: this.targetGuid, - sourceDateModified: sourceAsset.dateModified, - targetDateModified: targetAsset.dateModified, - sourceMediaID: sourceAsset.mediaID, - targetMediaID: targetAsset.mediaID, - sourceUrl: sourceAsset.edgeUrl, - targetUrl: targetAsset.edgeUrl, - sourceContainerEdgeUrl: sourceAsset.containerEdgeUrl, - targetContainerEdgeUrl: targetAsset.containerEdgeUrl, - sourceContainerOriginUrl: sourceAsset.containerOriginUrl, - targetContainerOriginUrl: targetAsset.containerOriginUrl, - } - - this.mappings.push(newMapping); - } - - this.saveMapping(); - } - - updateMapping(sourceAsset: mgmtApi.Media, targetAsset: mgmtApi.Media, mapping: AssetMapping) { - if (targetAsset.mediaID !== mapping.targetMediaID) { - throw new Error(`Invalid items trying to be mapped! Source mediaID: ${sourceAsset.mediaID}, Target mediaID: ${targetAsset.mediaID}`); - } - mapping.sourceGuid = this.sourceGuid; - mapping.targetGuid = this.targetGuid; - mapping.sourceDateModified = sourceAsset.dateModified; - mapping.targetDateModified = targetAsset.dateModified; - mapping.sourceMediaID = sourceAsset.mediaID; - mapping.targetMediaID = targetAsset.mediaID; - mapping.sourceUrl = sourceAsset.edgeUrl; - mapping.targetUrl = targetAsset.edgeUrl; - mapping.sourceContainerEdgeUrl = sourceAsset.containerEdgeUrl; - mapping.targetContainerEdgeUrl = targetAsset.containerEdgeUrl; - mapping.sourceContainerOriginUrl = sourceAsset.containerOriginUrl; - mapping.targetContainerOriginUrl = targetAsset.containerOriginUrl; - this.saveMapping(); - } + this.saveMapping(); + } - loadMapping() { - const mapping = this.fileOps.getMappingFile(this.directory, this.sourceGuid, this.targetGuid); - return mapping; + updateMapping(sourceAsset: mgmtApi.Media, targetAsset: mgmtApi.Media, mapping: AssetMapping) { + if (targetAsset.mediaID !== mapping.targetMediaID) { + throw new Error( + `Invalid items trying to be mapped! Source mediaID: ${sourceAsset.mediaID}, Target mediaID: ${targetAsset.mediaID}` + ); } - - saveMapping() { - this.fileOps.saveMappingFile(this.mappings, this.directory, this.sourceGuid, this.targetGuid); - } - - hasSourceChanged(sourceAsset: mgmtApi.Media | null | undefined) { - if (!sourceAsset) return false; - const mapping = this.getAssetMapping(sourceAsset, 'source'); - if (!mapping) return false; - - const sourceDate = new Date(sourceAsset.dateModified); - const mappingDate = new Date(mapping.sourceDateModified); - return sourceDate > mappingDate; - - } - - hasTargetChanged(targetAsset?: mgmtApi.Media | null | undefined) { - - if (!targetAsset) return false; - const mapping = this.getAssetMapping(targetAsset, 'target'); - if (!mapping) return false; - - const targetDate = new Date(targetAsset.dateModified); - const mappingDate = new Date(mapping.targetDateModified); - - return targetDate > mappingDate; - } - - -} \ No newline at end of file + mapping.sourceGuid = this.sourceGuid; + mapping.targetGuid = this.targetGuid; + mapping.sourceDateModified = sourceAsset.dateModified; + mapping.targetDateModified = targetAsset.dateModified; + mapping.sourceMediaID = sourceAsset.mediaID; + mapping.targetMediaID = targetAsset.mediaID; + mapping.sourceUrl = sourceAsset.edgeUrl; + mapping.targetUrl = targetAsset.edgeUrl; + mapping.sourceContainerEdgeUrl = sourceAsset.containerEdgeUrl; + mapping.targetContainerEdgeUrl = targetAsset.containerEdgeUrl; + mapping.sourceContainerOriginUrl = sourceAsset.containerOriginUrl; + mapping.targetContainerOriginUrl = targetAsset.containerOriginUrl; + this.saveMapping(); + } + + loadMapping() { + const mapping = this.fileOps.getMappingFile(this.directory, this.sourceGuid, this.targetGuid); + return mapping; + } + + saveMapping() { + this.fileOps.saveMappingFile(this.mappings, this.directory, this.sourceGuid, this.targetGuid); + } + + hasSourceChanged(sourceAsset: mgmtApi.Media | null | undefined) { + if (!sourceAsset) return false; + const mapping = this.getAssetMapping(sourceAsset, "source"); + if (!mapping) return false; + + const sourceDate = new Date(sourceAsset.dateModified); + const mappingDate = new Date(mapping.sourceDateModified); + return sourceDate > mappingDate; + } + + hasTargetChanged(targetAsset?: mgmtApi.Media | null | undefined) { + if (!targetAsset) return false; + const mapping = this.getAssetMapping(targetAsset, "target"); + if (!mapping) return false; + + const targetDate = new Date(targetAsset.dateModified); + const mappingDate = new Date(mapping.targetDateModified); + + return targetDate > mappingDate; + } +} diff --git a/src/lib/mappers/container-mapper.ts b/src/lib/mappers/container-mapper.ts index ef96ef3..f3e70a7 100644 --- a/src/lib/mappers/container-mapper.ts +++ b/src/lib/mappers/container-mapper.ts @@ -5,175 +5,177 @@ import * as mgmtApi from "@agility/management-sdk"; //TODO: Change to use lastModifiedOn instead of lastModifiedDate when the fix to that is deployed! interface ContainerMapping { - sourceGuid: string; - targetGuid: string; - sourceContentViewID: number; - targetContentViewID: number; - sourceReferenceName?: string; - targetReferenceName?: string; - sourceLastModifiedDate: string; - targetLastModifiedDate: string; + sourceGuid: string; + targetGuid: string; + sourceContentViewID: number; + targetContentViewID: number; + sourceReferenceName?: string; + targetReferenceName?: string; + sourceLastModifiedDate: string; + targetLastModifiedDate: string; } - export class ContainerMapper { - private fileOps: fileOperations; - private sourceGuid: string; - private targetGuid: string; - private mappings: ContainerMapping[]; - private directory: string; - - constructor(sourceGuid: string, targetGuid: string) { - this.sourceGuid = sourceGuid; - this.targetGuid = targetGuid; - this.directory = 'containers'; - // this will provide access to the /agility-files/{GUID} folder - this.fileOps = new fileOperations(targetGuid); - this.mappings = this.loadMapping(); - - } - - getContainerMapping(container: mgmtApi.Container, type: 'source' | 'target'): ContainerMapping | null { - const mapping = this.mappings.find((m: ContainerMapping) => - type === 'source' ? m.sourceContentViewID === container.contentViewID : m.targetContentViewID === container.contentViewID - ); - if (!mapping) return null; - return mapping; - } - - getContainerMappingByContentViewID(contentViewID: number, type: 'source' | 'target'): ContainerMapping | null { - const mapping = this.mappings.find((m: ContainerMapping) => - type === 'source' ? m.sourceContentViewID === contentViewID : m.targetContentViewID === contentViewID - ); - if (!mapping) return null; - return mapping; - } - - getContainerMappingByReferenceName(referenceName: string, type: 'source' | 'target'): ContainerMapping | null { - const refNameLower = referenceName.toLowerCase(); - const mapping = this.mappings.find((m: ContainerMapping) => - type === 'source' ? - m.sourceReferenceName.toLowerCase() === refNameLower : - m.targetReferenceName.toLowerCase() === refNameLower - ); - if (!mapping) return null; - return mapping; - } - - getMappedEntity(mapping: ContainerMapping, type: 'source' | 'target'): mgmtApi.Container | null { - if (!mapping) return null; - //fetch the container from the file system based on source or target GUID - const guid = type === 'source' ? mapping.sourceGuid : mapping.targetGuid; - const containerID = type === 'source' ? mapping.sourceContentViewID : mapping.targetContentViewID; - const fileOps = new fileOperations(guid); - const containerData = fileOps.readJsonFile(`containers/${containerID}.json`); - if (!containerData) return null; - return containerData as mgmtApi.Container; - } - - getContainerByReferenceName(referenceName: string, type: 'source' | 'target'): mgmtApi.Container | null { - //try to get the mapping first.. - const mapping = this.getContainerMappingByReferenceName(referenceName, type); - if (mapping) { - return this.getMappedEntity(mapping, type); - } else { - //if there's no mappping, we have to loop through ALL the containers to find it - const guid = type === 'source' ? mapping.sourceGuid : mapping.targetGuid; - const fileOps = new fileOperations(guid); - const containerFiles = fileOps.listFilesInFolder(`containers`); - - for (const file of containerFiles) { - try { - const containerData = fileOps.readJsonFile(`containers/${file}`); - if (containerData && containerData.referenceName && containerData.referenceName.toLowerCase() === referenceName.toLowerCase()) { - return containerData as mgmtApi.Container; - } - } catch (error) { - // If there's an error reading the file, we just skip it - } - } - - } - return null; - } - - addMapping(sourceContainer: mgmtApi.Container, targetContainer: mgmtApi.Container) { - const targetMapping = this.getContainerMapping(targetContainer, 'target'); - const sourceMapping = this.getContainerMapping(sourceContainer, 'source'); - - if (targetMapping && sourceMapping && targetMapping !== sourceMapping) { - throw new Error(`Invalid Mappings detected! Source contentViewID: ${sourceContainer.contentViewID}, Target contentViewID: ${targetContainer.contentViewID}`); - } - - if (targetMapping) { - this.updateMapping(sourceContainer, targetContainer, targetMapping); - } else { - - const newMapping: ContainerMapping = { - sourceGuid: this.sourceGuid, - targetGuid: this.targetGuid, - sourceContentViewID: sourceContainer.contentViewID, - targetContentViewID: targetContainer.contentViewID, - sourceLastModifiedDate: sourceContainer.lastModifiedDate, - targetLastModifiedDate: targetContainer.lastModifiedDate, - sourceReferenceName: sourceContainer.referenceName, - targetReferenceName: targetContainer.referenceName - - } - - this.mappings.push(newMapping); + private fileOps: fileOperations; + private sourceGuid: string; + private targetGuid: string; + private mappings: ContainerMapping[]; + private directory: string; + + constructor(sourceGuid: string, targetGuid: string) { + this.sourceGuid = sourceGuid; + this.targetGuid = targetGuid; + this.directory = "containers"; + // this will provide access to the /agility-files/{GUID} folder + this.fileOps = new fileOperations(targetGuid); + this.mappings = this.loadMapping(); + } + + getContainerMapping(container: mgmtApi.Container, type: "source" | "target"): ContainerMapping | null { + const mapping = this.mappings.find((m: ContainerMapping) => + type === "source" + ? m.sourceContentViewID === container.contentViewID + : m.targetContentViewID === container.contentViewID + ); + if (!mapping) return null; + return mapping; + } + + getContainerMappingByContentViewID(contentViewID: number, type: "source" | "target"): ContainerMapping | null { + const mapping = this.mappings.find((m: ContainerMapping) => + type === "source" ? m.sourceContentViewID === contentViewID : m.targetContentViewID === contentViewID + ); + if (!mapping) return null; + return mapping; + } + + getContainerMappingByReferenceName(referenceName: string, type: "source" | "target"): ContainerMapping | null { + const refNameLower = referenceName.toLowerCase(); + const mapping = this.mappings.find((m: ContainerMapping) => + type === "source" + ? m.sourceReferenceName.toLowerCase() === refNameLower + : m.targetReferenceName.toLowerCase() === refNameLower + ); + if (!mapping) return null; + return mapping; + } + + getMappedEntity(mapping: ContainerMapping, type: "source" | "target"): mgmtApi.Container | null { + if (!mapping) return null; + //fetch the container from the file system based on source or target GUID + const guid = type === "source" ? mapping.sourceGuid : mapping.targetGuid; + const containerID = type === "source" ? mapping.sourceContentViewID : mapping.targetContentViewID; + const fileOps = new fileOperations(guid); + const containerData = fileOps.readJsonFile(`containers/${containerID}.json`); + if (!containerData) return null; + return containerData as mgmtApi.Container; + } + + getContainerByReferenceName(referenceName: string, type: "source" | "target"): mgmtApi.Container | null { + //try to get the mapping first.. + const mapping = this.getContainerMappingByReferenceName(referenceName, type); + if (mapping) { + return this.getMappedEntity(mapping, type); + } else { + //if there's no mappping, we have to loop through ALL the containers to find it + const guid = type === "source" ? mapping.sourceGuid : mapping.targetGuid; + const fileOps = new fileOperations(guid); + const containerFiles = fileOps.listFilesInFolder(`containers`); + + for (const file of containerFiles) { + try { + const containerData = fileOps.readJsonFile(`containers/${file}`); + if ( + containerData && + containerData.referenceName && + containerData.referenceName.toLowerCase() === referenceName.toLowerCase() + ) { + return containerData as mgmtApi.Container; + } + } catch (error) { + // If there's an error reading the file, we just skip it } - - this.saveMapping(); + } } + return null; + } - updateMapping(sourceContainer: mgmtApi.Container, targetContainer: mgmtApi.Container, mapping: ContainerMapping) { - if (targetContainer.contentViewID !== mapping.targetContentViewID) { - throw new Error(`Invalid items trying to be mapped! Source contentViewID: ${sourceContainer.contentViewID}, Target contentViewID: ${targetContainer.contentViewID}`); - } - mapping.sourceGuid = this.sourceGuid; - mapping.targetGuid = this.targetGuid; - mapping.sourceContentViewID = sourceContainer.contentViewID; - mapping.targetContentViewID = targetContainer.contentViewID; - mapping.sourceLastModifiedDate = sourceContainer.lastModifiedDate; - mapping.targetLastModifiedDate = targetContainer.lastModifiedDate; - mapping.sourceReferenceName = sourceContainer.referenceName; - mapping.targetReferenceName = targetContainer.referenceName; - this.saveMapping(); - } + addMapping(sourceContainer: mgmtApi.Container, targetContainer: mgmtApi.Container) { + const targetMapping = this.getContainerMapping(targetContainer, "target"); + const sourceMapping = this.getContainerMapping(sourceContainer, "source"); - loadMapping() { - const mapping = this.fileOps.getMappingFile(this.directory, this.sourceGuid, this.targetGuid); - return mapping; + if (targetMapping && sourceMapping && targetMapping !== sourceMapping) { + throw new Error( + `Invalid Mappings detected! Source contentViewID: ${sourceContainer.contentViewID}, Target contentViewID: ${targetContainer.contentViewID}` + ); } - saveMapping() { - this.fileOps.saveMappingFile(this.mappings, this.directory, this.sourceGuid, this.targetGuid); + if (targetMapping) { + this.updateMapping(sourceContainer, targetContainer, targetMapping); + } else { + const newMapping: ContainerMapping = { + sourceGuid: this.sourceGuid, + targetGuid: this.targetGuid, + sourceContentViewID: sourceContainer.contentViewID, + targetContentViewID: targetContainer.contentViewID, + sourceLastModifiedDate: sourceContainer.lastModifiedDate, + targetLastModifiedDate: targetContainer.lastModifiedDate, + sourceReferenceName: sourceContainer.referenceName, + targetReferenceName: targetContainer.referenceName, + }; + + this.mappings.push(newMapping); } - hasSourceChanged(sourceContainer: mgmtApi.Container | null | undefined) { - if (!sourceContainer) return false; - const mapping = this.getContainerMapping(sourceContainer, 'source'); - if (!mapping) return false; - - //the date format is: 07/23/2025 08:22PM (MM/DD/YYYY hh:mma) so we need to convert it to a Date object - // Note: This assumes the date is in the format MM/DD/YYYY hh:mma - // If the date format is different, you may need to adjust the parsing logic accordingly - const sourceDate = parse(sourceContainer.lastModifiedDate, "MM/dd/yyyy hh:mma", new Date()); - const mappedDate = parse(mapping.sourceLastModifiedDate, "MM/dd/yyyy hh:mma", new Date()); - - return sourceDate > mappedDate; - } + this.saveMapping(); + } - hasTargetChanged(targetContainer: mgmtApi.Container | null | undefined) { - if (!targetContainer) return false; - const mapping = this.getContainerMapping(targetContainer, 'target'); - if (!mapping) return false; - const targetDate = parse(targetContainer.lastModifiedDate, "MM/dd/yyyy hh:mma", new Date()); - const mappedDate = parse(mapping.targetLastModifiedDate, "MM/dd/yyyy hh:mma", new Date()); - return targetDate > mappedDate; + updateMapping(sourceContainer: mgmtApi.Container, targetContainer: mgmtApi.Container, mapping: ContainerMapping) { + if (targetContainer.contentViewID !== mapping.targetContentViewID) { + throw new Error( + `Invalid items trying to be mapped! Source contentViewID: ${sourceContainer.contentViewID}, Target contentViewID: ${targetContainer.contentViewID}` + ); } - - - -} \ No newline at end of file + mapping.sourceGuid = this.sourceGuid; + mapping.targetGuid = this.targetGuid; + mapping.sourceContentViewID = sourceContainer.contentViewID; + mapping.targetContentViewID = targetContainer.contentViewID; + mapping.sourceLastModifiedDate = sourceContainer.lastModifiedDate; + mapping.targetLastModifiedDate = targetContainer.lastModifiedDate; + mapping.sourceReferenceName = sourceContainer.referenceName; + mapping.targetReferenceName = targetContainer.referenceName; + this.saveMapping(); + } + + loadMapping() { + const mapping = this.fileOps.getMappingFile(this.directory, this.sourceGuid, this.targetGuid); + return mapping; + } + + saveMapping() { + this.fileOps.saveMappingFile(this.mappings, this.directory, this.sourceGuid, this.targetGuid); + } + + hasSourceChanged(sourceContainer: mgmtApi.Container | null | undefined) { + if (!sourceContainer) return false; + const mapping = this.getContainerMapping(sourceContainer, "source"); + if (!mapping) return false; + + //the date format is: 07/23/2025 08:22PM (MM/DD/YYYY hh:mma) so we need to convert it to a Date object + // Note: This assumes the date is in the format MM/DD/YYYY hh:mma + // If the date format is different, you may need to adjust the parsing logic accordingly + const sourceDate = parse(sourceContainer.lastModifiedDate, "MM/dd/yyyy hh:mma", new Date()); + const mappedDate = parse(mapping.sourceLastModifiedDate, "MM/dd/yyyy hh:mma", new Date()); + + return sourceDate > mappedDate; + } + + hasTargetChanged(targetContainer: mgmtApi.Container | null | undefined) { + if (!targetContainer) return false; + const mapping = this.getContainerMapping(targetContainer, "target"); + if (!mapping) return false; + const targetDate = parse(targetContainer.lastModifiedDate, "MM/dd/yyyy hh:mma", new Date()); + const mappedDate = parse(mapping.targetLastModifiedDate, "MM/dd/yyyy hh:mma", new Date()); + return targetDate > mappedDate; + } +} diff --git a/src/lib/mappers/content-item-mapper.ts b/src/lib/mappers/content-item-mapper.ts index 38d1ab4..fc10a0b 100644 --- a/src/lib/mappers/content-item-mapper.ts +++ b/src/lib/mappers/content-item-mapper.ts @@ -3,199 +3,217 @@ import * as mgmtApi from "@agility/management-sdk"; import { ContainerMapper } from "./container-mapper"; export interface ContentItemMapping { - sourceGuid: string; - targetGuid: string; - sourceContentID: number; - targetContentID: number; - sourceVersionID: number; - targetVersionID: number; + sourceGuid: string; + targetGuid: string; + sourceContentID: number; + targetContentID: number; + sourceVersionID: number; + targetVersionID: number; } - export class ContentItemMapper { - private fileOps: fileOperations; - private sourceGuid: string; - private targetGuid: string; - private mappings: ContentItemMapping[]; - private directory: string; - private containerMapper: ContainerMapper; - public locale: string; - - constructor(sourceGuid: string, targetGuid: string, locale: string) { - this.sourceGuid = sourceGuid; - this.targetGuid = targetGuid; - this.directory = 'item'; - this.locale = locale; - this.containerMapper = new ContainerMapper(sourceGuid, targetGuid); - // this will provide access to the /agility-files/{GUID}/{locale} folder - this.fileOps = new fileOperations(targetGuid, locale); - this.mappings = this.loadMapping(); - + private fileOps: fileOperations; + private sourceGuid: string; + private targetGuid: string; + private mappings: ContentItemMapping[]; + private directory: string; + private containerMapper: ContainerMapper; + public locale: string; + + constructor(sourceGuid: string, targetGuid: string, locale: string) { + this.sourceGuid = sourceGuid; + this.targetGuid = targetGuid; + this.directory = "item"; + this.locale = locale; + this.containerMapper = new ContainerMapper(sourceGuid, targetGuid); + // this will provide access to the /agility-files/{GUID}/{locale} folder + this.fileOps = new fileOperations(targetGuid, locale); + this.mappings = this.loadMapping(); + } + + getContentItemMapping(contentItem: mgmtApi.ContentItem, type: "source" | "target"): ContentItemMapping | null { + const mapping = this.mappings.find((m: ContentItemMapping) => + type === "source" ? m.sourceContentID === contentItem.contentID : m.targetContentID === contentItem.contentID + ); + if (!mapping) return null; + return mapping; + } + + getContentItemMappingByContentID(contentID: number, type: "source" | "target"): ContentItemMapping | null { + const mapping = this.mappings.find((m: ContentItemMapping) => + type === "source" ? m.sourceContentID === contentID : m.targetContentID === contentID + ); + if (!mapping) return null; + return mapping; + } + + getMappedEntity(mapping: ContentItemMapping, type: "source" | "target"): mgmtApi.ContentItem | null { + //fetch the content item from the file system based on source or target GUID + if (!mapping) { + return null; } - getContentItemMapping(contentItem: mgmtApi.ContentItem, type: 'source' | 'target'): ContentItemMapping | null { - const mapping = this.mappings.find((m: ContentItemMapping) => - type === 'source' ? m.sourceContentID === contentItem.contentID : m.targetContentID === contentItem.contentID - ); - if (!mapping) return null; - return mapping; - } - - getContentItemMappingByContentID(contentID: number, type: 'source' | 'target'): ContentItemMapping | null { - const mapping = this.mappings.find((m: ContentItemMapping) => - type === 'source' ? m.sourceContentID === contentID : m.targetContentID === contentID - ); - if (!mapping) return null; - return mapping; - } + const guid = type === "source" ? mapping.sourceGuid : mapping.targetGuid; + const contentID = type === "source" ? mapping.sourceContentID : mapping.targetContentID; - getMappedEntity(mapping: ContentItemMapping, type: 'source' | 'target'): mgmtApi.ContentItem | null { - //fetch the content item from the file system based on source or target GUID - if (!mapping) { - return null; - } - - const guid = type === 'source' ? mapping.sourceGuid : mapping.targetGuid; - const contentID = type === 'source' ? mapping.sourceContentID : mapping.targetContentID; - - if (!guid || !contentID) { - return null; - } - - try { - const fileOps = new fileOperations(guid, this.locale); - - // Use the file operations to get the content item file path - const contentData = fileOps.readJsonFile(`item/${contentID}.json`); - if (!contentData) { - // This is normal for target entities that don't exist yet - not an error - return null; - } - - // Validate that the content data has the expected structure - if (!contentData.properties) { - return null; - } - - return contentData as mgmtApi.ContentItem; - } catch (error) { - return null; - } + if (!guid || !contentID) { + return null; } - /* - * Function to check if the items are mappable. If the definitions are the same, the contentviews are mapped, then we are safe - */ - checkItemIsMappable(sourceContentItem: mgmtApi.ContentItem, targetContentItem: mgmtApi.ContentItem): void { + try { + const fileOps = new fileOperations(guid, this.locale); - // check if the models are the same - if(sourceContentItem.properties.definitionName !== targetContentItem.properties.definitionName){ - throw new Error(`Items cannot be mapped. They are not congruent, the content models are different. source contentID: ${sourceContentItem.contentID}, target contentID: ${targetContentItem.contentID}`); - } + // Use the file operations to get the content item file path + const contentData = fileOps.readJsonFile(`item/${contentID}.json`); + if (!contentData) { + // This is normal for target entities that don't exist yet - not an error + return null; + } - // use the reference name to check if the containers are truly mapped together - const sourceContainerMapping = this.containerMapper.getContainerMappingByReferenceName(sourceContentItem.properties.referenceName, "source"); - const targetContainerMapping = this.containerMapper.getContainerMappingByReferenceName(targetContentItem.properties.referenceName, "target"); + // Validate that the content data has the expected structure + if (!contentData.properties) { + return null; + } - if(sourceContainerMapping !== targetContainerMapping){ - throw new Error(`Items cannot be mapped. The containers are not mapped to each other. source contentID: ${sourceContentItem.contentID}, target contentID: ${targetContentItem.contentID}`); - } + return contentData as mgmtApi.ContentItem; + } catch (error) { + return null; } - - addMapping(sourceContentItem: mgmtApi.ContentItem, targetContentItem: mgmtApi.ContentItem) { - const targetMapping = this.getContentItemMapping(targetContentItem, 'target'); - const sourceMapping = this.getContentItemMapping(sourceContentItem, 'source') - - if(targetMapping && sourceMapping && targetMapping !== sourceMapping){ - throw new Error(`Invalid Mappings detected! The two items have different mappings, Source contentID: ${sourceContentItem.contentID}, Target contentID: ${targetContentItem.contentID}`); - } - - // If there is a source mapping and it does not match the incoming ID - if(sourceMapping && !targetMapping && sourceMapping.targetContentID !== targetContentItem.contentID){ - throw new Error(`Aborting a duplicate mapping attempt! sourceContentID: ${sourceContentItem.contentID}, orphaned targetContentID: ${targetContentItem.contentID}`); - } - - // At this point target and source mappings should be the same - if (targetMapping) { - this.updateMapping(sourceContentItem, targetContentItem, targetMapping); - } else { - const newMapping: ContentItemMapping = { - sourceGuid: this.sourceGuid, - targetGuid: this.targetGuid, - sourceContentID: sourceContentItem.contentID, - targetContentID: targetContentItem.contentID, - sourceVersionID: sourceContentItem.properties.versionID, - targetVersionID: targetContentItem.properties.versionID, - } - this.mappings.push(newMapping); - } - this.saveMapping(); + } + + /* + * Function to check if the items are mappable. If the definitions are the same, the contentviews are mapped, then we are safe + */ + checkItemIsMappable(sourceContentItem: mgmtApi.ContentItem, targetContentItem: mgmtApi.ContentItem): void { + // check if the models are the same + if (sourceContentItem.properties.definitionName !== targetContentItem.properties.definitionName) { + throw new Error( + `Items cannot be mapped. They are not congruent, the content models are different. source contentID: ${sourceContentItem.contentID}, target contentID: ${targetContentItem.contentID}` + ); } - updateMapping(sourceContentItem: mgmtApi.ContentItem, targetContentItem: mgmtApi.ContentItem, mapping: ContentItemMapping) { - - if(targetContentItem.contentID !== mapping.targetContentID){ - throw new Error(`Invalid items trying to be mapped! Source contentID: ${sourceContentItem.contentID}, Target contentID: ${targetContentItem.contentID}`); - } - - mapping.sourceGuid = this.sourceGuid; - mapping.targetGuid = this.targetGuid; - mapping.sourceContentID = sourceContentItem.contentID; - mapping.targetContentID = targetContentItem.contentID; - mapping.sourceVersionID = sourceContentItem.properties.versionID; - mapping.targetVersionID = targetContentItem.properties.versionID; - this.saveMapping(); + // use the reference name to check if the containers are truly mapped together + const sourceContainerMapping = this.containerMapper.getContainerMappingByReferenceName( + sourceContentItem.properties.referenceName, + "source" + ); + const targetContainerMapping = this.containerMapper.getContainerMappingByReferenceName( + targetContentItem.properties.referenceName, + "target" + ); + + if (sourceContainerMapping !== targetContainerMapping) { + throw new Error( + `Items cannot be mapped. The containers are not mapped to each other. source contentID: ${sourceContentItem.contentID}, target contentID: ${targetContentItem.contentID}` + ); } + } - loadMapping() { - const mapping = this.fileOps.getMappingFile(this.directory, this.sourceGuid, this.targetGuid, this.locale); - return mapping; - } + addMapping(sourceContentItem: mgmtApi.ContentItem, targetContentItem: mgmtApi.ContentItem) { + const targetMapping = this.getContentItemMapping(targetContentItem, "target"); + const sourceMapping = this.getContentItemMapping(sourceContentItem, "source"); - saveMapping() { - this.fileOps.saveMappingFile(this.mappings, this.directory, this.sourceGuid, this.targetGuid, this.locale); + if (targetMapping && sourceMapping && targetMapping !== sourceMapping) { + throw new Error( + `Invalid Mappings detected! The two items have different mappings, Source contentID: ${sourceContentItem.contentID}, Target contentID: ${targetContentItem.contentID}` + ); } - hasSourceChanged(sourceContentItem: mgmtApi.ContentItem) { - if (!sourceContentItem) return false; - const mapping = this.getContentItemMapping(sourceContentItem, 'source'); - if (!mapping) return true; - return sourceContentItem.properties.versionID > mapping.sourceVersionID; + // If there is a source mapping and it does not match the incoming ID + if (sourceMapping && !targetMapping && sourceMapping.targetContentID !== targetContentItem.contentID) { + throw new Error( + `Aborting a duplicate mapping attempt! sourceContentID: ${sourceContentItem.contentID}, orphaned targetContentID: ${targetContentItem.contentID}` + ); } - hasTargetChanged(targetContentItem: mgmtApi.ContentItem) { - const mapping = this.getContentItemMapping(targetContentItem, 'target'); - if (!mapping) return false; - return targetContentItem.properties.versionID > mapping.targetVersionID; + // At this point target and source mappings should be the same + if (targetMapping) { + this.updateMapping(sourceContentItem, targetContentItem, targetMapping); + } else { + const newMapping: ContentItemMapping = { + sourceGuid: this.sourceGuid, + targetGuid: this.targetGuid, + sourceContentID: sourceContentItem.contentID, + targetContentID: targetContentItem.contentID, + sourceVersionID: sourceContentItem.properties.versionID, + targetVersionID: targetContentItem.properties.versionID, + }; + this.mappings.push(newMapping); + } + this.saveMapping(); + } + + updateMapping( + sourceContentItem: mgmtApi.ContentItem, + targetContentItem: mgmtApi.ContentItem, + mapping: ContentItemMapping + ) { + if (targetContentItem.contentID !== mapping.targetContentID) { + throw new Error( + `Invalid items trying to be mapped! Source contentID: ${sourceContentItem.contentID}, Target contentID: ${targetContentItem.contentID}` + ); } - /** - * Update only the target versionID in a mapping (used after publishing) - * Does NOT update sourceVersionID - that should only change during sync operations - * - * @returns Object with success status and old/new version IDs - */ - updateTargetVersionID(targetContentID: number, newVersionID: number): { - success: boolean; - oldVersionID?: number; - newVersionID?: number; - } { - const mapping = this.getContentItemMappingByContentID(targetContentID, 'target'); - if (!mapping) return { success: false }; - - const oldVersionID = mapping.targetVersionID; - - // Only update if version actually changed - if (oldVersionID !== newVersionID) { - mapping.targetVersionID = newVersionID; - this.saveMapping(); - } - - return { - success: true, - oldVersionID, - newVersionID - }; + mapping.sourceGuid = this.sourceGuid; + mapping.targetGuid = this.targetGuid; + mapping.sourceContentID = sourceContentItem.contentID; + mapping.targetContentID = targetContentItem.contentID; + mapping.sourceVersionID = sourceContentItem.properties.versionID; + mapping.targetVersionID = targetContentItem.properties.versionID; + this.saveMapping(); + } + + loadMapping() { + const mapping = this.fileOps.getMappingFile(this.directory, this.sourceGuid, this.targetGuid, this.locale); + return mapping; + } + + saveMapping() { + this.fileOps.saveMappingFile(this.mappings, this.directory, this.sourceGuid, this.targetGuid, this.locale); + } + + hasSourceChanged(sourceContentItem: mgmtApi.ContentItem) { + if (!sourceContentItem) return false; + const mapping = this.getContentItemMapping(sourceContentItem, "source"); + if (!mapping) return true; + return sourceContentItem.properties.versionID > mapping.sourceVersionID; + } + + hasTargetChanged(targetContentItem: mgmtApi.ContentItem) { + const mapping = this.getContentItemMapping(targetContentItem, "target"); + if (!mapping) return false; + return targetContentItem.properties.versionID > mapping.targetVersionID; + } + + /** + * Update only the target versionID in a mapping (used after publishing) + * Does NOT update sourceVersionID - that should only change during sync operations + * + * @returns Object with success status and old/new version IDs + */ + updateTargetVersionID( + targetContentID: number, + newVersionID: number + ): { + success: boolean; + oldVersionID?: number; + newVersionID?: number; + } { + const mapping = this.getContentItemMappingByContentID(targetContentID, "target"); + if (!mapping) return { success: false }; + + const oldVersionID = mapping.targetVersionID; + + // Only update if version actually changed + if (oldVersionID !== newVersionID) { + mapping.targetVersionID = newVersionID; + this.saveMapping(); } + return { + success: true, + oldVersionID, + newVersionID, + }; + } } diff --git a/src/lib/mappers/gallery-mapper.ts b/src/lib/mappers/gallery-mapper.ts index d17ab8c..369f035 100644 --- a/src/lib/mappers/gallery-mapper.ts +++ b/src/lib/mappers/gallery-mapper.ts @@ -2,139 +2,141 @@ import { parse } from "date-fns"; import * as mgmtApi from "@agility/management-sdk"; import { fileOperations } from "../../core"; interface GalleryMapping { - sourceGuid: string; - targetGuid: string; - sourceMediaGroupingID: number; - targetMediaGroupingID: number; - sourceModifiedOn: string; - targetModifiedOn: string; + sourceGuid: string; + targetGuid: string; + sourceMediaGroupingID: number; + targetMediaGroupingID: number; + sourceModifiedOn: string; + targetModifiedOn: string; } - export class GalleryMapper { - private fileOps: fileOperations; - private sourceGuid: string; - private targetGuid: string; - private mappings: GalleryMapping[]; - private directory: string; - - constructor(sourceGuid: string, targetGuid: string) { - this.sourceGuid = sourceGuid; - this.targetGuid = targetGuid; - this.directory = 'galleries'; - // this will provide access to the /agility-files/{GUID} folder - this.fileOps = new fileOperations(targetGuid) - this.mappings = this.loadMapping(); - - } - - getGalleryMapping(gallery: mgmtApi.assetMediaGrouping, type: 'source' | 'target'): GalleryMapping | null { - const mapping = this.mappings.find((m: GalleryMapping) => - type === 'source' ? m.sourceMediaGroupingID === gallery.mediaGroupingID : m.targetMediaGroupingID === gallery.mediaGroupingID - ); - if (!mapping) return null; - return mapping; - } - - getGalleryMappingByMediaGroupingID(mediaGroupingID: number, type: 'source' | 'target'): GalleryMapping | null { - const mapping = this.mappings.find((m: GalleryMapping) => - type === 'source' ? m.sourceMediaGroupingID === mediaGroupingID : m.targetMediaGroupingID === mediaGroupingID - ); - if (!mapping) return null; - return mapping; - } - - - getMappedEntity(mapping: GalleryMapping | null, type: 'source' | 'target'): mgmtApi.assetMediaGrouping | null { - if(!mapping) return null; - const guid = type === 'source' ? mapping.sourceGuid : mapping.targetGuid; - const mediaGroupingID = type === 'source' ? mapping.sourceMediaGroupingID : mapping.targetMediaGroupingID; - const fileOps = new fileOperations(guid); - const galleriesFiles = fileOps.getFolderContents('galleries'); - - console.log('galleriesFiles',galleriesFiles) - for(const galleryFile of galleriesFiles){ - const galleryData = fileOps.readJsonFile(`galleries/${galleryFile}`); - if(galleryData.mediaGroupingID === mediaGroupingID){ - return galleryData; - } - } - return null; - } - - addMapping(sourceGallery: mgmtApi.assetMediaGrouping, targetGallery: mgmtApi.assetMediaGrouping) { - const targetMapping = this.getGalleryMapping(targetGallery, 'target'); - const sourceMapping = this.getGalleryMapping(sourceGallery, 'source'); - - if (targetMapping && sourceMapping && targetMapping !== sourceMapping) { - throw new Error(`Invalid Mappings detected! Source mediaGroupingID: ${sourceGallery.mediaGroupingID}, Target mediaGroupingID: ${targetGallery.mediaGroupingID}`); - } - - if (targetMapping) { - this.updateMapping(sourceGallery, targetGallery, targetMapping); - } else { - - const newMapping: GalleryMapping = { - sourceGuid: this.sourceGuid, - targetGuid: this.targetGuid, - sourceMediaGroupingID: sourceGallery.mediaGroupingID, - targetMediaGroupingID: targetGallery.mediaGroupingID, - sourceModifiedOn: sourceGallery.modifiedOn, - targetModifiedOn: targetGallery.modifiedOn, - - } - - this.mappings.push(newMapping); - } - - this.saveMapping(); + private fileOps: fileOperations; + private sourceGuid: string; + private targetGuid: string; + private mappings: GalleryMapping[]; + private directory: string; + + constructor(sourceGuid: string, targetGuid: string) { + this.sourceGuid = sourceGuid; + this.targetGuid = targetGuid; + this.directory = "galleries"; + // this will provide access to the /agility-files/{GUID} folder + this.fileOps = new fileOperations(targetGuid); + this.mappings = this.loadMapping(); + } + + getGalleryMapping(gallery: mgmtApi.assetMediaGrouping, type: "source" | "target"): GalleryMapping | null { + const mapping = this.mappings.find((m: GalleryMapping) => + type === "source" + ? m.sourceMediaGroupingID === gallery.mediaGroupingID + : m.targetMediaGroupingID === gallery.mediaGroupingID + ); + if (!mapping) return null; + return mapping; + } + + getGalleryMappingByMediaGroupingID(mediaGroupingID: number, type: "source" | "target"): GalleryMapping | null { + const mapping = this.mappings.find((m: GalleryMapping) => + type === "source" ? m.sourceMediaGroupingID === mediaGroupingID : m.targetMediaGroupingID === mediaGroupingID + ); + if (!mapping) return null; + return mapping; + } + + getMappedEntity(mapping: GalleryMapping | null, type: "source" | "target"): mgmtApi.assetMediaGrouping | null { + if (!mapping) return null; + const guid = type === "source" ? mapping.sourceGuid : mapping.targetGuid; + const mediaGroupingID = type === "source" ? mapping.sourceMediaGroupingID : mapping.targetMediaGroupingID; + const fileOps = new fileOperations(guid); + const galleriesFiles = fileOps.getFolderContents("galleries"); + + console.log("galleriesFiles", galleriesFiles); + for (const galleryFile of galleriesFiles) { + const galleryData = fileOps.readJsonFile(`galleries/${galleryFile}`); + if (galleryData.mediaGroupingID === mediaGroupingID) { + return galleryData; + } } + return null; + } - updateMapping(sourceGallery: mgmtApi.assetMediaGrouping, targetGallery: mgmtApi.assetMediaGrouping, mapping: GalleryMapping) { - if (targetGallery.mediaGroupingID !== mapping.targetMediaGroupingID) { - throw new Error(`Invalid items trying to be mapped! Source mediaGroupingID: ${sourceGallery.mediaGroupingID}, Target mediaGroupingID: ${targetGallery.mediaGroupingID}`); - } - mapping.sourceGuid = this.sourceGuid; - mapping.targetGuid = this.targetGuid; - mapping.sourceMediaGroupingID = sourceGallery.mediaGroupingID; - mapping.targetMediaGroupingID = targetGallery.mediaGroupingID; - mapping.sourceModifiedOn = sourceGallery.modifiedOn; - mapping.targetModifiedOn = targetGallery.modifiedOn; - this.saveMapping(); - } + addMapping(sourceGallery: mgmtApi.assetMediaGrouping, targetGallery: mgmtApi.assetMediaGrouping) { + const targetMapping = this.getGalleryMapping(targetGallery, "target"); + const sourceMapping = this.getGalleryMapping(sourceGallery, "source"); - loadMapping() { - const mapping = this.fileOps.getMappingFile(this.directory, this.sourceGuid, this.targetGuid); - return mapping; + if (targetMapping && sourceMapping && targetMapping !== sourceMapping) { + throw new Error( + `Invalid Mappings detected! Source mediaGroupingID: ${sourceGallery.mediaGroupingID}, Target mediaGroupingID: ${targetGallery.mediaGroupingID}` + ); } - saveMapping() { - this.fileOps.saveMappingFile(this.mappings, this.directory, this.sourceGuid, this.targetGuid); + if (targetMapping) { + this.updateMapping(sourceGallery, targetGallery, targetMapping); + } else { + const newMapping: GalleryMapping = { + sourceGuid: this.sourceGuid, + targetGuid: this.targetGuid, + sourceMediaGroupingID: sourceGallery.mediaGroupingID, + targetMediaGroupingID: targetGallery.mediaGroupingID, + sourceModifiedOn: sourceGallery.modifiedOn, + targetModifiedOn: targetGallery.modifiedOn, + }; + + this.mappings.push(newMapping); } - hasSourceChanged(sourceGallery: mgmtApi.assetMediaGrouping) { - const mapping = this.getGalleryMapping(sourceGallery, 'source'); - if (!mapping) return false; - - //the date format is: 07/23/2025 08:22PM (MM/DD/YYYY hh:mma) so we need to convert it to a Date object - // Note: This assumes the date is in the format MM/DD/YYYY hh:mma - // If the date format is different, you may need to adjust the parsing logic accordingly - const sourceDate = parse(sourceGallery.modifiedOn, "MM/dd/yyyy hh:mma", new Date()); - const mappedDate = parse(mapping.sourceModifiedOn, "MM/dd/yyyy hh:mma", new Date()); - - return sourceDate > mappedDate; + this.saveMapping(); + } + + updateMapping( + sourceGallery: mgmtApi.assetMediaGrouping, + targetGallery: mgmtApi.assetMediaGrouping, + mapping: GalleryMapping + ) { + if (targetGallery.mediaGroupingID !== mapping.targetMediaGroupingID) { + throw new Error( + `Invalid items trying to be mapped! Source mediaGroupingID: ${sourceGallery.mediaGroupingID}, Target mediaGroupingID: ${targetGallery.mediaGroupingID}` + ); } - - hasTargetChanged(targetGallery: mgmtApi.assetMediaGrouping) { - if (!targetGallery) return false; - const mapping = this.getGalleryMapping(targetGallery, 'target'); - if (!mapping) return false; - - const targetDate = parse(targetGallery.modifiedOn, "MM/dd/yyyy hh:mma", new Date()); - const mappedDate = parse(mapping.targetModifiedOn, "MM/dd/yyyy hh:mma", new Date()); - return targetDate > mappedDate; - } - - - + mapping.sourceGuid = this.sourceGuid; + mapping.targetGuid = this.targetGuid; + mapping.sourceMediaGroupingID = sourceGallery.mediaGroupingID; + mapping.targetMediaGroupingID = targetGallery.mediaGroupingID; + mapping.sourceModifiedOn = sourceGallery.modifiedOn; + mapping.targetModifiedOn = targetGallery.modifiedOn; + this.saveMapping(); + } + + loadMapping() { + const mapping = this.fileOps.getMappingFile(this.directory, this.sourceGuid, this.targetGuid); + return mapping; + } + + saveMapping() { + this.fileOps.saveMappingFile(this.mappings, this.directory, this.sourceGuid, this.targetGuid); + } + + hasSourceChanged(sourceGallery: mgmtApi.assetMediaGrouping) { + const mapping = this.getGalleryMapping(sourceGallery, "source"); + if (!mapping) return false; + + //the date format is: 07/23/2025 08:22PM (MM/DD/YYYY hh:mma) so we need to convert it to a Date object + // Note: This assumes the date is in the format MM/DD/YYYY hh:mma + // If the date format is different, you may need to adjust the parsing logic accordingly + const sourceDate = parse(sourceGallery.modifiedOn, "MM/dd/yyyy hh:mma", new Date()); + const mappedDate = parse(mapping.sourceModifiedOn, "MM/dd/yyyy hh:mma", new Date()); + + return sourceDate > mappedDate; + } + + hasTargetChanged(targetGallery: mgmtApi.assetMediaGrouping) { + if (!targetGallery) return false; + const mapping = this.getGalleryMapping(targetGallery, "target"); + if (!mapping) return false; + + const targetDate = parse(targetGallery.modifiedOn, "MM/dd/yyyy hh:mma", new Date()); + const mappedDate = parse(mapping.targetModifiedOn, "MM/dd/yyyy hh:mma", new Date()); + return targetDate > mappedDate; + } } diff --git a/src/lib/mappers/mapping-reader.ts b/src/lib/mappers/mapping-reader.ts index fc92f36..fbd3772 100644 --- a/src/lib/mappers/mapping-reader.ts +++ b/src/lib/mappers/mapping-reader.ts @@ -1,13 +1,13 @@ /** * Mapping Reader Utility - * + * * Reads content and page mappings from the file system to extract target IDs * for workflow operations. Uses fileOperations for consistent filesystem access. */ -import { fileOperations } from '../../core'; -import { state } from '../../core/state'; -import { ContentMapping, PageMapping, MappingReadResult } from '../../types'; +import { fileOperations } from "../../core"; +import { state } from "../../core/state"; +import { ContentMapping, PageMapping, MappingReadResult } from "../../types"; // Re-export types for convenience export { ContentMapping, PageMapping, MappingReadResult }; @@ -16,43 +16,39 @@ export { ContentMapping, PageMapping, MappingReadResult }; * Read all mappings for a source/target GUID pair across all locales * Uses fileOperations for consistent filesystem access */ -export function readMappingsForGuidPair( - sourceGuid: string, - targetGuid: string, - locales: string[] -): MappingReadResult { - const result: MappingReadResult = { - contentIds: [], - pageIds: [], - contentMappings: [], - pageMappings: [], - errors: [] - }; - - for (const locale of locales) { - // Use fileOperations for consistent access - const fileOps = new fileOperations(targetGuid, locale); - - // Read content mappings using fileOperations getMappingFile - const contentMappings = fileOps.getMappingFile('item', sourceGuid, targetGuid, locale); - if (contentMappings && contentMappings.length > 0) { - result.contentMappings.push(...contentMappings as ContentMapping[]); - result.contentIds.push(...contentMappings.map((m: ContentMapping) => m.targetContentID)); - } +export function readMappingsForGuidPair(sourceGuid: string, targetGuid: string, locales: string[]): MappingReadResult { + const result: MappingReadResult = { + contentIds: [], + pageIds: [], + contentMappings: [], + pageMappings: [], + errors: [], + }; - // Read page mappings using fileOperations getMappingFile - const pageMappings = fileOps.getMappingFile('page', sourceGuid, targetGuid, locale); - if (pageMappings && pageMappings.length > 0) { - result.pageMappings.push(...pageMappings as PageMapping[]); - result.pageIds.push(...pageMappings.map((m: PageMapping) => m.targetPageID)); - } + for (const locale of locales) { + // Use fileOperations for consistent access + const fileOps = new fileOperations(targetGuid, locale); + + // Read content mappings using fileOperations getMappingFile + const contentMappings = fileOps.getMappingFile("item", sourceGuid, targetGuid, locale); + if (contentMappings && contentMappings.length > 0) { + result.contentMappings.push(...(contentMappings as ContentMapping[])); + result.contentIds.push(...contentMappings.map((m: ContentMapping) => m.targetContentID)); + } + + // Read page mappings using fileOperations getMappingFile + const pageMappings = fileOps.getMappingFile("page", sourceGuid, targetGuid, locale); + if (pageMappings && pageMappings.length > 0) { + result.pageMappings.push(...(pageMappings as PageMapping[])); + result.pageIds.push(...pageMappings.map((m: PageMapping) => m.targetPageID)); } + } - // Deduplicate IDs (same content/page might appear in multiple locales) - result.contentIds = Array.from(new Set(result.contentIds)); - result.pageIds = Array.from(new Set(result.pageIds)); + // Deduplicate IDs (same content/page might appear in multiple locales) + result.contentIds = Array.from(new Set(result.contentIds)); + result.pageIds = Array.from(new Set(result.pageIds)); - return result; + return result; } /** @@ -60,84 +56,84 @@ export function readMappingsForGuidPair( * Uses fileOperations for consistent filesystem access */ export function listAvailableMappingPairs(): Array<{ sourceGuid: string; targetGuid: string; locales: string[] }> { - const fileOps = new fileOperations('', ''); - const mappingsDir = fileOps.getMappingFilePath('', ''); - - // Get the root mappings folder from state - const rootMappingsPath = `${state.rootPath}/mappings`; - - if (!fileOps.fileExists(rootMappingsPath)) { - return []; - } + const fileOps = new fileOperations("", ""); + const mappingsDir = fileOps.getMappingFilePath("", ""); + + // Get the root mappings folder from state + const rootMappingsPath = `${state.rootPath}/mappings`; + + if (!fileOps.fileExists(rootMappingsPath)) { + return []; + } + + const pairs: Array<{ sourceGuid: string; targetGuid: string; locales: string[] }> = []; - const pairs: Array<{ sourceGuid: string; targetGuid: string; locales: string[] }> = []; - - try { - const dirs = fileOps.getFolderContents(rootMappingsPath); - - for (const dir of dirs) { - // Directory format: {sourceGuid}-{targetGuid} - const fullPath = `${rootMappingsPath}/${dir}`; - - // Find locales in this directory - const locales: string[] = []; - try { - const contents = fileOps.getFolderContents(fullPath); - - for (const item of contents) { - // Check if it looks like a locale (e.g., en-us, es-us) - if (/^[a-z]{2}-[a-z]{2}$/i.test(item)) { - locales.push(item); - } - } - } catch (e) { - // Skip directories we can't read - continue; - } - - if (locales.length > 0) { - // Parse the directory name to extract source and target GUIDs - // Format: {sourceGuid}-{targetGuid} where GUIDs are like "c39c63bd-us2" - const guidPattern = /^([a-zA-Z0-9]+-[a-zA-Z0-9]+)-([a-zA-Z0-9]+-[a-zA-Z0-9]+)$/; - const match = dir.match(guidPattern); - - if (match) { - pairs.push({ - sourceGuid: match[1], - targetGuid: match[2], - locales - }); - } - } + try { + const dirs = fileOps.getFolderContents(rootMappingsPath); + + for (const dir of dirs) { + // Directory format: {sourceGuid}-{targetGuid} + const fullPath = `${rootMappingsPath}/${dir}`; + + // Find locales in this directory + const locales: string[] = []; + try { + const contents = fileOps.getFolderContents(fullPath); + + for (const item of contents) { + // Check if it looks like a locale (e.g., en-us, es-us) + if (/^[a-z]{2}-[a-z]{2}$/i.test(item)) { + locales.push(item); + } + } + } catch (e) { + // Skip directories we can't read + continue; + } + + if (locales.length > 0) { + // Parse the directory name to extract source and target GUIDs + // Format: {sourceGuid}-{targetGuid} where GUIDs are like "c39c63bd-us2" + const guidPattern = /^([a-zA-Z0-9]+-[a-zA-Z0-9]+)-([a-zA-Z0-9]+-[a-zA-Z0-9]+)$/; + const match = dir.match(guidPattern); + + if (match) { + pairs.push({ + sourceGuid: match[1], + targetGuid: match[2], + locales, + }); } - } catch (error: any) { - console.error(`Error listing mapping directories: ${error.message}`); + } } + } catch (error: any) { + console.error(`Error listing mapping directories: ${error.message}`); + } - return pairs; + return pairs; } /** * Get mapping summary for display */ export function getMappingSummary( - sourceGuid: string, - targetGuid: string, - locales: string[] + sourceGuid: string, + targetGuid: string, + locales: string[] ): { totalContent: number; totalPages: number; localesFound: string[] } { - const result = readMappingsForGuidPair(sourceGuid, targetGuid, locales); - const fileOps = new fileOperations('', ''); - - const localesFound = locales.filter(locale => { - const ops = new fileOperations(targetGuid, locale); - const contentMappings = ops.getMappingFile('item', sourceGuid, targetGuid, locale); - const pageMappings = ops.getMappingFile('page', sourceGuid, targetGuid, locale); - return (contentMappings && contentMappings.length > 0) || (pageMappings && pageMappings.length > 0); - }); - - return { - totalContent: result.contentIds.length, - totalPages: result.pageIds.length, - localesFound - }; + const result = readMappingsForGuidPair(sourceGuid, targetGuid, locales); + const fileOps = new fileOperations("", ""); + + const localesFound = locales.filter((locale) => { + const ops = new fileOperations(targetGuid, locale); + const contentMappings = ops.getMappingFile("item", sourceGuid, targetGuid, locale); + const pageMappings = ops.getMappingFile("page", sourceGuid, targetGuid, locale); + return (contentMappings && contentMappings.length > 0) || (pageMappings && pageMappings.length > 0); + }); + + return { + totalContent: result.contentIds.length, + totalPages: result.pageIds.length, + localesFound, + }; } diff --git a/src/lib/mappers/mapping-version-updater.ts b/src/lib/mappers/mapping-version-updater.ts index 3f187be..886ce32 100644 --- a/src/lib/mappers/mapping-version-updater.ts +++ b/src/lib/mappers/mapping-version-updater.ts @@ -1,18 +1,18 @@ /** * Mapping Version Updater - * + * * After publishing, updates the mappings with the new versionIDs * by reading the refreshed data from the filesystem using fileOperations. */ -import { fileOperations } from '../../core'; -import { getLogger } from '../../core/state'; -import { ContentItemMapper } from './content-item-mapper'; -import { PageMapper } from './page-mapper'; -import { getContentItemsFromFileSystem } from '../getters/filesystem/get-content-items'; -import { getPagesFromFileSystem } from '../getters/filesystem/get-pages'; -import ansiColors from 'ansi-colors'; -import { MappingUpdateResult } from '../../types'; +import { fileOperations } from "../../core"; +import { getLogger } from "../../core/state"; +import { ContentItemMapper } from "./content-item-mapper"; +import { PageMapper } from "./page-mapper"; +import { getContentItemsFromFileSystem } from "../getters/filesystem/get-content-items"; +import { getPagesFromFileSystem } from "../getters/filesystem/get-pages"; +import ansiColors from "ansi-colors"; +import { MappingUpdateResult } from "../../types"; // Re-export type for convenience export { MappingUpdateResult }; @@ -21,26 +21,26 @@ export { MappingUpdateResult }; * Version change detail for logging */ export interface VersionChangeDetail { - id: number; - oldVersion: number; - newVersion: number; - changed: boolean; - name?: string; // Content title/name or page title - refName?: string; // Content referenceName or page path - modelName?: string; // Content model (definitionName) + id: number; + oldVersion: number; + newVersion: number; + changed: boolean; + name?: string; // Content title/name or page title + refName?: string; // Content referenceName or page path + modelName?: string; // Content model (definitionName) } /** * Helper to log to both logger and capture lines */ function logLine(line: string, logLines: string[]): void { - const logger = getLogger(); - if (logger) { - logger.info(line); - } else { - console.log(line); - } - logLines.push(line); + const logger = getLogger(); + if (logger) { + logger.info(line); + } else { + console.log(line); + } + logLines.push(line); } /** @@ -48,73 +48,68 @@ function logLine(line: string, logLines: string[]): void { * Only updates targetVersionID - sourceVersionID should only change during sync operations */ export async function updateContentMappingsAfterPublish( - publishedContentIds: number[], - sourceGuid: string, - targetGuid: string, - locale: string + publishedContentIds: number[], + sourceGuid: string, + targetGuid: string, + locale: string ): Promise<{ updated: number; errors: string[]; changes: VersionChangeDetail[] }> { - const errors: string[] = []; - const changes: VersionChangeDetail[] = []; - let updated = 0; - - // Deduplicate IDs - API may return duplicates for nested content - const uniqueContentIds = Array.from(new Set(publishedContentIds)); - - if (uniqueContentIds.length === 0) { - return { updated: 0, errors: [], changes: [] }; + const errors: string[] = []; + const changes: VersionChangeDetail[] = []; + let updated = 0; + + // Deduplicate IDs - API may return duplicates for nested content + const uniqueContentIds = Array.from(new Set(publishedContentIds)); + + if (uniqueContentIds.length === 0) { + return { updated: 0, errors: [], changes: [] }; + } + + try { + // Create file operations for target (we only need target data for versionID) + const targetFileOps = new fileOperations(targetGuid, locale); + + // Load content items from target filesystem (refreshed after pull) + const targetContentItems = getContentItemsFromFileSystem(targetFileOps); + + // Create content item mapper + const contentMapper = new ContentItemMapper(sourceGuid, targetGuid, locale); + + // Create lookup map for quick access + const targetContentMap = new Map(targetContentItems.map((item) => [item.contentID, item])); + + // Update targetVersionID for each published content item + for (const targetContentId of uniqueContentIds) { + const targetItem = targetContentMap.get(targetContentId); + if (!targetItem) { + errors.push(`Target content item ${targetContentId} not found in filesystem`); + continue; + } + + // Update only the target versionID in the mapping + const result = contentMapper.updateTargetVersionID(targetContentId, targetItem.properties.versionID); + + if (result.success) { + updated++; + // Track all version updates with display info + changes.push({ + id: targetContentId, + oldVersion: result.oldVersionID!, + newVersion: result.newVersionID!, + changed: result.oldVersionID !== result.newVersionID, + name: targetItem.fields?.title || targetItem.fields?.name || `Item ${targetContentId}`, + refName: targetItem.properties?.referenceName, + modelName: targetItem.properties?.definitionName, + }); + } else { + errors.push(`No mapping found for target content ID ${targetContentId}`); + } } - try { - // Create file operations for target (we only need target data for versionID) - const targetFileOps = new fileOperations(targetGuid, locale); - - // Load content items from target filesystem (refreshed after pull) - const targetContentItems = getContentItemsFromFileSystem(targetFileOps); - - // Create content item mapper - const contentMapper = new ContentItemMapper(sourceGuid, targetGuid, locale); - - // Create lookup map for quick access - const targetContentMap = new Map( - targetContentItems.map(item => [item.contentID, item]) - ); - - // Update targetVersionID for each published content item - for (const targetContentId of uniqueContentIds) { - const targetItem = targetContentMap.get(targetContentId); - if (!targetItem) { - errors.push(`Target content item ${targetContentId} not found in filesystem`); - continue; - } - - // Update only the target versionID in the mapping - const result = contentMapper.updateTargetVersionID( - targetContentId, - targetItem.properties.versionID - ); - - if (result.success) { - updated++; - // Track all version updates with display info - changes.push({ - id: targetContentId, - oldVersion: result.oldVersionID!, - newVersion: result.newVersionID!, - changed: result.oldVersionID !== result.newVersionID, - name: targetItem.fields?.title || targetItem.fields?.name || `Item ${targetContentId}`, - refName: targetItem.properties?.referenceName, - modelName: targetItem.properties?.definitionName - }); - } else { - errors.push(`No mapping found for target content ID ${targetContentId}`); - } - } - - return { updated, errors, changes }; - } catch (error: any) { - errors.push(`Content mapping update failed: ${error.message}`); - return { updated, errors, changes: [] }; - } + return { updated, errors, changes }; + } catch (error: any) { + errors.push(`Content mapping update failed: ${error.message}`); + return { updated, errors, changes: [] }; + } } /** @@ -122,72 +117,67 @@ export async function updateContentMappingsAfterPublish( * Only updates targetVersionID - sourceVersionID should only change during sync operations */ export async function updatePageMappingsAfterPublish( - publishedPageIds: number[], - sourceGuid: string, - targetGuid: string, - locale: string + publishedPageIds: number[], + sourceGuid: string, + targetGuid: string, + locale: string ): Promise<{ updated: number; errors: string[]; changes: VersionChangeDetail[] }> { - const errors: string[] = []; - const changes: VersionChangeDetail[] = []; - let updated = 0; - - // Deduplicate IDs - API may return duplicates - const uniquePageIds = Array.from(new Set(publishedPageIds)); - - if (uniquePageIds.length === 0) { - return { updated: 0, errors: [], changes: [] }; + const errors: string[] = []; + const changes: VersionChangeDetail[] = []; + let updated = 0; + + // Deduplicate IDs - API may return duplicates + const uniquePageIds = Array.from(new Set(publishedPageIds)); + + if (uniquePageIds.length === 0) { + return { updated: 0, errors: [], changes: [] }; + } + + try { + // Create file operations for target (we only need target data for versionID) + const targetFileOps = new fileOperations(targetGuid, locale); + + // Load pages from target filesystem (refreshed after pull) + const targetPages = getPagesFromFileSystem(targetFileOps); + + // Create page mapper + const pageMapper = new PageMapper(sourceGuid, targetGuid, locale); + + // Create lookup map for quick access + const targetPageMap = new Map(targetPages.map((page) => [page.pageID, page])); + + // Update targetVersionID for each published page + for (const targetPageId of uniquePageIds) { + const targetPage = targetPageMap.get(targetPageId); + if (!targetPage) { + errors.push(`Target page ${targetPageId} not found in filesystem`); + continue; + } + + // Update only the target versionID in the mapping + const result = pageMapper.updateTargetVersionID(targetPageId, targetPage.properties.versionID); + + if (result.success) { + updated++; + // Track all version updates with display info + changes.push({ + id: targetPageId, + oldVersion: result.oldVersionID!, + newVersion: result.newVersionID!, + changed: result.oldVersionID !== result.newVersionID, + name: targetPage.title || targetPage.name || `Page ${targetPageId}`, + refName: targetPage.name ? `/${targetPage.name}` : undefined, + }); + } else { + errors.push(`No mapping found for target page ID ${targetPageId}`); + } } - try { - // Create file operations for target (we only need target data for versionID) - const targetFileOps = new fileOperations(targetGuid, locale); - - // Load pages from target filesystem (refreshed after pull) - const targetPages = getPagesFromFileSystem(targetFileOps); - - // Create page mapper - const pageMapper = new PageMapper(sourceGuid, targetGuid, locale); - - // Create lookup map for quick access - const targetPageMap = new Map( - targetPages.map(page => [page.pageID, page]) - ); - - // Update targetVersionID for each published page - for (const targetPageId of uniquePageIds) { - const targetPage = targetPageMap.get(targetPageId); - if (!targetPage) { - errors.push(`Target page ${targetPageId} not found in filesystem`); - continue; - } - - // Update only the target versionID in the mapping - const result = pageMapper.updateTargetVersionID( - targetPageId, - targetPage.properties.versionID - ); - - if (result.success) { - updated++; - // Track all version updates with display info - changes.push({ - id: targetPageId, - oldVersion: result.oldVersionID!, - newVersion: result.newVersionID!, - changed: result.oldVersionID !== result.newVersionID, - name: targetPage.title || targetPage.name || `Page ${targetPageId}`, - refName: targetPage.name ? `/${targetPage.name}` : undefined - }); - } else { - errors.push(`No mapping found for target page ID ${targetPageId}`); - } - } - - return { updated, errors, changes }; - } catch (error: any) { - errors.push(`Page mapping update failed: ${error.message}`); - return { updated, errors, changes: [] }; - } + return { updated, errors, changes }; + } catch (error: any) { + errors.push(`Page mapping update failed: ${error.message}`); + return { updated, errors, changes: [] }; + } } /** @@ -195,35 +185,35 @@ export async function updatePageMappingsAfterPublish( * Format: ● [guid][locale] content ID: {id} - Name (Type) v1565 → v1593 mapping updated */ function formatVersionChange( - change: VersionChangeDetail, - entityType: string, - targetGuid: string, - locale: string + change: VersionChangeDetail, + entityType: string, + targetGuid: string, + locale: string ): string { - const symbol = change.changed ? ansiColors.green('●') : ansiColors.yellow('○'); - const guidDisplay = change.changed ? ansiColors.green(`[${targetGuid}]`) : ansiColors.yellow(`[${targetGuid}]`); - const localeDisplay = ansiColors.gray(`[${locale}]`); - const entityDisplay = ansiColors.white(entityType); - const idDisplay = ansiColors.cyan.underline(String(change.id)); - const nameDisplay = ansiColors.white(change.name || ''); - - // Build the type display (model name for content, path for pages) - let typeDisplay = ''; - if (change.modelName) { - typeDisplay = ansiColors.gray(` (${change.modelName})`); - } else if (change.refName) { - typeDisplay = ansiColors.gray(` (${change.refName})`); - } - - if (change.changed) { - const versionDisplay = ansiColors.gray(`v${change.oldVersion} → v${change.newVersion}`); - const action = ansiColors.green('mapping updated'); - // Format: ● [guid][locale] content ID: {id} - Name (Type) v1565 → v1593 mapping updated - return `${symbol} ${guidDisplay}${localeDisplay} ${entityDisplay} ID: ${idDisplay} - ${nameDisplay}${typeDisplay} ${versionDisplay} ${action}`; - } else { - const versionDisplay = ansiColors.gray(`v${change.newVersion}`); - return `${symbol} ${guidDisplay}${localeDisplay} ${entityDisplay} ID: ${idDisplay} - ${nameDisplay}${typeDisplay} ${versionDisplay} ${ansiColors.gray('unchanged')}`; - } + const symbol = change.changed ? ansiColors.green("●") : ansiColors.yellow("○"); + const guidDisplay = change.changed ? ansiColors.green(`[${targetGuid}]`) : ansiColors.yellow(`[${targetGuid}]`); + const localeDisplay = ansiColors.gray(`[${locale}]`); + const entityDisplay = ansiColors.white(entityType); + const idDisplay = ansiColors.cyan.underline(String(change.id)); + const nameDisplay = ansiColors.white(change.name || ""); + + // Build the type display (model name for content, path for pages) + let typeDisplay = ""; + if (change.modelName) { + typeDisplay = ansiColors.gray(` (${change.modelName})`); + } else if (change.refName) { + typeDisplay = ansiColors.gray(` (${change.refName})`); + } + + if (change.changed) { + const versionDisplay = ansiColors.gray(`v${change.oldVersion} → v${change.newVersion}`); + const action = ansiColors.green("mapping updated"); + // Format: ● [guid][locale] content ID: {id} - Name (Type) v1565 → v1593 mapping updated + return `${symbol} ${guidDisplay}${localeDisplay} ${entityDisplay} ID: ${idDisplay} - ${nameDisplay}${typeDisplay} ${versionDisplay} ${action}`; + } else { + const versionDisplay = ansiColors.gray(`v${change.newVersion}`); + return `${symbol} ${guidDisplay}${localeDisplay} ${entityDisplay} ID: ${idDisplay} - ${nameDisplay}${typeDisplay} ${versionDisplay} ${ansiColors.gray("unchanged")}`; + } } /** @@ -231,21 +221,21 @@ function formatVersionChange( * Returns formatted lines for logging */ function displayVersionChanges( - label: string, - entityType: string, - changes: VersionChangeDetail[], - totalUpdated: number, - targetGuid: string, - locale: string, - logLines: string[] + label: string, + entityType: string, + changes: VersionChangeDetail[], + totalUpdated: number, + targetGuid: string, + locale: string, + logLines: string[] ): void { - if (changes.length === 0) return; - - // Show all items using the logger - changes.forEach(change => { - const line = formatVersionChange(change, entityType, targetGuid, locale); - logLine(line, logLines); - }); + if (changes.length === 0) return; + + // Show all items using the logger + changes.forEach((change) => { + const line = formatVersionChange(change, entityType, targetGuid, locale); + logLine(line, logLines); + }); } /** @@ -253,57 +243,60 @@ function displayVersionChanges( * Returns result and log lines for the logger */ export async function updateMappingsAfterPublish( - publishedContentIds: number[], - publishedPageIds: number[], - sourceGuid: string, - targetGuid: string, - locale: string + publishedContentIds: number[], + publishedPageIds: number[], + sourceGuid: string, + targetGuid: string, + locale: string ): Promise<{ result: MappingUpdateResult; logLines: string[] }> { - const logLines: string[] = []; - - logLine(ansiColors.cyan('\nUpdating mappings with new version IDs...'), logLines); - - const result: MappingUpdateResult = { - contentMappingsUpdated: 0, - pageMappingsUpdated: 0, - errors: [] - }; - - // Update content mappings - if (publishedContentIds.length > 0) { - const contentResult = await updateContentMappingsAfterPublish( - publishedContentIds, - sourceGuid, - targetGuid, - locale - ); - result.contentMappingsUpdated = contentResult.updated; - result.errors.push(...contentResult.errors); - - displayVersionChanges('content item', 'content', contentResult.changes, contentResult.updated, targetGuid, locale, logLines); - } - - // Update page mappings - if (publishedPageIds.length > 0) { - const pageResult = await updatePageMappingsAfterPublish( - publishedPageIds, - sourceGuid, - targetGuid, - locale - ); - result.pageMappingsUpdated = pageResult.updated; - result.errors.push(...pageResult.errors); - - displayVersionChanges('page', 'page', pageResult.changes, pageResult.updated, targetGuid, locale, logLines); - } - - // Summary line - logLine(ansiColors.green(`✓ Mappings updated: ${result.contentMappingsUpdated} content, ${result.pageMappingsUpdated} pages`), logLines); - - // Report any errors - if (result.errors.length > 0) { - logLine(ansiColors.yellow(` ⚠️ ${result.errors.length} mapping update errors (see logs)`), logLines); - } - - return { result, logLines }; + const logLines: string[] = []; + + logLine(ansiColors.cyan("\nUpdating mappings with new version IDs..."), logLines); + + const result: MappingUpdateResult = { + contentMappingsUpdated: 0, + pageMappingsUpdated: 0, + errors: [], + }; + + // Update content mappings + if (publishedContentIds.length > 0) { + const contentResult = await updateContentMappingsAfterPublish(publishedContentIds, sourceGuid, targetGuid, locale); + result.contentMappingsUpdated = contentResult.updated; + result.errors.push(...contentResult.errors); + + displayVersionChanges( + "content item", + "content", + contentResult.changes, + contentResult.updated, + targetGuid, + locale, + logLines + ); + } + + // Update page mappings + if (publishedPageIds.length > 0) { + const pageResult = await updatePageMappingsAfterPublish(publishedPageIds, sourceGuid, targetGuid, locale); + result.pageMappingsUpdated = pageResult.updated; + result.errors.push(...pageResult.errors); + + displayVersionChanges("page", "page", pageResult.changes, pageResult.updated, targetGuid, locale, logLines); + } + + // Summary line + logLine( + ansiColors.green( + `✓ Mappings updated: ${result.contentMappingsUpdated} content, ${result.pageMappingsUpdated} pages` + ), + logLines + ); + + // Report any errors + if (result.errors.length > 0) { + logLine(ansiColors.yellow(` ⚠️ ${result.errors.length} mapping update errors (see logs)`), logLines); + } + + return { result, logLines }; } diff --git a/src/lib/mappers/model-mapper.ts b/src/lib/mappers/model-mapper.ts index 4462ab9..9fff61f 100644 --- a/src/lib/mappers/model-mapper.ts +++ b/src/lib/mappers/model-mapper.ts @@ -2,148 +2,146 @@ import { fileOperations } from "../../core"; import * as mgmtApi from "@agility/management-sdk"; interface ModelMapping { - sourceGuid: string; - targetGuid: string; - sourceID: number; - targetID: number; - sourceReferenceName?: string; - targetReferenceName?: string; - sourceLastModifiedDate: string; - targetLastModifiedDate: string; + sourceGuid: string; + targetGuid: string; + sourceID: number; + targetID: number; + sourceReferenceName?: string; + targetReferenceName?: string; + sourceLastModifiedDate: string; + targetLastModifiedDate: string; } - export class ModelMapper { - private fileOps: fileOperations; - private sourceGuid: string; - private targetGuid: string; - private mappings: ModelMapping[]; - private directory: string; - - constructor(sourceGuid: string, targetGuid: string) { - this.sourceGuid = sourceGuid; - this.targetGuid = targetGuid; - this.directory = 'models'; - // this will provide access to the /agility-files/{GUID} folder - this.fileOps = new fileOperations(targetGuid) - this.mappings = this.loadMapping(); - - } - - getModelMapping(model: mgmtApi.Model, type: 'source' | 'target'): ModelMapping | null { - const mapping = this.mappings.find((m: ModelMapping) => - type === 'source' ? m.sourceID === model.id : m.targetID === model.id - ); - if (!mapping) return null; - return mapping; + private fileOps: fileOperations; + private sourceGuid: string; + private targetGuid: string; + private mappings: ModelMapping[]; + private directory: string; + + constructor(sourceGuid: string, targetGuid: string) { + this.sourceGuid = sourceGuid; + this.targetGuid = targetGuid; + this.directory = "models"; + // this will provide access to the /agility-files/{GUID} folder + this.fileOps = new fileOperations(targetGuid); + this.mappings = this.loadMapping(); + } + + getModelMapping(model: mgmtApi.Model, type: "source" | "target"): ModelMapping | null { + const mapping = this.mappings.find((m: ModelMapping) => + type === "source" ? m.sourceID === model.id : m.targetID === model.id + ); + if (!mapping) return null; + return mapping; + } + + getModelMappingByID(id: number, type: "source" | "target"): ModelMapping | null { + const mapping = this.mappings.find((m: ModelMapping) => + type === "source" ? m.sourceID === id : m.targetID === id + ); + if (!mapping) return null; + return mapping; + } + + getModelMappingByReferenceName(referenceName: string, type: "source" | "target"): ModelMapping | null { + //do a case-insensitive search for the referenceName + const refNameLower = referenceName.toLowerCase(); + + const mapping = this.mappings.find((m: ModelMapping) => + type === "source" + ? m.sourceReferenceName.toLowerCase() === refNameLower + : m.targetReferenceName.toLowerCase() === refNameLower + ); + if (!mapping) return null; + return mapping; + } + + getMappedEntity(mapping: ModelMapping, type: "source" | "target"): mgmtApi.Model | null { + if (!mapping) return null; + //fetch the model from the file system based on source or target GUID + const guid = type === "source" ? mapping.sourceGuid : mapping.targetGuid; + const modelID = type === "source" ? mapping.sourceID : mapping.targetID; + + const fileOps = new fileOperations(guid); + const modelData = fileOps.readJsonFile(`models/${modelID}.json`); + if (!modelData) return null; + return modelData as mgmtApi.Model; + } + + addMapping(sourceModel: mgmtApi.Model, targetModel: mgmtApi.Model) { + const targetMapping = this.getModelMapping(targetModel, "target"); + const sourceMapping = this.getModelMapping(sourceModel, "source"); + + if (targetMapping && sourceMapping && targetMapping !== sourceMapping) { + throw new Error( + `Invalid Mappings detected! Source model ID: ${sourceModel.id}, Target model ID: ${targetModel.id}` + ); } - getModelMappingByID(id: number, type: 'source' | 'target'): ModelMapping | null { - const mapping = this.mappings.find((m: ModelMapping) => - type === 'source' ? m.sourceID === id : m.targetID === id - ); - if (!mapping) return null; - return mapping; + if (targetMapping) { + this.updateMapping(sourceModel, targetModel, targetMapping); + } else { + const newMapping: ModelMapping = { + sourceGuid: this.sourceGuid, + targetGuid: this.targetGuid, + sourceID: sourceModel.id, + targetID: targetModel.id, + sourceReferenceName: sourceModel.referenceName, + targetReferenceName: targetModel.referenceName, + sourceLastModifiedDate: sourceModel.lastModifiedDate, + targetLastModifiedDate: targetModel.lastModifiedDate, + }; + + this.mappings.push(newMapping); } - getModelMappingByReferenceName(referenceName: string, type: 'source' | 'target'): ModelMapping | null { - //do a case-insensitive search for the referenceName - const refNameLower = referenceName.toLowerCase(); - - const mapping = this.mappings.find((m: ModelMapping) => - type === 'source' - ? m.sourceReferenceName.toLowerCase() === refNameLower - : m.targetReferenceName.toLowerCase() === refNameLower - ); - if (!mapping) return null; - return mapping; - } - - getMappedEntity(mapping: ModelMapping, type: 'source' | 'target'): mgmtApi.Model | null { - if (!mapping) return null; - //fetch the model from the file system based on source or target GUID - const guid = type === 'source' ? mapping.sourceGuid : mapping.targetGuid; - const modelID = type === 'source' ? mapping.sourceID : mapping.targetID; + this.saveMapping(); + } - const fileOps = new fileOperations(guid); - const modelData = fileOps.readJsonFile(`models/${modelID}.json`); - if (!modelData) return null; - return modelData as mgmtApi.Model; + updateMapping(sourceModel: mgmtApi.Model, targetModel: mgmtApi.Model, mapping: ModelMapping) { + if (targetModel.id !== mapping.targetID) { + throw new Error( + `Invalid items trying to be mapped! Source model ID: ${sourceModel.id}, Target model ID: ${targetModel.id}` + ); } - - addMapping(sourceModel: mgmtApi.Model, targetModel: mgmtApi.Model) { - const targetMapping = this.getModelMapping(targetModel, 'target'); - const sourceMapping = this.getModelMapping(sourceModel, 'source'); - - if (targetMapping && sourceMapping && targetMapping !== sourceMapping) { - throw new Error(`Invalid Mappings detected! Source model ID: ${sourceModel.id}, Target model ID: ${targetModel.id}`); - } - - if (targetMapping) { - this.updateMapping(sourceModel, targetModel, targetMapping); - } else { - - const newMapping: ModelMapping = { - sourceGuid: this.sourceGuid, - targetGuid: this.targetGuid, - sourceID: sourceModel.id, - targetID: targetModel.id, - sourceReferenceName: sourceModel.referenceName, - targetReferenceName: targetModel.referenceName, - sourceLastModifiedDate: sourceModel.lastModifiedDate, - targetLastModifiedDate: targetModel.lastModifiedDate, - - } - - this.mappings.push(newMapping); - } - - this.saveMapping(); - } - - updateMapping(sourceModel: mgmtApi.Model, targetModel: mgmtApi.Model, mapping: ModelMapping) { - if (targetModel.id !== mapping.targetID) { - throw new Error(`Invalid items trying to be mapped! Source model ID: ${sourceModel.id}, Target model ID: ${targetModel.id}`); - } - mapping.sourceGuid = this.sourceGuid; - mapping.targetGuid = this.targetGuid; - mapping.sourceID = sourceModel.id; - mapping.targetID = targetModel.id; - mapping.sourceReferenceName = sourceModel.referenceName; - mapping.targetReferenceName = targetModel.referenceName; - mapping.sourceLastModifiedDate = sourceModel.lastModifiedDate; - mapping.targetLastModifiedDate = targetModel.lastModifiedDate; - this.saveMapping(); - } - - loadMapping() { - const mapping = this.fileOps.getMappingFile(this.directory, this.sourceGuid, this.targetGuid); - return mapping; - } - - saveMapping() { - this.fileOps.saveMappingFile(this.mappings, this.directory, this.sourceGuid, this.targetGuid); - } - - hasSourceChanged(sourceModel: mgmtApi.Model | null | undefined) { - if (!sourceModel) return false; - const mapping = this.getModelMapping(sourceModel, 'source'); - if (!mapping) return false; - - const sourceDate = new Date(sourceModel.lastModifiedDate); - const mappedDate = new Date(mapping.sourceLastModifiedDate); - - return sourceDate > mappedDate; - - } - - hasTargetChanged(targetModel: mgmtApi.Model | null | undefined) { - if (!targetModel) return false; - const mapping = this.getModelMapping(targetModel, 'target'); - if (!mapping) return false; - const targetDate = new Date(targetModel.lastModifiedDate); - const mappedDate = new Date(mapping.targetLastModifiedDate); - return targetDate > mappedDate; - } - -} \ No newline at end of file + mapping.sourceGuid = this.sourceGuid; + mapping.targetGuid = this.targetGuid; + mapping.sourceID = sourceModel.id; + mapping.targetID = targetModel.id; + mapping.sourceReferenceName = sourceModel.referenceName; + mapping.targetReferenceName = targetModel.referenceName; + mapping.sourceLastModifiedDate = sourceModel.lastModifiedDate; + mapping.targetLastModifiedDate = targetModel.lastModifiedDate; + this.saveMapping(); + } + + loadMapping() { + const mapping = this.fileOps.getMappingFile(this.directory, this.sourceGuid, this.targetGuid); + return mapping; + } + + saveMapping() { + this.fileOps.saveMappingFile(this.mappings, this.directory, this.sourceGuid, this.targetGuid); + } + + hasSourceChanged(sourceModel: mgmtApi.Model | null | undefined) { + if (!sourceModel) return false; + const mapping = this.getModelMapping(sourceModel, "source"); + if (!mapping) return false; + + const sourceDate = new Date(sourceModel.lastModifiedDate); + const mappedDate = new Date(mapping.sourceLastModifiedDate); + + return sourceDate > mappedDate; + } + + hasTargetChanged(targetModel: mgmtApi.Model | null | undefined) { + if (!targetModel) return false; + const mapping = this.getModelMapping(targetModel, "target"); + if (!mapping) return false; + const targetDate = new Date(targetModel.lastModifiedDate); + const mappedDate = new Date(mapping.targetLastModifiedDate); + return targetDate > mappedDate; + } +} diff --git a/src/lib/mappers/page-mapper.ts b/src/lib/mappers/page-mapper.ts index b3c2075..db0f39c 100644 --- a/src/lib/mappers/page-mapper.ts +++ b/src/lib/mappers/page-mapper.ts @@ -2,166 +2,172 @@ import { fileOperations } from "../../core"; import * as mgmtApi from "@agility/management-sdk"; export interface PageMapping { - sourceGuid: string; - targetGuid: string; - sourcePageID: number; - targetPageID: number; - sourceVersionID: number; - targetVersionID: number; - sourcePageTemplateName: string; - targetPageTemplateName: string; + sourceGuid: string; + targetGuid: string; + sourcePageID: number; + targetPageID: number; + sourceVersionID: number; + targetVersionID: number; + sourcePageTemplateName: string; + targetPageTemplateName: string; } - export class PageMapper { - private fileOps: fileOperations; - private sourceGuid: string; - private targetGuid: string; - private mappings: PageMapping[]; - private directory: string; - private locale: string; - - constructor(sourceGuid: string, targetGuid: string, locale: string) { - this.sourceGuid = sourceGuid; - this.targetGuid = targetGuid; - this.directory = 'page'; - this.locale = locale; - // this will provide access to the /agility-files/{GUID}/{locale} folder - this.fileOps = new fileOperations(targetGuid, locale) - this.mappings = this.loadMapping(); - - } - - getPageMapping(page: mgmtApi.PageItem, type: 'source' | 'target'): PageMapping | null { - const mapping = this.mappings.find((m: PageMapping) => - type === 'source' ? m.sourcePageID === page.pageID : m.targetPageID === page.pageID - ); - if (!mapping) return null; - return mapping; - } - - getPageMappingByPageID(pageID: number, type: 'source' | 'target'): PageMapping | null { - const mapping = this.mappings.find((m: PageMapping) => - type === 'source' ? m.sourcePageID === pageID : m.targetPageID === pageID - ); - if (!mapping) return null; - return mapping; + private fileOps: fileOperations; + private sourceGuid: string; + private targetGuid: string; + private mappings: PageMapping[]; + private directory: string; + private locale: string; + + constructor(sourceGuid: string, targetGuid: string, locale: string) { + this.sourceGuid = sourceGuid; + this.targetGuid = targetGuid; + this.directory = "page"; + this.locale = locale; + // this will provide access to the /agility-files/{GUID}/{locale} folder + this.fileOps = new fileOperations(targetGuid, locale); + this.mappings = this.loadMapping(); + } + + getPageMapping(page: mgmtApi.PageItem, type: "source" | "target"): PageMapping | null { + const mapping = this.mappings.find((m: PageMapping) => + type === "source" ? m.sourcePageID === page.pageID : m.targetPageID === page.pageID + ); + if (!mapping) return null; + return mapping; + } + + getPageMappingByPageID(pageID: number, type: "source" | "target"): PageMapping | null { + const mapping = this.mappings.find((m: PageMapping) => + type === "source" ? m.sourcePageID === pageID : m.targetPageID === pageID + ); + if (!mapping) return null; + return mapping; + } + + getPageMappingByPageTemplateName(pageTemplateName: string, type: "source" | "target"): PageMapping | null { + const mapping = this.mappings.find((m: PageMapping) => + type === "source" ? m.sourcePageTemplateName === pageTemplateName : m.targetPageTemplateName === pageTemplateName + ); + if (!mapping) return null; + return mapping; + } + + getMappedEntity(mapping: PageMapping, type: "source" | "target"): mgmtApi.PageItem | null { + if (!mapping) return null; + const guid = type === "source" ? mapping.sourceGuid : mapping.targetGuid; + const pageID = type === "source" ? mapping.sourcePageID : mapping.targetPageID; + const fileOps = new fileOperations(guid, this.locale); + const pageData = fileOps.readJsonFile(`page/${pageID}.json`); + if (!pageData) return null; + return pageData as mgmtApi.PageItem; + } + + addMapping(sourcePage: mgmtApi.PageItem, targetPage: mgmtApi.PageItem) { + const targetMapping = this.getPageMapping(targetPage, "target"); + const sourceMapping = this.getPageMapping(sourcePage, "source"); + + if (targetMapping && sourceMapping && targetMapping !== sourceMapping) { + throw new Error( + `Invalid Mappings detected! Source pageID: ${sourcePage.pageID}, Target pageID: ${targetPage.pageID}` + ); } - getPageMappingByPageTemplateName(pageTemplateName: string, type: 'source' | 'target'): PageMapping | null { - const mapping = this.mappings.find((m: PageMapping) => - type === 'source' ? m.sourcePageTemplateName === pageTemplateName : m.targetPageTemplateName === pageTemplateName - ); - if (!mapping) return null; - return mapping; + if (targetMapping) { + this.updateMapping(sourcePage, targetPage, targetMapping); + } else { + const newMapping: PageMapping = { + sourceGuid: this.sourceGuid, + targetGuid: this.targetGuid, + sourcePageID: sourcePage.pageID, + targetPageID: targetPage.pageID, + sourceVersionID: sourcePage.properties.versionID, + targetVersionID: targetPage.properties.versionID, + sourcePageTemplateName: sourcePage.templateName, + targetPageTemplateName: targetPage.templateName, + }; + + this.mappings.push(newMapping); } - getMappedEntity(mapping: PageMapping, type: 'source' | 'target'): mgmtApi.PageItem | null { - if (!mapping) return null; - const guid = type === 'source' ? mapping.sourceGuid : mapping.targetGuid; - const pageID = type === 'source' ? mapping.sourcePageID : mapping.targetPageID; - const fileOps = new fileOperations(guid, this.locale); - const pageData = fileOps.readJsonFile(`page/${pageID}.json`); - if (!pageData) return null; - return pageData as mgmtApi.PageItem; - } + this.saveMapping(); + } - addMapping(sourcePage: mgmtApi.PageItem, targetPage: mgmtApi.PageItem) { - const targetMapping = this.getPageMapping(targetPage, 'target'); - const sourceMapping = this.getPageMapping(sourcePage, 'source'); - - if (targetMapping && sourceMapping && targetMapping !== sourceMapping) { - throw new Error(`Invalid Mappings detected! Source pageID: ${sourcePage.pageID}, Target pageID: ${targetPage.pageID}`); - } - - if (targetMapping) { - this.updateMapping(sourcePage, targetPage, targetMapping); - } else { - - const newMapping: PageMapping = { - sourceGuid: this.sourceGuid, - targetGuid: this.targetGuid, - sourcePageID: sourcePage.pageID, - targetPageID: targetPage.pageID, - sourceVersionID: sourcePage.properties.versionID, - targetVersionID: targetPage.properties.versionID, - sourcePageTemplateName: sourcePage.templateName, - targetPageTemplateName: targetPage.templateName, - } - - this.mappings.push(newMapping); - } - - this.saveMapping(); + updateMapping(sourcePage: mgmtApi.PageItem, targetPage: mgmtApi.PageItem, mapping: PageMapping) { + if (targetPage.pageID !== mapping.targetPageID) { + throw new Error( + `Invalid items trying to be mapped! Source pageID: ${sourcePage.pageID}, Target pageID: ${targetPage.pageID}` + ); } - - updateMapping(sourcePage: mgmtApi.PageItem, targetPage: mgmtApi.PageItem, mapping: PageMapping) { - if (targetPage.pageID !== mapping.targetPageID) { - throw new Error(`Invalid items trying to be mapped! Source pageID: ${sourcePage.pageID}, Target pageID: ${targetPage.pageID}`); - } - mapping.sourceGuid = this.sourceGuid; - mapping.targetGuid = this.targetGuid; - mapping.sourcePageID = sourcePage.pageID; - mapping.targetPageID = targetPage.pageID; - mapping.sourceVersionID = sourcePage.properties.versionID; - mapping.targetVersionID = targetPage.properties.versionID; - mapping.sourcePageTemplateName = sourcePage.templateName; - mapping.targetPageTemplateName = targetPage.templateName; - this.saveMapping(); - } - - loadMapping() { - const mapping = this.fileOps.getMappingFile(this.directory, this.sourceGuid, this.targetGuid, this.locale); - return mapping; + mapping.sourceGuid = this.sourceGuid; + mapping.targetGuid = this.targetGuid; + mapping.sourcePageID = sourcePage.pageID; + mapping.targetPageID = targetPage.pageID; + mapping.sourceVersionID = sourcePage.properties.versionID; + mapping.targetVersionID = targetPage.properties.versionID; + mapping.sourcePageTemplateName = sourcePage.templateName; + mapping.targetPageTemplateName = targetPage.templateName; + this.saveMapping(); + } + + loadMapping() { + const mapping = this.fileOps.getMappingFile(this.directory, this.sourceGuid, this.targetGuid, this.locale); + return mapping; + } + + saveMapping() { + this.fileOps.saveMappingFile(this.mappings, this.directory, this.sourceGuid, this.targetGuid, this.locale); + } + + hasSourceChanged(sourcePage: mgmtApi.PageItem) { + if (!sourcePage) return false; + const mapping = this.getPageMapping(sourcePage, "source"); + if (!mapping) return true; + return sourcePage.properties.versionID > mapping.sourceVersionID; + } + + hasTargetChanged( + targetPage: mgmtApi.PageItem | null, + mapping: PageMapping | null + ): "version_changed" | "file_missing" | null { + if (!mapping) return null; + // Mapping exists but no downloaded file — page was previously synced and its file has + // since been removed (e.g. unpublished or deleted in the target instance). + if (!targetPage) return "file_missing"; + if (targetPage.properties.versionID > mapping.targetVersionID) return "version_changed"; + return null; + } + + /** + * Update only the target versionID in a mapping (used after publishing) + * Does NOT update sourceVersionID - that should only change during sync operations + * + * @returns Object with success status and old/new version IDs + */ + updateTargetVersionID( + targetPageID: number, + newVersionID: number + ): { + success: boolean; + oldVersionID?: number; + newVersionID?: number; + } { + const mapping = this.getPageMappingByPageID(targetPageID, "target"); + if (!mapping) return { success: false }; + + const oldVersionID = mapping.targetVersionID; + + // Only update if version actually changed + if (oldVersionID !== newVersionID) { + mapping.targetVersionID = newVersionID; + this.saveMapping(); } - saveMapping() { - this.fileOps.saveMappingFile(this.mappings, this.directory, this.sourceGuid, this.targetGuid, this.locale); - } - - hasSourceChanged(sourcePage: mgmtApi.PageItem) { - if (!sourcePage) return false; - const mapping = this.getPageMapping(sourcePage, 'source'); - if (!mapping) return true; - return sourcePage.properties.versionID > mapping.sourceVersionID; - } - - hasTargetChanged(targetPage: mgmtApi.PageItem | null, mapping: PageMapping | null): 'version_changed' | 'file_missing' | null { - if (!mapping) return null; - // Mapping exists but no downloaded file — page was previously synced and its file has - // since been removed (e.g. unpublished or deleted in the target instance). - if (!targetPage) return 'file_missing'; - if (targetPage.properties.versionID > mapping.targetVersionID) return 'version_changed'; - return null; - } - - /** - * Update only the target versionID in a mapping (used after publishing) - * Does NOT update sourceVersionID - that should only change during sync operations - * - * @returns Object with success status and old/new version IDs - */ - updateTargetVersionID(targetPageID: number, newVersionID: number): { - success: boolean; - oldVersionID?: number; - newVersionID?: number; - } { - const mapping = this.getPageMappingByPageID(targetPageID, 'target'); - if (!mapping) return { success: false }; - - const oldVersionID = mapping.targetVersionID; - - // Only update if version actually changed - if (oldVersionID !== newVersionID) { - mapping.targetVersionID = newVersionID; - this.saveMapping(); - } - - return { - success: true, - oldVersionID, - newVersionID - }; - } - -} \ No newline at end of file + return { + success: true, + oldVersionID, + newVersionID, + }; + } +} diff --git a/src/lib/mappers/template-mapper.ts b/src/lib/mappers/template-mapper.ts index 6bb8a91..5ac4b23 100644 --- a/src/lib/mappers/template-mapper.ts +++ b/src/lib/mappers/template-mapper.ts @@ -1,134 +1,133 @@ import { fileOperations } from "../../core"; import * as mgmtApi from "@agility/management-sdk"; interface TemplateMapping { - sourceGuid: string; - targetGuid: string; - sourcePageTemplateID: number; - targetPageTemplateID: number; - sourcePageTemplateName: string; - targetPageTemplateName: string; + sourceGuid: string; + targetGuid: string; + sourcePageTemplateID: number; + targetPageTemplateID: number; + sourcePageTemplateName: string; + targetPageTemplateName: string; } - export class TemplateMapper { - private fileOps: fileOperations; - private sourceGuid: string; - private targetGuid: string; - private mappings: TemplateMapping[]; - private directory: string; - - constructor(sourceGuid: string, targetGuid: string) { - this.sourceGuid = sourceGuid; - this.targetGuid = targetGuid; - this.directory = 'templates'; - // this will provide access to the /agility-files/{GUID} folder - this.fileOps = new fileOperations(targetGuid) - this.mappings = this.loadMapping(); - - } - - getTemplateMapping(template: mgmtApi.PageModel, type: 'source' | 'target'): TemplateMapping | null { - if (!template) return null; - const mapping = this.mappings.find((m: TemplateMapping) => - type === 'source' - ? m.sourcePageTemplateID === template.pageTemplateID - : m.targetPageTemplateID === template.pageTemplateID - ); - if (!mapping) return null; - return mapping; - } - - getTemplateMappingByPageTemplateID(pageTemplateID: number, type: 'source' | 'target'): TemplateMapping | null { - const mapping = this.mappings.find((m: TemplateMapping) => - type === 'source' ? m.sourcePageTemplateID === pageTemplateID : m.targetPageTemplateID === pageTemplateID - ); - if (!mapping) return null; - return mapping; - } - - getTemplateMappingByPageTemplateName(pageTemplateName: string, type: 'source' | 'target'): TemplateMapping | null { - const mapping = this.mappings.find((m: TemplateMapping) => - type === 'source' ? m.sourcePageTemplateName === pageTemplateName : m.targetPageTemplateName === pageTemplateName - ); - if (!mapping) return null; - return mapping; + private fileOps: fileOperations; + private sourceGuid: string; + private targetGuid: string; + private mappings: TemplateMapping[]; + private directory: string; + + constructor(sourceGuid: string, targetGuid: string) { + this.sourceGuid = sourceGuid; + this.targetGuid = targetGuid; + this.directory = "templates"; + // this will provide access to the /agility-files/{GUID} folder + this.fileOps = new fileOperations(targetGuid); + this.mappings = this.loadMapping(); + } + + getTemplateMapping(template: mgmtApi.PageModel, type: "source" | "target"): TemplateMapping | null { + if (!template) return null; + const mapping = this.mappings.find((m: TemplateMapping) => + type === "source" + ? m.sourcePageTemplateID === template.pageTemplateID + : m.targetPageTemplateID === template.pageTemplateID + ); + if (!mapping) return null; + return mapping; + } + + getTemplateMappingByPageTemplateID(pageTemplateID: number, type: "source" | "target"): TemplateMapping | null { + const mapping = this.mappings.find((m: TemplateMapping) => + type === "source" ? m.sourcePageTemplateID === pageTemplateID : m.targetPageTemplateID === pageTemplateID + ); + if (!mapping) return null; + return mapping; + } + + getTemplateMappingByPageTemplateName(pageTemplateName: string, type: "source" | "target"): TemplateMapping | null { + const mapping = this.mappings.find((m: TemplateMapping) => + type === "source" ? m.sourcePageTemplateName === pageTemplateName : m.targetPageTemplateName === pageTemplateName + ); + if (!mapping) return null; + return mapping; + } + + getMappedEntity(mapping: TemplateMapping, type: "source" | "target"): mgmtApi.PageModel | null { + if (!mapping) return null; + const guid = type === "source" ? mapping.sourceGuid : mapping.targetGuid; + const pageTemplateID = type === "source" ? mapping.sourcePageTemplateID : mapping.targetPageTemplateID; + const fileOps = new fileOperations(guid); + + const templateData = fileOps.readJsonFile(`templates/${pageTemplateID}.json`); + if (!templateData) return null; + return templateData as mgmtApi.PageModel; + } + + addMapping(sourceTemplate: mgmtApi.PageModel, targetTemplate: mgmtApi.PageModel) { + const targetMapping = this.getTemplateMapping(targetTemplate, "target"); + const sourceMapping = this.getTemplateMapping(sourceTemplate, "source"); + + if (targetMapping && sourceMapping && targetMapping !== sourceMapping) { + throw new Error( + `Invalid Mappings detected! Source pageTemplateID: ${sourceTemplate.pageTemplateID}, Target pageTemplateID: ${targetTemplate.pageTemplateID}` + ); } - getMappedEntity(mapping: TemplateMapping, type: 'source' | 'target'): mgmtApi.PageModel | null { - if (!mapping) return null; - const guid = type === 'source' ? mapping.sourceGuid : mapping.targetGuid; - const pageTemplateID = type === 'source' ? mapping.sourcePageTemplateID : mapping.targetPageTemplateID; - const fileOps = new fileOperations(guid); - - const templateData = fileOps.readJsonFile(`templates/${pageTemplateID}.json`); - if (!templateData) return null; - return templateData as mgmtApi.PageModel; + if (targetMapping) { + this.updateMapping(sourceTemplate, targetTemplate, targetMapping); + } else { + const newMapping: TemplateMapping = { + sourceGuid: this.sourceGuid, + targetGuid: this.targetGuid, + sourcePageTemplateID: sourceTemplate.pageTemplateID, + targetPageTemplateID: targetTemplate.pageTemplateID, + sourcePageTemplateName: sourceTemplate.pageTemplateName, + targetPageTemplateName: targetTemplate.pageTemplateName, + }; + + this.mappings.push(newMapping); } - addMapping(sourceTemplate: mgmtApi.PageModel, targetTemplate: mgmtApi.PageModel) { - const targetMapping = this.getTemplateMapping(targetTemplate, 'target'); - const sourceMapping = this.getTemplateMapping(sourceTemplate, 'source'); - - if (targetMapping && sourceMapping && targetMapping !== sourceMapping) { - throw new Error(`Invalid Mappings detected! Source pageTemplateID: ${sourceTemplate.pageTemplateID}, Target pageTemplateID: ${targetTemplate.pageTemplateID}`); - } - - if (targetMapping) { - this.updateMapping(sourceTemplate, targetTemplate, targetMapping); - } else { + this.saveMapping(); + } - const newMapping: TemplateMapping = { - sourceGuid: this.sourceGuid, - targetGuid: this.targetGuid, - sourcePageTemplateID: sourceTemplate.pageTemplateID, - targetPageTemplateID: targetTemplate.pageTemplateID, - sourcePageTemplateName: sourceTemplate.pageTemplateName, - targetPageTemplateName: targetTemplate.pageTemplateName, - } - - this.mappings.push(newMapping); - } - - this.saveMapping(); + updateMapping(sourceTemplate: mgmtApi.PageModel, targetTemplate: mgmtApi.PageModel, mapping: TemplateMapping) { + if (targetTemplate.pageTemplateID !== mapping.targetPageTemplateID) { + throw new Error( + `Invalid items trying to be mapped! Source pageTemplateID: ${sourceTemplate.pageTemplateID}, Target pageTemplateID: ${targetTemplate.pageTemplateID}` + ); } - - updateMapping(sourceTemplate: mgmtApi.PageModel, targetTemplate: mgmtApi.PageModel, mapping: TemplateMapping) { - if (targetTemplate.pageTemplateID !== mapping.targetPageTemplateID) { - throw new Error(`Invalid items trying to be mapped! Source pageTemplateID: ${sourceTemplate.pageTemplateID}, Target pageTemplateID: ${targetTemplate.pageTemplateID}`); - } - mapping.sourceGuid = this.sourceGuid; - mapping.targetGuid = this.targetGuid; - mapping.sourcePageTemplateID = sourceTemplate.pageTemplateID; - mapping.targetPageTemplateID = targetTemplate.pageTemplateID; - mapping.sourcePageTemplateName = sourceTemplate.pageTemplateName; - mapping.targetPageTemplateName = targetTemplate.pageTemplateName; - this.saveMapping(); - } - - loadMapping() { - const mapping = this.fileOps.getMappingFile(this.directory, this.sourceGuid, this.targetGuid); - return mapping; - } - - saveMapping() { - this.fileOps.saveMappingFile(this.mappings, this.directory, this.sourceGuid, this.targetGuid); - } - - hasTargetChanged(template: mgmtApi.PageModel): boolean { - if (!template) return false; - const mapping = this.getTemplateMapping(template, 'target'); - if (!mapping) return false; - return mapping.targetPageTemplateID !== template.pageTemplateID; - } - - hasSourceChanged(template: mgmtApi.PageModel): boolean { - const mapping = this.getTemplateMapping(template, 'source'); - if (!mapping) return false; - return mapping.sourcePageTemplateID !== template.pageTemplateID; - } - - - // we can't detect if the template has changed - // we just have to push it to the target and respect the --overwrite flag - -} \ No newline at end of file + mapping.sourceGuid = this.sourceGuid; + mapping.targetGuid = this.targetGuid; + mapping.sourcePageTemplateID = sourceTemplate.pageTemplateID; + mapping.targetPageTemplateID = targetTemplate.pageTemplateID; + mapping.sourcePageTemplateName = sourceTemplate.pageTemplateName; + mapping.targetPageTemplateName = targetTemplate.pageTemplateName; + this.saveMapping(); + } + + loadMapping() { + const mapping = this.fileOps.getMappingFile(this.directory, this.sourceGuid, this.targetGuid); + return mapping; + } + + saveMapping() { + this.fileOps.saveMappingFile(this.mappings, this.directory, this.sourceGuid, this.targetGuid); + } + + hasTargetChanged(template: mgmtApi.PageModel): boolean { + if (!template) return false; + const mapping = this.getTemplateMapping(template, "target"); + if (!mapping) return false; + return mapping.targetPageTemplateID !== template.pageTemplateID; + } + + hasSourceChanged(template: mgmtApi.PageModel): boolean { + const mapping = this.getTemplateMapping(template, "source"); + if (!mapping) return false; + return mapping.sourcePageTemplateID !== template.pageTemplateID; + } + + // we can't detect if the template has changed + // we just have to push it to the target and respect the --overwrite flag +} diff --git a/src/lib/mappers/tests/asset-mapper.test.ts b/src/lib/mappers/tests/asset-mapper.test.ts index dcb3be0..6a044e5 100644 --- a/src/lib/mappers/tests/asset-mapper.test.ts +++ b/src/lib/mappers/tests/asset-mapper.test.ts @@ -1,13 +1,13 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, setState } from 'core/state'; -import { AssetMapper } from 'lib/mappers/asset-mapper'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState } from "core/state"; +import { AssetMapper } from "lib/mappers/asset-mapper"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-asset-mapper-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-asset-mapper-")); }); afterAll(() => { @@ -17,9 +17,9 @@ afterAll(() => { beforeEach(() => { resetState(); setState({ rootPath: tmpDir }); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -37,51 +37,51 @@ function makeMapper(): AssetMapper { function makeAsset(overrides: Record = {}): any { return { mediaID: 1, - dateModified: '2024-01-01T00:00:00Z', - edgeUrl: 'https://cdn.aglty.io/src/photo.jpg', - containerEdgeUrl: 'https://cdn.aglty.io/src', - containerOriginUrl: 'https://origin.aglty.io/src', + dateModified: "2024-01-01T00:00:00Z", + edgeUrl: "https://cdn.aglty.io/src/photo.jpg", + containerEdgeUrl: "https://cdn.aglty.io/src", + containerOriginUrl: "https://origin.aglty.io/src", ...overrides, }; } // ─── constructor ────────────────────────────────────────────────────────────── -describe('AssetMapper constructor', () => { - it('constructs without throwing when no mapping file exists', () => { +describe("AssetMapper constructor", () => { + it("constructs without throwing when no mapping file exists", () => { expect(() => makeMapper()).not.toThrow(); }); }); // ─── getAssetMapping ────────────────────────────────────────────────────────── -describe('AssetMapper.getAssetMapping', () => { - it('returns null when no mapping exists for source', () => { +describe("AssetMapper.getAssetMapping", () => { + it("returns null when no mapping exists for source", () => { const mapper = makeMapper(); - expect(mapper.getAssetMapping(makeAsset({ mediaID: 99 }), 'source')).toBeNull(); + expect(mapper.getAssetMapping(makeAsset({ mediaID: 99 }), "source")).toBeNull(); }); - it('returns null when no mapping exists for target', () => { + it("returns null when no mapping exists for target", () => { const mapper = makeMapper(); - expect(mapper.getAssetMapping(makeAsset({ mediaID: 99 }), 'target')).toBeNull(); + expect(mapper.getAssetMapping(makeAsset({ mediaID: 99 }), "target")).toBeNull(); }); - it('returns the mapping after addMapping', () => { + it("returns the mapping after addMapping", () => { const mapper = makeMapper(); const src = makeAsset({ mediaID: 10 }); - const tgt = makeAsset({ mediaID: 20, edgeUrl: 'https://cdn.aglty.io/tgt/photo.jpg' }); + const tgt = makeAsset({ mediaID: 20, edgeUrl: "https://cdn.aglty.io/tgt/photo.jpg" }); mapper.addMapping(src, tgt); - const found = mapper.getAssetMapping(tgt, 'target'); + const found = mapper.getAssetMapping(tgt, "target"); expect(found).not.toBeNull(); expect(found!.targetMediaID).toBe(20); }); - it('finds the mapping by source mediaID', () => { + it("finds the mapping by source mediaID", () => { const mapper = makeMapper(); const src = makeAsset({ mediaID: 10 }); const tgt = makeAsset({ mediaID: 20 }); mapper.addMapping(src, tgt); - const found = mapper.getAssetMapping(src, 'source'); + const found = mapper.getAssetMapping(src, "source"); expect(found).not.toBeNull(); expect(found!.sourceMediaID).toBe(10); }); @@ -89,208 +89,208 @@ describe('AssetMapper.getAssetMapping', () => { // ─── getAssetMappingByMediaID ───────────────────────────────────────────────── -describe('AssetMapper.getAssetMappingByMediaID', () => { - it('returns null for unknown ID', () => { +describe("AssetMapper.getAssetMappingByMediaID", () => { + it("returns null for unknown ID", () => { const mapper = makeMapper(); - expect(mapper.getAssetMappingByMediaID(999, 'source')).toBeNull(); + expect(mapper.getAssetMappingByMediaID(999, "source")).toBeNull(); }); - it('returns mapping by source mediaID', () => { + it("returns mapping by source mediaID", () => { const mapper = makeMapper(); mapper.addMapping(makeAsset({ mediaID: 5 }), makeAsset({ mediaID: 6 })); - expect(mapper.getAssetMappingByMediaID(5, 'source')).not.toBeNull(); + expect(mapper.getAssetMappingByMediaID(5, "source")).not.toBeNull(); }); - it('returns mapping by target mediaID', () => { + it("returns mapping by target mediaID", () => { const mapper = makeMapper(); mapper.addMapping(makeAsset({ mediaID: 5 }), makeAsset({ mediaID: 6 })); - expect(mapper.getAssetMappingByMediaID(6, 'target')).not.toBeNull(); + expect(mapper.getAssetMappingByMediaID(6, "target")).not.toBeNull(); }); }); // ─── getAssetMappingByMediaUrl ──────────────────────────────────────────────── -describe('AssetMapper.getAssetMappingByMediaUrl', () => { - it('returns null when no mappings exist', () => { +describe("AssetMapper.getAssetMappingByMediaUrl", () => { + it("returns null when no mappings exist", () => { const mapper = makeMapper(); - expect(mapper.getAssetMappingByMediaUrl('https://cdn.aglty.io/none.jpg', 'source')).toBeNull(); + expect(mapper.getAssetMappingByMediaUrl("https://cdn.aglty.io/none.jpg", "source")).toBeNull(); }); - it('returns a mapping by exact source URL', () => { + it("returns a mapping by exact source URL", () => { const mapper = makeMapper(); - const src = makeAsset({ mediaID: 1, edgeUrl: 'https://cdn.aglty.io/src/photo.jpg' }); - const tgt = makeAsset({ mediaID: 2, edgeUrl: 'https://cdn.aglty.io/tgt/photo.jpg' }); + const src = makeAsset({ mediaID: 1, edgeUrl: "https://cdn.aglty.io/src/photo.jpg" }); + const tgt = makeAsset({ mediaID: 2, edgeUrl: "https://cdn.aglty.io/tgt/photo.jpg" }); mapper.addMapping(src, tgt); - const found = mapper.getAssetMappingByMediaUrl('https://cdn.aglty.io/src/photo.jpg', 'source'); + const found = mapper.getAssetMappingByMediaUrl("https://cdn.aglty.io/src/photo.jpg", "source"); expect(found).not.toBeNull(); expect(found!.sourceMediaID).toBe(1); }); - it('returns a mapping by exact target URL', () => { + it("returns a mapping by exact target URL", () => { const mapper = makeMapper(); - const src = makeAsset({ mediaID: 1, edgeUrl: 'https://cdn.aglty.io/src/photo.jpg' }); - const tgt = makeAsset({ mediaID: 2, edgeUrl: 'https://cdn.aglty.io/tgt/photo.jpg' }); + const src = makeAsset({ mediaID: 1, edgeUrl: "https://cdn.aglty.io/src/photo.jpg" }); + const tgt = makeAsset({ mediaID: 2, edgeUrl: "https://cdn.aglty.io/tgt/photo.jpg" }); mapper.addMapping(src, tgt); - const found = mapper.getAssetMappingByMediaUrl('https://cdn.aglty.io/tgt/photo.jpg', 'target'); + const found = mapper.getAssetMappingByMediaUrl("https://cdn.aglty.io/tgt/photo.jpg", "target"); expect(found).not.toBeNull(); expect(found!.targetMediaID).toBe(2); }); - it('falls back to container prefix match when exact URL is not found', () => { + it("falls back to container prefix match when exact URL is not found", () => { const mapper = makeMapper(); const src = makeAsset({ mediaID: 1, - edgeUrl: 'https://cdn.aglty.io/src/img.jpg', - containerEdgeUrl: 'https://cdn.aglty.io/src', + edgeUrl: "https://cdn.aglty.io/src/img.jpg", + containerEdgeUrl: "https://cdn.aglty.io/src", }); const tgt = makeAsset({ mediaID: 2, - edgeUrl: 'https://cdn.aglty.io/tgt/img.jpg', - containerEdgeUrl: 'https://cdn.aglty.io/tgt', + edgeUrl: "https://cdn.aglty.io/tgt/img.jpg", + containerEdgeUrl: "https://cdn.aglty.io/tgt", }); mapper.addMapping(src, tgt); - const found = mapper.getAssetMappingByMediaUrl('https://cdn.aglty.io/src/subfolder/other.jpg', 'source'); + const found = mapper.getAssetMappingByMediaUrl("https://cdn.aglty.io/src/subfolder/other.jpg", "source"); expect(found).not.toBeNull(); }); }); // ─── remapUrlByContainer ────────────────────────────────────────────────────── -describe('AssetMapper.remapUrlByContainer', () => { - it('returns null when no mappings exist', () => { +describe("AssetMapper.remapUrlByContainer", () => { + it("returns null when no mappings exist", () => { const mapper = makeMapper(); - expect(mapper.remapUrlByContainer('https://cdn.aglty.io/src/file.jpg', 'source')).toBeNull(); + expect(mapper.remapUrlByContainer("https://cdn.aglty.io/src/file.jpg", "source")).toBeNull(); }); - it('remaps a URL by swapping the edge container prefix', () => { + it("remaps a URL by swapping the edge container prefix", () => { const mapper = makeMapper(); const src = makeAsset({ mediaID: 1, - edgeUrl: 'https://cdn.aglty.io/src/img.jpg', - containerEdgeUrl: 'https://cdn.aglty.io/src', - containerOriginUrl: 'https://origin.aglty.io/src', + edgeUrl: "https://cdn.aglty.io/src/img.jpg", + containerEdgeUrl: "https://cdn.aglty.io/src", + containerOriginUrl: "https://origin.aglty.io/src", }); const tgt = makeAsset({ mediaID: 2, - edgeUrl: 'https://cdn.aglty.io/tgt/img.jpg', - containerEdgeUrl: 'https://cdn.aglty.io/tgt', - containerOriginUrl: 'https://origin.aglty.io/tgt', + edgeUrl: "https://cdn.aglty.io/tgt/img.jpg", + containerEdgeUrl: "https://cdn.aglty.io/tgt", + containerOriginUrl: "https://origin.aglty.io/tgt", }); mapper.addMapping(src, tgt); - const result = mapper.remapUrlByContainer('https://cdn.aglty.io/src/sub/file.jpg', 'source'); - expect(result).toBe('https://cdn.aglty.io/tgt/sub/file.jpg'); + const result = mapper.remapUrlByContainer("https://cdn.aglty.io/src/sub/file.jpg", "source"); + expect(result).toBe("https://cdn.aglty.io/tgt/sub/file.jpg"); }); - it('remaps a URL by swapping the origin container prefix when edge does not match', () => { + it("remaps a URL by swapping the origin container prefix when edge does not match", () => { const mapper = makeMapper(); const src = makeAsset({ mediaID: 1, - edgeUrl: 'https://cdn.aglty.io/src/img.jpg', + edgeUrl: "https://cdn.aglty.io/src/img.jpg", containerEdgeUrl: null, - containerOriginUrl: 'https://origin.aglty.io/src', + containerOriginUrl: "https://origin.aglty.io/src", }); const tgt = makeAsset({ mediaID: 2, - edgeUrl: 'https://cdn.aglty.io/tgt/img.jpg', + edgeUrl: "https://cdn.aglty.io/tgt/img.jpg", containerEdgeUrl: null, - containerOriginUrl: 'https://origin.aglty.io/tgt', + containerOriginUrl: "https://origin.aglty.io/tgt", }); mapper.addMapping(src, tgt); - const result = mapper.remapUrlByContainer('https://origin.aglty.io/src/photo.jpg', 'source'); - expect(result).toBe('https://origin.aglty.io/tgt/photo.jpg'); + const result = mapper.remapUrlByContainer("https://origin.aglty.io/src/photo.jpg", "source"); + expect(result).toBe("https://origin.aglty.io/tgt/photo.jpg"); }); - it('returns null when URL does not match any container prefix', () => { + it("returns null when URL does not match any container prefix", () => { const mapper = makeMapper(); - const src = makeAsset({ mediaID: 1, containerEdgeUrl: 'https://cdn.aglty.io/src' }); - const tgt = makeAsset({ mediaID: 2, containerEdgeUrl: 'https://cdn.aglty.io/tgt' }); + const src = makeAsset({ mediaID: 1, containerEdgeUrl: "https://cdn.aglty.io/src" }); + const tgt = makeAsset({ mediaID: 2, containerEdgeUrl: "https://cdn.aglty.io/tgt" }); mapper.addMapping(src, tgt); - const result = mapper.remapUrlByContainer('https://completely-different.io/file.jpg', 'source'); + const result = mapper.remapUrlByContainer("https://completely-different.io/file.jpg", "source"); expect(result).toBeNull(); }); }); // ─── addMapping / updateMapping ─────────────────────────────────────────────── -describe('AssetMapper.addMapping', () => { - it('adds a new mapping when target does not exist', () => { +describe("AssetMapper.addMapping", () => { + it("adds a new mapping when target does not exist", () => { const mapper = makeMapper(); const src = makeAsset({ mediaID: 10 }); const tgt = makeAsset({ mediaID: 20 }); mapper.addMapping(src, tgt); - expect(mapper.getAssetMappingByMediaID(20, 'target')).not.toBeNull(); + expect(mapper.getAssetMappingByMediaID(20, "target")).not.toBeNull(); }); - it('updates the mapping when target mediaID already exists', () => { + it("updates the mapping when target mediaID already exists", () => { const mapper = makeMapper(); - const src1 = makeAsset({ mediaID: 10, dateModified: '2024-01-01T00:00:00Z' }); + const src1 = makeAsset({ mediaID: 10, dateModified: "2024-01-01T00:00:00Z" }); const tgt = makeAsset({ mediaID: 20 }); mapper.addMapping(src1, tgt); - const src2 = makeAsset({ mediaID: 11, dateModified: '2024-02-01T00:00:00Z' }); + const src2 = makeAsset({ mediaID: 11, dateModified: "2024-02-01T00:00:00Z" }); mapper.addMapping(src2, tgt); - const found = mapper.getAssetMappingByMediaID(20, 'target'); + const found = mapper.getAssetMappingByMediaID(20, "target"); expect(found!.sourceMediaID).toBe(11); }); }); // ─── hasSourceChanged ───────────────────────────────────────────────────────── -describe('AssetMapper.hasSourceChanged', () => { - it('returns false when sourceAsset is null', () => { +describe("AssetMapper.hasSourceChanged", () => { + it("returns false when sourceAsset is null", () => { const mapper = makeMapper(); expect(mapper.hasSourceChanged(null)).toBe(false); }); - it('returns false when no mapping exists for the source asset', () => { + it("returns false when no mapping exists for the source asset", () => { const mapper = makeMapper(); expect(mapper.hasSourceChanged(makeAsset({ mediaID: 999 }))).toBe(false); }); - it('returns false when source date has not changed', () => { + it("returns false when source date has not changed", () => { const mapper = makeMapper(); - const date = '2024-01-01T00:00:00Z'; + const date = "2024-01-01T00:00:00Z"; const src = makeAsset({ mediaID: 1, dateModified: date }); mapper.addMapping(src, makeAsset({ mediaID: 2 })); expect(mapper.hasSourceChanged(makeAsset({ mediaID: 1, dateModified: date }))).toBe(false); }); - it('returns true when source date is newer than mapped date', () => { + it("returns true when source date is newer than mapped date", () => { const mapper = makeMapper(); - const src = makeAsset({ mediaID: 1, dateModified: '2024-01-01T00:00:00Z' }); + const src = makeAsset({ mediaID: 1, dateModified: "2024-01-01T00:00:00Z" }); mapper.addMapping(src, makeAsset({ mediaID: 2 })); - expect(mapper.hasSourceChanged(makeAsset({ mediaID: 1, dateModified: '2025-01-01T00:00:00Z' }))).toBe(true); + expect(mapper.hasSourceChanged(makeAsset({ mediaID: 1, dateModified: "2025-01-01T00:00:00Z" }))).toBe(true); }); }); // ─── hasTargetChanged ───────────────────────────────────────────────────────── -describe('AssetMapper.hasTargetChanged', () => { - it('returns false when targetAsset is undefined', () => { +describe("AssetMapper.hasTargetChanged", () => { + it("returns false when targetAsset is undefined", () => { const mapper = makeMapper(); expect(mapper.hasTargetChanged(undefined)).toBe(false); }); - it('returns false when no mapping exists for the target asset', () => { + it("returns false when no mapping exists for the target asset", () => { const mapper = makeMapper(); expect(mapper.hasTargetChanged(makeAsset({ mediaID: 999 }))).toBe(false); }); - it('returns false when target date has not changed', () => { + it("returns false when target date has not changed", () => { const mapper = makeMapper(); - const date = '2024-01-01T00:00:00Z'; + const date = "2024-01-01T00:00:00Z"; const src = makeAsset({ mediaID: 1, dateModified: date }); const tgt = makeAsset({ mediaID: 2, dateModified: date }); mapper.addMapping(src, tgt); expect(mapper.hasTargetChanged(makeAsset({ mediaID: 2, dateModified: date }))).toBe(false); }); - it('returns true when target date is newer than mapped date', () => { + it("returns true when target date is newer than mapped date", () => { const mapper = makeMapper(); const src = makeAsset({ mediaID: 1 }); - const tgt = makeAsset({ mediaID: 2, dateModified: '2024-01-01T00:00:00Z' }); + const tgt = makeAsset({ mediaID: 2, dateModified: "2024-01-01T00:00:00Z" }); mapper.addMapping(src, tgt); - expect(mapper.hasTargetChanged(makeAsset({ mediaID: 2, dateModified: '2025-06-01T00:00:00Z' }))).toBe(true); + expect(mapper.hasTargetChanged(makeAsset({ mediaID: 2, dateModified: "2025-06-01T00:00:00Z" }))).toBe(true); }); }); diff --git a/src/lib/mappers/tests/container-mapper.test.ts b/src/lib/mappers/tests/container-mapper.test.ts index db5a2e7..4cf6a7d 100644 --- a/src/lib/mappers/tests/container-mapper.test.ts +++ b/src/lib/mappers/tests/container-mapper.test.ts @@ -1,13 +1,13 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, setState } from 'core/state'; -import { ContainerMapper } from 'lib/mappers/container-mapper'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState } from "core/state"; +import { ContainerMapper } from "lib/mappers/container-mapper"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-container-mapper-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-container-mapper-")); }); afterAll(() => { @@ -17,9 +17,9 @@ afterAll(() => { beforeEach(() => { resetState(); setState({ rootPath: tmpDir }); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -36,42 +36,42 @@ function makeMapper(): ContainerMapper { function makeContainer(overrides: Record = {}): any { return { contentViewID: 100, - referenceName: 'MyContainer', - lastModifiedDate: '01/01/2024 10:00AM', + referenceName: "MyContainer", + lastModifiedDate: "01/01/2024 10:00AM", ...overrides, }; } // ─── constructor ────────────────────────────────────────────────────────────── -describe('ContainerMapper constructor', () => { - it('constructs without throwing', () => { +describe("ContainerMapper constructor", () => { + it("constructs without throwing", () => { expect(() => makeMapper()).not.toThrow(); }); }); // ─── getContainerMapping ────────────────────────────────────────────────────── -describe('ContainerMapper.getContainerMapping', () => { - it('returns null when no mapping exists', () => { +describe("ContainerMapper.getContainerMapping", () => { + it("returns null when no mapping exists", () => { const mapper = makeMapper(); - expect(mapper.getContainerMapping(makeContainer({ contentViewID: 999 }), 'source')).toBeNull(); + expect(mapper.getContainerMapping(makeContainer({ contentViewID: 999 }), "source")).toBeNull(); }); - it('finds mapping by source contentViewID after addMapping', () => { + it("finds mapping by source contentViewID after addMapping", () => { const mapper = makeMapper(); const src = makeContainer({ contentViewID: 10 }); const tgt = makeContainer({ contentViewID: 20 }); mapper.addMapping(src, tgt); - expect(mapper.getContainerMapping(src, 'source')).not.toBeNull(); + expect(mapper.getContainerMapping(src, "source")).not.toBeNull(); }); - it('finds mapping by target contentViewID after addMapping', () => { + it("finds mapping by target contentViewID after addMapping", () => { const mapper = makeMapper(); const src = makeContainer({ contentViewID: 10 }); const tgt = makeContainer({ contentViewID: 20 }); mapper.addMapping(src, tgt); - const found = mapper.getContainerMapping(tgt, 'target'); + const found = mapper.getContainerMapping(tgt, "target"); expect(found).not.toBeNull(); expect(found!.targetContentViewID).toBe(20); }); @@ -79,137 +79,137 @@ describe('ContainerMapper.getContainerMapping', () => { // ─── getContainerMappingByContentViewID ─────────────────────────────────────── -describe('ContainerMapper.getContainerMappingByContentViewID', () => { - it('returns null for unknown ID', () => { +describe("ContainerMapper.getContainerMappingByContentViewID", () => { + it("returns null for unknown ID", () => { const mapper = makeMapper(); - expect(mapper.getContainerMappingByContentViewID(999, 'source')).toBeNull(); + expect(mapper.getContainerMappingByContentViewID(999, "source")).toBeNull(); }); - it('returns mapping by source contentViewID', () => { + it("returns mapping by source contentViewID", () => { const mapper = makeMapper(); mapper.addMapping(makeContainer({ contentViewID: 5 }), makeContainer({ contentViewID: 6 })); - expect(mapper.getContainerMappingByContentViewID(5, 'source')).not.toBeNull(); + expect(mapper.getContainerMappingByContentViewID(5, "source")).not.toBeNull(); }); - it('returns mapping by target contentViewID', () => { + it("returns mapping by target contentViewID", () => { const mapper = makeMapper(); mapper.addMapping(makeContainer({ contentViewID: 5 }), makeContainer({ contentViewID: 6 })); - expect(mapper.getContainerMappingByContentViewID(6, 'target')).not.toBeNull(); + expect(mapper.getContainerMappingByContentViewID(6, "target")).not.toBeNull(); }); }); // ─── getContainerMappingByReferenceName ─────────────────────────────────────── -describe('ContainerMapper.getContainerMappingByReferenceName', () => { - it('returns null when no mapping exists', () => { +describe("ContainerMapper.getContainerMappingByReferenceName", () => { + it("returns null when no mapping exists", () => { const mapper = makeMapper(); - expect(mapper.getContainerMappingByReferenceName('Unknown', 'source')).toBeNull(); + expect(mapper.getContainerMappingByReferenceName("Unknown", "source")).toBeNull(); }); - it('finds by source referenceName (case insensitive)', () => { + it("finds by source referenceName (case insensitive)", () => { const mapper = makeMapper(); mapper.addMapping( - makeContainer({ contentViewID: 10, referenceName: 'MyList' }), - makeContainer({ contentViewID: 20, referenceName: 'MyListTarget' }), + makeContainer({ contentViewID: 10, referenceName: "MyList" }), + makeContainer({ contentViewID: 20, referenceName: "MyListTarget" }) ); - expect(mapper.getContainerMappingByReferenceName('mylist', 'source')).not.toBeNull(); - expect(mapper.getContainerMappingByReferenceName('MYLIST', 'source')).not.toBeNull(); + expect(mapper.getContainerMappingByReferenceName("mylist", "source")).not.toBeNull(); + expect(mapper.getContainerMappingByReferenceName("MYLIST", "source")).not.toBeNull(); }); - it('finds by target referenceName (case insensitive)', () => { + it("finds by target referenceName (case insensitive)", () => { const mapper = makeMapper(); mapper.addMapping( - makeContainer({ contentViewID: 10, referenceName: 'MyList' }), - makeContainer({ contentViewID: 20, referenceName: 'MyListTarget' }), + makeContainer({ contentViewID: 10, referenceName: "MyList" }), + makeContainer({ contentViewID: 20, referenceName: "MyListTarget" }) ); - expect(mapper.getContainerMappingByReferenceName('mylisttarget', 'target')).not.toBeNull(); + expect(mapper.getContainerMappingByReferenceName("mylisttarget", "target")).not.toBeNull(); }); }); // ─── addMapping / updateMapping ─────────────────────────────────────────────── -describe('ContainerMapper.addMapping', () => { - it('adds a new mapping when target does not exist', () => { +describe("ContainerMapper.addMapping", () => { + it("adds a new mapping when target does not exist", () => { const mapper = makeMapper(); mapper.addMapping(makeContainer({ contentViewID: 10 }), makeContainer({ contentViewID: 20 })); - expect(mapper.getContainerMappingByContentViewID(20, 'target')).not.toBeNull(); + expect(mapper.getContainerMappingByContentViewID(20, "target")).not.toBeNull(); }); - it('updates an existing mapping when called again with the same target', () => { + it("updates an existing mapping when called again with the same target", () => { const mapper = makeMapper(); const tgt = makeContainer({ contentViewID: 20 }); - mapper.addMapping(makeContainer({ contentViewID: 10, referenceName: 'OldRef' }), tgt); - mapper.addMapping(makeContainer({ contentViewID: 11, referenceName: 'NewRef' }), tgt); - const found = mapper.getContainerMappingByContentViewID(20, 'target'); + mapper.addMapping(makeContainer({ contentViewID: 10, referenceName: "OldRef" }), tgt); + mapper.addMapping(makeContainer({ contentViewID: 11, referenceName: "NewRef" }), tgt); + const found = mapper.getContainerMappingByContentViewID(20, "target"); expect(found!.sourceContentViewID).toBe(11); - expect(found!.sourceReferenceName).toBe('NewRef'); + expect(found!.sourceReferenceName).toBe("NewRef"); }); }); // ─── hasSourceChanged ───────────────────────────────────────────────────────── -describe('ContainerMapper.hasSourceChanged', () => { - it('returns false when sourceContainer is null', () => { +describe("ContainerMapper.hasSourceChanged", () => { + it("returns false when sourceContainer is null", () => { const mapper = makeMapper(); expect(mapper.hasSourceChanged(null)).toBe(false); }); - it('returns false when no mapping exists', () => { + it("returns false when no mapping exists", () => { const mapper = makeMapper(); expect(mapper.hasSourceChanged(makeContainer({ contentViewID: 999 }))).toBe(false); }); - it('returns false when date has not changed', () => { + it("returns false when date has not changed", () => { const mapper = makeMapper(); - const date = '01/15/2024 02:30PM'; + const date = "01/15/2024 02:30PM"; const src = makeContainer({ contentViewID: 10, lastModifiedDate: date }); mapper.addMapping(src, makeContainer({ contentViewID: 20 })); expect(mapper.hasSourceChanged(makeContainer({ contentViewID: 10, lastModifiedDate: date }))).toBe(false); }); - it('returns true when source date is newer than mapped date', () => { + it("returns true when source date is newer than mapped date", () => { const mapper = makeMapper(); mapper.addMapping( - makeContainer({ contentViewID: 10, lastModifiedDate: '01/01/2024 10:00AM' }), - makeContainer({ contentViewID: 20 }), + makeContainer({ contentViewID: 10, lastModifiedDate: "01/01/2024 10:00AM" }), + makeContainer({ contentViewID: 20 }) + ); + expect(mapper.hasSourceChanged(makeContainer({ contentViewID: 10, lastModifiedDate: "06/01/2025 10:00AM" }))).toBe( + true ); - expect( - mapper.hasSourceChanged(makeContainer({ contentViewID: 10, lastModifiedDate: '06/01/2025 10:00AM' })) - ).toBe(true); }); }); // ─── hasTargetChanged ───────────────────────────────────────────────────────── -describe('ContainerMapper.hasTargetChanged', () => { - it('returns false when targetContainer is null', () => { +describe("ContainerMapper.hasTargetChanged", () => { + it("returns false when targetContainer is null", () => { const mapper = makeMapper(); expect(mapper.hasTargetChanged(null)).toBe(false); }); - it('returns false when no mapping exists', () => { + it("returns false when no mapping exists", () => { const mapper = makeMapper(); expect(mapper.hasTargetChanged(makeContainer({ contentViewID: 999 }))).toBe(false); }); - it('returns false when date has not changed', () => { + it("returns false when date has not changed", () => { const mapper = makeMapper(); - const date = '03/10/2024 09:00AM'; + const date = "03/10/2024 09:00AM"; mapper.addMapping( makeContainer({ contentViewID: 10 }), - makeContainer({ contentViewID: 20, lastModifiedDate: date }), + makeContainer({ contentViewID: 20, lastModifiedDate: date }) ); expect(mapper.hasTargetChanged(makeContainer({ contentViewID: 20, lastModifiedDate: date }))).toBe(false); }); - it('returns true when target date is newer than mapped date', () => { + it("returns true when target date is newer than mapped date", () => { const mapper = makeMapper(); mapper.addMapping( makeContainer({ contentViewID: 10 }), - makeContainer({ contentViewID: 20, lastModifiedDate: '01/01/2024 10:00AM' }), + makeContainer({ contentViewID: 20, lastModifiedDate: "01/01/2024 10:00AM" }) + ); + expect(mapper.hasTargetChanged(makeContainer({ contentViewID: 20, lastModifiedDate: "12/01/2025 10:00AM" }))).toBe( + true ); - expect( - mapper.hasTargetChanged(makeContainer({ contentViewID: 20, lastModifiedDate: '12/01/2025 10:00AM' })) - ).toBe(true); }); }); diff --git a/src/lib/mappers/tests/content-item-mapper.test.ts b/src/lib/mappers/tests/content-item-mapper.test.ts index 2aac435..ee18193 100644 --- a/src/lib/mappers/tests/content-item-mapper.test.ts +++ b/src/lib/mappers/tests/content-item-mapper.test.ts @@ -1,13 +1,13 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, setState } from 'core/state'; -import { ContentItemMapper } from 'lib/mappers/content-item-mapper'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState } from "core/state"; +import { ContentItemMapper } from "lib/mappers/content-item-mapper"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-content-item-mapper-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-content-item-mapper-")); }); afterAll(() => { @@ -17,9 +17,9 @@ afterAll(() => { beforeEach(() => { resetState(); setState({ rootPath: tmpDir }); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -27,7 +27,7 @@ afterEach(() => { }); let testCounter = 0; -const LOCALE = 'en-us'; +const LOCALE = "en-us"; let currentSrc: string; let currentTgt: string; @@ -44,20 +44,20 @@ function makeItem(overrides: Record = {}): any { contentID: 100, properties: { versionID: 1, - referenceName: 'my-ref', - definitionName: 'MyModel', + referenceName: "my-ref", + definitionName: "MyModel", state: 2, ...propOverride, }, - fields: { title: 'Test Item' }, + fields: { title: "Test Item" }, ...rest, }; } // ─── constructor ────────────────────────────────────────────────────────────── -describe('ContentItemMapper constructor', () => { - it('constructs without throwing and exposes locale', () => { +describe("ContentItemMapper constructor", () => { + it("constructs without throwing and exposes locale", () => { const mapper = makeMapper(); expect(mapper.locale).toBe(LOCALE); }); @@ -65,87 +65,87 @@ describe('ContentItemMapper constructor', () => { // ─── getContentItemMapping ──────────────────────────────────────────────────── -describe('ContentItemMapper.getContentItemMapping', () => { - it('returns null when no mapping exists', () => { +describe("ContentItemMapper.getContentItemMapping", () => { + it("returns null when no mapping exists", () => { const mapper = makeMapper(); - expect(mapper.getContentItemMapping(makeItem({ contentID: 999 }), 'source')).toBeNull(); + expect(mapper.getContentItemMapping(makeItem({ contentID: 999 }), "source")).toBeNull(); }); - it('finds mapping by source contentID', () => { + it("finds mapping by source contentID", () => { const mapper = makeMapper(); mapper.addMapping(makeItem({ contentID: 10 }), makeItem({ contentID: 20 })); - expect(mapper.getContentItemMapping(makeItem({ contentID: 10 }), 'source')).not.toBeNull(); + expect(mapper.getContentItemMapping(makeItem({ contentID: 10 }), "source")).not.toBeNull(); }); - it('finds mapping by target contentID', () => { + it("finds mapping by target contentID", () => { const mapper = makeMapper(); mapper.addMapping(makeItem({ contentID: 10 }), makeItem({ contentID: 20 })); - const found = mapper.getContentItemMapping(makeItem({ contentID: 20 }), 'target'); + const found = mapper.getContentItemMapping(makeItem({ contentID: 20 }), "target"); expect(found!.targetContentID).toBe(20); }); }); // ─── getContentItemMappingByContentID ──────────────────────────────────────── -describe('ContentItemMapper.getContentItemMappingByContentID', () => { - it('returns null for unknown ID', () => { +describe("ContentItemMapper.getContentItemMappingByContentID", () => { + it("returns null for unknown ID", () => { const mapper = makeMapper(); - expect(mapper.getContentItemMappingByContentID(999, 'source')).toBeNull(); + expect(mapper.getContentItemMappingByContentID(999, "source")).toBeNull(); }); - it('returns mapping by source contentID', () => { + it("returns mapping by source contentID", () => { const mapper = makeMapper(); mapper.addMapping(makeItem({ contentID: 5 }), makeItem({ contentID: 6 })); - expect(mapper.getContentItemMappingByContentID(5, 'source')).not.toBeNull(); + expect(mapper.getContentItemMappingByContentID(5, "source")).not.toBeNull(); }); - it('returns mapping by target contentID', () => { + it("returns mapping by target contentID", () => { const mapper = makeMapper(); mapper.addMapping(makeItem({ contentID: 5 }), makeItem({ contentID: 6 })); - expect(mapper.getContentItemMappingByContentID(6, 'target')).not.toBeNull(); + expect(mapper.getContentItemMappingByContentID(6, "target")).not.toBeNull(); }); }); // ─── getMappedEntity ────────────────────────────────────────────────────────── -describe('ContentItemMapper.getMappedEntity', () => { - it('returns null when mapping is null', () => { +describe("ContentItemMapper.getMappedEntity", () => { + it("returns null when mapping is null", () => { const mapper = makeMapper(); - expect(mapper.getMappedEntity(null as any, 'source')).toBeNull(); + expect(mapper.getMappedEntity(null as any, "source")).toBeNull(); }); - it('returns null when mapping has no guid', () => { + it("returns null when mapping has no guid", () => { const mapper = makeMapper(); const mapping = { - sourceGuid: '', - targetGuid: '', + sourceGuid: "", + targetGuid: "", sourceContentID: 0, targetContentID: 0, sourceVersionID: 1, targetVersionID: 1, }; - expect(mapper.getMappedEntity(mapping as any, 'source')).toBeNull(); + expect(mapper.getMappedEntity(mapping as any, "source")).toBeNull(); }); - it('returns null when the content file does not exist on disk', () => { + it("returns null when the content file does not exist on disk", () => { const mapper = makeMapper(); mapper.addMapping(makeItem({ contentID: 10 }), makeItem({ contentID: 20 })); - const mapping = mapper.getContentItemMappingByContentID(20, 'target')!; - expect(mapper.getMappedEntity(mapping, 'target')).toBeNull(); + const mapping = mapper.getContentItemMappingByContentID(20, "target")!; + expect(mapper.getMappedEntity(mapping, "target")).toBeNull(); }); - it('returns the content item when the file exists and has properties', () => { + it("returns the content item when the file exists and has properties", () => { const mapper = makeMapper(); mapper.addMapping(makeItem({ contentID: 10 }), makeItem({ contentID: 20 })); - const mapping = mapper.getContentItemMappingByContentID(20, 'target')!; + const mapping = mapper.getContentItemMappingByContentID(20, "target")!; // Write a fake content item file to the target location - const itemDir = path.join(tmpDir, currentTgt, LOCALE, 'item'); + const itemDir = path.join(tmpDir, currentTgt, LOCALE, "item"); fs.mkdirSync(itemDir, { recursive: true }); const itemData = { contentID: 20, properties: { versionID: 5 }, fields: {} }; - fs.writeFileSync(path.join(itemDir, '20.json'), JSON.stringify(itemData)); + fs.writeFileSync(path.join(itemDir, "20.json"), JSON.stringify(itemData)); - const result = mapper.getMappedEntity(mapping, 'target'); + const result = mapper.getMappedEntity(mapping, "target"); expect(result).not.toBeNull(); expect((result as any).contentID).toBe(20); }); @@ -153,31 +153,31 @@ describe('ContentItemMapper.getMappedEntity', () => { // ─── addMapping / updateMapping ─────────────────────────────────────────────── -describe('ContentItemMapper.addMapping', () => { - it('adds a new mapping', () => { +describe("ContentItemMapper.addMapping", () => { + it("adds a new mapping", () => { const mapper = makeMapper(); mapper.addMapping(makeItem({ contentID: 10 }), makeItem({ contentID: 20 })); - expect(mapper.getContentItemMappingByContentID(20, 'target')).not.toBeNull(); + expect(mapper.getContentItemMappingByContentID(20, "target")).not.toBeNull(); }); - it('updates an existing mapping when target contentID already exists', () => { + it("updates an existing mapping when target contentID already exists", () => { const mapper = makeMapper(); const tgt = makeItem({ contentID: 20, properties: { versionID: 1 } }); mapper.addMapping(makeItem({ contentID: 10, properties: { versionID: 1 } }), tgt); mapper.addMapping(makeItem({ contentID: 11, properties: { versionID: 2 } }), tgt); - const found = mapper.getContentItemMappingByContentID(20, 'target')!; + const found = mapper.getContentItemMappingByContentID(20, "target")!; expect(found.sourceContentID).toBe(11); expect(found.sourceVersionID).toBe(2); }); - it('throws when source is already mapped to a target and a different target is also already mapped', () => { + it("throws when source is already mapped to a target and a different target is also already mapped", () => { const mapper = makeMapper(); mapper.addMapping(makeItem({ contentID: 10 }), makeItem({ contentID: 20 })); mapper.addMapping(makeItem({ contentID: 11 }), makeItem({ contentID: 21 })); // source 10 maps to 20; target 21 maps to 11 — genuinely conflicting cross-mapping - expect(() => - mapper.addMapping(makeItem({ contentID: 10 }), makeItem({ contentID: 21 })) - ).toThrow('Invalid Mappings detected'); + expect(() => mapper.addMapping(makeItem({ contentID: 10 }), makeItem({ contentID: 21 }))).toThrow( + "Invalid Mappings detected" + ); }); it('throws when source already has a mapping but a different unmapped target is provided (duplicate mapping attempt)', () => { @@ -193,44 +193,41 @@ describe('ContentItemMapper.addMapping', () => { // ─── hasSourceChanged ───────────────────────────────────────────────────────── -describe('ContentItemMapper.hasSourceChanged', () => { - it('returns true when no mapping exists (treat as changed)', () => { +describe("ContentItemMapper.hasSourceChanged", () => { + it("returns true when no mapping exists (treat as changed)", () => { const mapper = makeMapper(); expect(mapper.hasSourceChanged(makeItem({ contentID: 999, properties: { versionID: 1 } }))).toBe(true); }); - it('returns false when versionID matches mapping', () => { + it("returns false when versionID matches mapping", () => { const mapper = makeMapper(); const src = makeItem({ contentID: 10, properties: { versionID: 5 } }); mapper.addMapping(src, makeItem({ contentID: 20 })); expect(mapper.hasSourceChanged(makeItem({ contentID: 10, properties: { versionID: 5 } }))).toBe(false); }); - it('returns true when source versionID is greater than mapped versionID', () => { + it("returns true when source versionID is greater than mapped versionID", () => { const mapper = makeMapper(); - mapper.addMapping( - makeItem({ contentID: 10, properties: { versionID: 5 } }), - makeItem({ contentID: 20 }), - ); + mapper.addMapping(makeItem({ contentID: 10, properties: { versionID: 5 } }), makeItem({ contentID: 20 })); expect(mapper.hasSourceChanged(makeItem({ contentID: 10, properties: { versionID: 10 } }))).toBe(true); }); }); // ─── hasTargetChanged ───────────────────────────────────────────────────────── -describe('ContentItemMapper.hasTargetChanged', () => { - it('returns false when no mapping exists', () => { +describe("ContentItemMapper.hasTargetChanged", () => { + it("returns false when no mapping exists", () => { const mapper = makeMapper(); expect(mapper.hasTargetChanged(makeItem({ contentID: 999 }))).toBe(false); }); - it('returns false when versionID matches', () => { + it("returns false when versionID matches", () => { const mapper = makeMapper(); mapper.addMapping(makeItem({ contentID: 10 }), makeItem({ contentID: 20, properties: { versionID: 3 } })); expect(mapper.hasTargetChanged(makeItem({ contentID: 20, properties: { versionID: 3 } }))).toBe(false); }); - it('returns true when target versionID is greater than mapped versionID', () => { + it("returns true when target versionID is greater than mapped versionID", () => { const mapper = makeMapper(); mapper.addMapping(makeItem({ contentID: 10 }), makeItem({ contentID: 20, properties: { versionID: 3 } })); expect(mapper.hasTargetChanged(makeItem({ contentID: 20, properties: { versionID: 9 } }))).toBe(true); @@ -239,13 +236,13 @@ describe('ContentItemMapper.hasTargetChanged', () => { // ─── updateTargetVersionID ──────────────────────────────────────────────────── -describe('ContentItemMapper.updateTargetVersionID', () => { - it('returns success:false when no mapping exists', () => { +describe("ContentItemMapper.updateTargetVersionID", () => { + it("returns success:false when no mapping exists", () => { const mapper = makeMapper(); expect(mapper.updateTargetVersionID(999, 42)).toEqual({ success: false }); }); - it('returns success:true with old and new versionIDs when mapping exists', () => { + it("returns success:true with old and new versionIDs when mapping exists", () => { const mapper = makeMapper(); mapper.addMapping(makeItem({ contentID: 10 }), makeItem({ contentID: 20, properties: { versionID: 5 } })); const result = mapper.updateTargetVersionID(20, 10); @@ -254,18 +251,18 @@ describe('ContentItemMapper.updateTargetVersionID', () => { expect(result.newVersionID).toBe(10); }); - it('does not save mapping when versionID is unchanged', () => { + it("does not save mapping when versionID is unchanged", () => { const mapper = makeMapper(); mapper.addMapping(makeItem({ contentID: 10 }), makeItem({ contentID: 20, properties: { versionID: 5 } })); - const saveSpy = jest.spyOn(mapper as any, 'saveMapping'); + const saveSpy = jest.spyOn(mapper as any, "saveMapping"); mapper.updateTargetVersionID(20, 5); expect(saveSpy).not.toHaveBeenCalled(); }); - it('saves mapping when versionID changes', () => { + it("saves mapping when versionID changes", () => { const mapper = makeMapper(); mapper.addMapping(makeItem({ contentID: 10 }), makeItem({ contentID: 20, properties: { versionID: 5 } })); - const saveSpy = jest.spyOn(mapper as any, 'saveMapping'); + const saveSpy = jest.spyOn(mapper as any, "saveMapping"); mapper.updateTargetVersionID(20, 99); expect(saveSpy).toHaveBeenCalledTimes(1); }); diff --git a/src/lib/mappers/tests/gallery-mapper.test.ts b/src/lib/mappers/tests/gallery-mapper.test.ts index 0f7cd56..d5a1a8c 100644 --- a/src/lib/mappers/tests/gallery-mapper.test.ts +++ b/src/lib/mappers/tests/gallery-mapper.test.ts @@ -1,13 +1,13 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, setState } from 'core/state'; -import { GalleryMapper } from 'lib/mappers/gallery-mapper'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState } from "core/state"; +import { GalleryMapper } from "lib/mappers/gallery-mapper"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-gallery-mapper-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-gallery-mapper-")); }); afterAll(() => { @@ -17,9 +17,9 @@ afterAll(() => { beforeEach(() => { resetState(); setState({ rootPath: tmpDir }); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -36,140 +36,133 @@ function makeMapper(): GalleryMapper { function makeGallery(overrides: Record = {}): any { return { mediaGroupingID: 1, - modifiedOn: '01/01/2024 10:00AM', + modifiedOn: "01/01/2024 10:00AM", ...overrides, }; } // ─── constructor ────────────────────────────────────────────────────────────── -describe('GalleryMapper constructor', () => { - it('constructs without throwing', () => { +describe("GalleryMapper constructor", () => { + it("constructs without throwing", () => { expect(() => makeMapper()).not.toThrow(); }); }); // ─── getGalleryMapping ──────────────────────────────────────────────────────── -describe('GalleryMapper.getGalleryMapping', () => { - it('returns null when no mapping exists', () => { +describe("GalleryMapper.getGalleryMapping", () => { + it("returns null when no mapping exists", () => { const mapper = makeMapper(); - expect(mapper.getGalleryMapping(makeGallery({ mediaGroupingID: 999 }), 'source')).toBeNull(); + expect(mapper.getGalleryMapping(makeGallery({ mediaGroupingID: 999 }), "source")).toBeNull(); }); - it('finds mapping by source mediaGroupingID', () => { + it("finds mapping by source mediaGroupingID", () => { const mapper = makeMapper(); mapper.addMapping(makeGallery({ mediaGroupingID: 10 }), makeGallery({ mediaGroupingID: 20 })); - expect(mapper.getGalleryMapping(makeGallery({ mediaGroupingID: 10 }), 'source')).not.toBeNull(); + expect(mapper.getGalleryMapping(makeGallery({ mediaGroupingID: 10 }), "source")).not.toBeNull(); }); - it('finds mapping by target mediaGroupingID', () => { + it("finds mapping by target mediaGroupingID", () => { const mapper = makeMapper(); mapper.addMapping(makeGallery({ mediaGroupingID: 10 }), makeGallery({ mediaGroupingID: 20 })); - const found = mapper.getGalleryMapping(makeGallery({ mediaGroupingID: 20 }), 'target'); + const found = mapper.getGalleryMapping(makeGallery({ mediaGroupingID: 20 }), "target"); expect(found!.targetMediaGroupingID).toBe(20); }); }); // ─── getGalleryMappingByMediaGroupingID ─────────────────────────────────────── -describe('GalleryMapper.getGalleryMappingByMediaGroupingID', () => { - it('returns null for unknown ID', () => { +describe("GalleryMapper.getGalleryMappingByMediaGroupingID", () => { + it("returns null for unknown ID", () => { const mapper = makeMapper(); - expect(mapper.getGalleryMappingByMediaGroupingID(999, 'source')).toBeNull(); + expect(mapper.getGalleryMappingByMediaGroupingID(999, "source")).toBeNull(); }); - it('returns mapping by source mediaGroupingID', () => { + it("returns mapping by source mediaGroupingID", () => { const mapper = makeMapper(); mapper.addMapping(makeGallery({ mediaGroupingID: 5 }), makeGallery({ mediaGroupingID: 6 })); - expect(mapper.getGalleryMappingByMediaGroupingID(5, 'source')).not.toBeNull(); + expect(mapper.getGalleryMappingByMediaGroupingID(5, "source")).not.toBeNull(); }); - it('returns mapping by target mediaGroupingID', () => { + it("returns mapping by target mediaGroupingID", () => { const mapper = makeMapper(); mapper.addMapping(makeGallery({ mediaGroupingID: 5 }), makeGallery({ mediaGroupingID: 6 })); - expect(mapper.getGalleryMappingByMediaGroupingID(6, 'target')).not.toBeNull(); + expect(mapper.getGalleryMappingByMediaGroupingID(6, "target")).not.toBeNull(); }); }); // ─── addMapping / updateMapping ─────────────────────────────────────────────── -describe('GalleryMapper.addMapping', () => { - it('adds a new mapping', () => { +describe("GalleryMapper.addMapping", () => { + it("adds a new mapping", () => { const mapper = makeMapper(); mapper.addMapping(makeGallery({ mediaGroupingID: 10 }), makeGallery({ mediaGroupingID: 20 })); - expect(mapper.getGalleryMappingByMediaGroupingID(20, 'target')).not.toBeNull(); + expect(mapper.getGalleryMappingByMediaGroupingID(20, "target")).not.toBeNull(); }); - it('updates existing mapping when target already exists', () => { + it("updates existing mapping when target already exists", () => { const mapper = makeMapper(); const tgt = makeGallery({ mediaGroupingID: 20 }); - mapper.addMapping(makeGallery({ mediaGroupingID: 10, modifiedOn: '01/01/2024 10:00AM' }), tgt); - mapper.addMapping(makeGallery({ mediaGroupingID: 11, modifiedOn: '02/01/2024 10:00AM' }), tgt); - const found = mapper.getGalleryMappingByMediaGroupingID(20, 'target')!; + mapper.addMapping(makeGallery({ mediaGroupingID: 10, modifiedOn: "01/01/2024 10:00AM" }), tgt); + mapper.addMapping(makeGallery({ mediaGroupingID: 11, modifiedOn: "02/01/2024 10:00AM" }), tgt); + const found = mapper.getGalleryMappingByMediaGroupingID(20, "target")!; expect(found.sourceMediaGroupingID).toBe(11); }); }); // ─── hasSourceChanged ───────────────────────────────────────────────────────── -describe('GalleryMapper.hasSourceChanged', () => { - it('returns false when no mapping exists', () => { +describe("GalleryMapper.hasSourceChanged", () => { + it("returns false when no mapping exists", () => { const mapper = makeMapper(); expect(mapper.hasSourceChanged(makeGallery({ mediaGroupingID: 999 }))).toBe(false); }); - it('returns false when modifiedOn has not changed', () => { + it("returns false when modifiedOn has not changed", () => { const mapper = makeMapper(); - const date = '03/15/2024 02:00PM'; + const date = "03/15/2024 02:00PM"; const src = makeGallery({ mediaGroupingID: 10, modifiedOn: date }); mapper.addMapping(src, makeGallery({ mediaGroupingID: 20 })); expect(mapper.hasSourceChanged(makeGallery({ mediaGroupingID: 10, modifiedOn: date }))).toBe(false); }); - it('returns true when source date is newer than mapped date', () => { + it("returns true when source date is newer than mapped date", () => { const mapper = makeMapper(); mapper.addMapping( - makeGallery({ mediaGroupingID: 10, modifiedOn: '01/01/2024 10:00AM' }), - makeGallery({ mediaGroupingID: 20 }), + makeGallery({ mediaGroupingID: 10, modifiedOn: "01/01/2024 10:00AM" }), + makeGallery({ mediaGroupingID: 20 }) ); - expect( - mapper.hasSourceChanged(makeGallery({ mediaGroupingID: 10, modifiedOn: '06/01/2025 10:00AM' })) - ).toBe(true); + expect(mapper.hasSourceChanged(makeGallery({ mediaGroupingID: 10, modifiedOn: "06/01/2025 10:00AM" }))).toBe(true); }); }); // ─── hasTargetChanged ───────────────────────────────────────────────────────── -describe('GalleryMapper.hasTargetChanged', () => { - it('returns false when targetGallery is null/falsy', () => { +describe("GalleryMapper.hasTargetChanged", () => { + it("returns false when targetGallery is null/falsy", () => { const mapper = makeMapper(); expect(mapper.hasTargetChanged(null as any)).toBe(false); }); - it('returns false when no mapping exists', () => { + it("returns false when no mapping exists", () => { const mapper = makeMapper(); expect(mapper.hasTargetChanged(makeGallery({ mediaGroupingID: 999 }))).toBe(false); }); - it('returns false when modifiedOn has not changed', () => { + it("returns false when modifiedOn has not changed", () => { const mapper = makeMapper(); - const date = '04/10/2024 09:00AM'; - mapper.addMapping( - makeGallery({ mediaGroupingID: 10 }), - makeGallery({ mediaGroupingID: 20, modifiedOn: date }), - ); + const date = "04/10/2024 09:00AM"; + mapper.addMapping(makeGallery({ mediaGroupingID: 10 }), makeGallery({ mediaGroupingID: 20, modifiedOn: date })); expect(mapper.hasTargetChanged(makeGallery({ mediaGroupingID: 20, modifiedOn: date }))).toBe(false); }); - it('returns true when target date is newer than mapped date', () => { + it("returns true when target date is newer than mapped date", () => { const mapper = makeMapper(); mapper.addMapping( makeGallery({ mediaGroupingID: 10 }), - makeGallery({ mediaGroupingID: 20, modifiedOn: '01/01/2024 10:00AM' }), + makeGallery({ mediaGroupingID: 20, modifiedOn: "01/01/2024 10:00AM" }) ); - expect( - mapper.hasTargetChanged(makeGallery({ mediaGroupingID: 20, modifiedOn: '12/31/2025 11:59PM' })) - ).toBe(true); + expect(mapper.hasTargetChanged(makeGallery({ mediaGroupingID: 20, modifiedOn: "12/31/2025 11:59PM" }))).toBe(true); }); }); diff --git a/src/lib/mappers/tests/mapping-reader.test.ts b/src/lib/mappers/tests/mapping-reader.test.ts index 8229ba2..7c1f77d 100644 --- a/src/lib/mappers/tests/mapping-reader.test.ts +++ b/src/lib/mappers/tests/mapping-reader.test.ts @@ -1,24 +1,20 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, setState } from 'core/state'; -import { - readMappingsForGuidPair, - listAvailableMappingPairs, - getMappingSummary, -} from 'lib/mappers/mapping-reader'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState } from "core/state"; +import { readMappingsForGuidPair, listAvailableMappingPairs, getMappingSummary } from "lib/mappers/mapping-reader"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-mapping-reader-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-mapping-reader-")); }); afterAll(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); -const LOCALE = 'en-us'; +const LOCALE = "en-us"; let testCounter = 0; let SRC: string; let TGT: string; @@ -31,9 +27,9 @@ beforeEach(() => { resetState(); setState({ rootPath: tmpDir }); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -41,16 +37,16 @@ afterEach(() => { }); function writeMappingFile(type: string, data: any[], locale?: string): void { - const localeSegment = locale ?? ''; - const dir = path.join(tmpDir, 'mappings', `${SRC}-${TGT}`, localeSegment, type); + const localeSegment = locale ?? ""; + const dir = path.join(tmpDir, "mappings", `${SRC}-${TGT}`, localeSegment, type); fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, 'mappings.json'), JSON.stringify(data)); + fs.writeFileSync(path.join(dir, "mappings.json"), JSON.stringify(data)); } // ─── readMappingsForGuidPair ────────────────────────────────────────────────── -describe('readMappingsForGuidPair', () => { - it('returns empty arrays when no mapping files exist for this GUID pair', () => { +describe("readMappingsForGuidPair", () => { + it("returns empty arrays when no mapping files exist for this GUID pair", () => { const result = readMappingsForGuidPair(SRC, TGT, [LOCALE]); expect(result.contentIds).toEqual([]); expect(result.pageIds).toEqual([]); @@ -58,40 +54,70 @@ describe('readMappingsForGuidPair', () => { expect(result.pageMappings).toEqual([]); }); - it('reads content mappings and extracts targetContentIDs', () => { + it("reads content mappings and extracts targetContentIDs", () => { const mappings = [ - { sourceGuid: SRC, targetGuid: TGT, sourceContentID: 1, targetContentID: 101, sourceVersionID: 1, targetVersionID: 2 }, - { sourceGuid: SRC, targetGuid: TGT, sourceContentID: 2, targetContentID: 102, sourceVersionID: 1, targetVersionID: 2 }, + { + sourceGuid: SRC, + targetGuid: TGT, + sourceContentID: 1, + targetContentID: 101, + sourceVersionID: 1, + targetVersionID: 2, + }, + { + sourceGuid: SRC, + targetGuid: TGT, + sourceContentID: 2, + targetContentID: 102, + sourceVersionID: 1, + targetVersionID: 2, + }, ]; - writeMappingFile('item', mappings, LOCALE); + writeMappingFile("item", mappings, LOCALE); const result = readMappingsForGuidPair(SRC, TGT, [LOCALE]); expect(result.contentIds).toContain(101); expect(result.contentIds).toContain(102); expect(result.contentMappings).toHaveLength(2); }); - it('reads page mappings and extracts targetPageIDs', () => { + it("reads page mappings and extracts targetPageIDs", () => { const pageMappings = [ - { sourceGuid: SRC, targetGuid: TGT, sourcePageID: 10, targetPageID: 110, sourceVersionID: 1, targetVersionID: 1, sourcePageTemplateName: 'T', targetPageTemplateName: 'T' }, + { + sourceGuid: SRC, + targetGuid: TGT, + sourcePageID: 10, + targetPageID: 110, + sourceVersionID: 1, + targetVersionID: 1, + sourcePageTemplateName: "T", + targetPageTemplateName: "T", + }, ]; - writeMappingFile('page', pageMappings, LOCALE); + writeMappingFile("page", pageMappings, LOCALE); const result = readMappingsForGuidPair(SRC, TGT, [LOCALE]); expect(result.pageIds).toContain(110); expect(result.pageMappings).toHaveLength(1); }); - it('deduplicates IDs that appear across multiple locales', () => { + it("deduplicates IDs that appear across multiple locales", () => { const mappings = [ - { sourceGuid: SRC, targetGuid: TGT, sourceContentID: 1, targetContentID: 101, sourceVersionID: 1, targetVersionID: 1 }, + { + sourceGuid: SRC, + targetGuid: TGT, + sourceContentID: 1, + targetContentID: 101, + sourceVersionID: 1, + targetVersionID: 1, + }, ]; - writeMappingFile('item', mappings, 'en-us'); - writeMappingFile('item', mappings, 'fr-ca'); - const result = readMappingsForGuidPair(SRC, TGT, ['en-us', 'fr-ca']); + writeMappingFile("item", mappings, "en-us"); + writeMappingFile("item", mappings, "fr-ca"); + const result = readMappingsForGuidPair(SRC, TGT, ["en-us", "fr-ca"]); // 101 appears in both locales but should be deduplicated expect(result.contentIds.filter((id) => id === 101)).toHaveLength(1); }); - it('returns empty results when locales array is empty', () => { + it("returns empty results when locales array is empty", () => { const result = readMappingsForGuidPair(SRC, TGT, []); expect(result.contentIds).toEqual([]); expect(result.pageIds).toEqual([]); @@ -100,10 +126,10 @@ describe('readMappingsForGuidPair', () => { // ─── listAvailableMappingPairs ──────────────────────────────────────────────── -describe('listAvailableMappingPairs', () => { - it('returns empty array when mappings root does not exist', () => { +describe("listAvailableMappingPairs", () => { + it("returns empty array when mappings root does not exist", () => { // Use a fresh isolated tmpDir so we are certain there are no pre-existing mapping dirs - const isolatedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-reader-iso-')); + const isolatedDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-reader-iso-")); try { setState({ rootPath: isolatedDir }); const result = listAvailableMappingPairs(); @@ -113,52 +139,81 @@ describe('listAvailableMappingPairs', () => { } }); - it('returns pairs that have locale subdirectories', () => { - const pairDir = path.join(tmpDir, 'mappings', `${SRC}-${TGT}`); - fs.mkdirSync(path.join(pairDir, 'en-us'), { recursive: true }); + it("returns pairs that have locale subdirectories", () => { + const pairDir = path.join(tmpDir, "mappings", `${SRC}-${TGT}`); + fs.mkdirSync(path.join(pairDir, "en-us"), { recursive: true }); const result = listAvailableMappingPairs(); const pair = result.find((p) => p.sourceGuid === SRC && p.targetGuid === TGT); expect(pair).toBeDefined(); - expect(pair!.locales).toContain('en-us'); + expect(pair!.locales).toContain("en-us"); }); - it('skips directories that do not match the GUID pair format', () => { - const invalidDir = path.join(tmpDir, 'mappings', 'not-valid-format-xyz'); - fs.mkdirSync(path.join(invalidDir, 'en-us'), { recursive: true }); + it("skips directories that do not match the GUID pair format", () => { + const invalidDir = path.join(tmpDir, "mappings", "not-valid-format-xyz"); + fs.mkdirSync(path.join(invalidDir, "en-us"), { recursive: true }); const result = listAvailableMappingPairs(); - const found = result.find((p) => p.sourceGuid === 'not' && p.targetGuid === 'valid'); + const found = result.find((p) => p.sourceGuid === "not" && p.targetGuid === "valid"); expect(found).toBeUndefined(); }); }); // ─── getMappingSummary ──────────────────────────────────────────────────────── -describe('getMappingSummary', () => { - it('returns zero totals when no mapping files exist for this GUID pair', () => { +describe("getMappingSummary", () => { + it("returns zero totals when no mapping files exist for this GUID pair", () => { const summary = getMappingSummary(SRC, TGT, [LOCALE]); expect(summary.totalContent).toBe(0); expect(summary.totalPages).toBe(0); }); - it('returns correct totalContent count', () => { - writeMappingFile('item', [ - { sourceGuid: SRC, targetGuid: TGT, sourceContentID: 50, targetContentID: 150, sourceVersionID: 1, targetVersionID: 1 }, - { sourceGuid: SRC, targetGuid: TGT, sourceContentID: 51, targetContentID: 151, sourceVersionID: 1, targetVersionID: 1 }, - ], LOCALE); + it("returns correct totalContent count", () => { + writeMappingFile( + "item", + [ + { + sourceGuid: SRC, + targetGuid: TGT, + sourceContentID: 50, + targetContentID: 150, + sourceVersionID: 1, + targetVersionID: 1, + }, + { + sourceGuid: SRC, + targetGuid: TGT, + sourceContentID: 51, + targetContentID: 151, + sourceVersionID: 1, + targetVersionID: 1, + }, + ], + LOCALE + ); const summary = getMappingSummary(SRC, TGT, [LOCALE]); expect(summary.totalContent).toBe(2); }); - it('includes locale in localesFound when it has content mappings', () => { - writeMappingFile('item', [ - { sourceGuid: SRC, targetGuid: TGT, sourceContentID: 70, targetContentID: 170, sourceVersionID: 1, targetVersionID: 1 }, - ], LOCALE); + it("includes locale in localesFound when it has content mappings", () => { + writeMappingFile( + "item", + [ + { + sourceGuid: SRC, + targetGuid: TGT, + sourceContentID: 70, + targetContentID: 170, + sourceVersionID: 1, + targetVersionID: 1, + }, + ], + LOCALE + ); const summary = getMappingSummary(SRC, TGT, [LOCALE]); expect(summary.localesFound).toContain(LOCALE); }); - it('does not include locale in localesFound when it has no mappings', () => { - const summary = getMappingSummary(SRC, TGT, ['de-de']); - expect(summary.localesFound).not.toContain('de-de'); + it("does not include locale in localesFound when it has no mappings", () => { + const summary = getMappingSummary(SRC, TGT, ["de-de"]); + expect(summary.localesFound).not.toContain("de-de"); }); }); diff --git a/src/lib/mappers/tests/mapping-version-updater.test.ts b/src/lib/mappers/tests/mapping-version-updater.test.ts index b61dba1..d606b3f 100644 --- a/src/lib/mappers/tests/mapping-version-updater.test.ts +++ b/src/lib/mappers/tests/mapping-version-updater.test.ts @@ -1,27 +1,27 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, setState } from 'core/state'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState } from "core/state"; // Mock the filesystem getters to avoid real I/O beyond what we control -jest.mock('lib/getters/filesystem/get-content-items', () => ({ +jest.mock("lib/getters/filesystem/get-content-items", () => ({ getContentItemsFromFileSystem: jest.fn(), })); -jest.mock('lib/getters/filesystem/get-pages', () => ({ +jest.mock("lib/getters/filesystem/get-pages", () => ({ getPagesFromFileSystem: jest.fn(), })); // Import after mocks are in place -import { getContentItemsFromFileSystem } from 'lib/getters/filesystem/get-content-items'; -import { getPagesFromFileSystem } from 'lib/getters/filesystem/get-pages'; +import { getContentItemsFromFileSystem } from "lib/getters/filesystem/get-content-items"; +import { getPagesFromFileSystem } from "lib/getters/filesystem/get-pages"; import { updateContentMappingsAfterPublish, updatePageMappingsAfterPublish, updateMappingsAfterPublish, VersionChangeDetail, -} from 'lib/mappers/mapping-version-updater'; -import { ContentItemMapper } from 'lib/mappers/content-item-mapper'; -import { PageMapper } from 'lib/mappers/page-mapper'; +} from "lib/mappers/mapping-version-updater"; +import { ContentItemMapper } from "lib/mappers/content-item-mapper"; +import { PageMapper } from "lib/mappers/page-mapper"; const mockGetContentItems = getContentItemsFromFileSystem as jest.MockedFunction; const mockGetPages = getPagesFromFileSystem as jest.MockedFunction; @@ -29,7 +29,7 @@ const mockGetPages = getPagesFromFileSystem as jest.MockedFunction { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-mapping-version-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-mapping-version-")); }); afterAll(() => { @@ -42,9 +42,9 @@ beforeEach(() => { TGT = `tgt-${testCounter}`; resetState(); setState({ rootPath: tmpDir }); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); mockGetContentItems.mockReturnValue([]); mockGetPages.mockReturnValue([]); }); @@ -54,7 +54,7 @@ afterEach(() => { }); let testCounter = 0; -const LOCALE = 'en-us'; +const LOCALE = "en-us"; let SRC: string; let TGT: string; @@ -62,8 +62,8 @@ function makeContentItem(overrides: Record = {}): any { const { properties: propOverride, ...rest } = overrides; return { contentID: 100, - properties: { versionID: 1, referenceName: 'ref', definitionName: 'Model', state: 2, ...propOverride }, - fields: { title: 'Test' }, + properties: { versionID: 1, referenceName: "ref", definitionName: "Model", state: 2, ...propOverride }, + fields: { title: "Test" }, ...rest, }; } @@ -71,9 +71,9 @@ function makeContentItem(overrides: Record = {}): any { function makePage(overrides: Record = {}): any { return { pageID: 50, - title: 'Test Page', - name: 'test-page', - templateName: 'TwoCol', + title: "Test Page", + name: "test-page", + templateName: "TwoCol", properties: { versionID: 1 }, ...overrides, }; @@ -81,7 +81,10 @@ function makePage(overrides: Record = {}): any { function seedContentMapper(sourceID: number, targetID: number, versionID: number = 1): void { const mapper = new ContentItemMapper(SRC, TGT, LOCALE); - mapper.addMapping(makeContentItem({ contentID: sourceID }), makeContentItem({ contentID: targetID, properties: { versionID } })); + mapper.addMapping( + makeContentItem({ contentID: sourceID }), + makeContentItem({ contentID: targetID, properties: { versionID } }) + ); } function seedPageMapper(sourceID: number, targetID: number, versionID: number = 1): void { @@ -91,28 +94,28 @@ function seedPageMapper(sourceID: number, targetID: number, versionID: number = // ─── updateContentMappingsAfterPublish ──────────────────────────────────────── -describe('updateContentMappingsAfterPublish', () => { - it('returns zero updated when publishedContentIds is empty', async () => { +describe("updateContentMappingsAfterPublish", () => { + it("returns zero updated when publishedContentIds is empty", async () => { const result = await updateContentMappingsAfterPublish([], SRC, TGT, LOCALE); expect(result.updated).toBe(0); expect(result.errors).toHaveLength(0); }); - it('deduplicates published content IDs before processing', async () => { + it("deduplicates published content IDs before processing", async () => { mockGetContentItems.mockReturnValue([]); const result = await updateContentMappingsAfterPublish([100, 100, 100], SRC, TGT, LOCALE); // 100 is not found in filesystem → one error, not three expect(result.errors).toHaveLength(1); }); - it('records an error when a content item is not found in target filesystem', async () => { + it("records an error when a content item is not found in target filesystem", async () => { mockGetContentItems.mockReturnValue([]); const result = await updateContentMappingsAfterPublish([999], SRC, TGT, LOCALE); expect(result.errors.length).toBeGreaterThan(0); expect(result.errors[0]).toMatch(/999/); }); - it('records an error when no mapping exists for a target content ID', async () => { + it("records an error when no mapping exists for a target content ID", async () => { const targetItem = makeContentItem({ contentID: 200, properties: { versionID: 7 } }); mockGetContentItems.mockReturnValue([targetItem]); const result = await updateContentMappingsAfterPublish([200], SRC, TGT, LOCALE); @@ -120,7 +123,7 @@ describe('updateContentMappingsAfterPublish', () => { expect(result.errors[0]).toMatch(/200/); }); - it('updates the mapping successfully when item and mapping exist', async () => { + it("updates the mapping successfully when item and mapping exist", async () => { seedContentMapper(10, 20, 5); const targetItem = makeContentItem({ contentID: 20, properties: { versionID: 9 } }); mockGetContentItems.mockReturnValue([targetItem]); @@ -130,7 +133,7 @@ describe('updateContentMappingsAfterPublish', () => { expect(result.changes[0].newVersion).toBe(9); }); - it('tracks change.changed as false when versionID is already up to date', async () => { + it("tracks change.changed as false when versionID is already up to date", async () => { seedContentMapper(10, 20, 5); const targetItem = makeContentItem({ contentID: 20, properties: { versionID: 5 } }); mockGetContentItems.mockReturnValue([targetItem]); @@ -141,21 +144,21 @@ describe('updateContentMappingsAfterPublish', () => { // ─── updatePageMappingsAfterPublish ─────────────────────────────────────────── -describe('updatePageMappingsAfterPublish', () => { - it('returns zero updated when publishedPageIds is empty', async () => { +describe("updatePageMappingsAfterPublish", () => { + it("returns zero updated when publishedPageIds is empty", async () => { const result = await updatePageMappingsAfterPublish([], SRC, TGT, LOCALE); expect(result.updated).toBe(0); expect(result.errors).toHaveLength(0); }); - it('records an error when a page is not found in target filesystem', async () => { + it("records an error when a page is not found in target filesystem", async () => { mockGetPages.mockReturnValue([]); const result = await updatePageMappingsAfterPublish([999], SRC, TGT, LOCALE); expect(result.errors.length).toBeGreaterThan(0); expect(result.errors[0]).toMatch(/999/); }); - it('records an error when no mapping exists for a target page ID', async () => { + it("records an error when no mapping exists for a target page ID", async () => { const targetPage = makePage({ pageID: 500, properties: { versionID: 3 } }); mockGetPages.mockReturnValue([targetPage]); const result = await updatePageMappingsAfterPublish([500], SRC, TGT, LOCALE); @@ -163,7 +166,7 @@ describe('updatePageMappingsAfterPublish', () => { expect(result.errors[0]).toMatch(/500/); }); - it('updates the mapping successfully when page and mapping exist', async () => { + it("updates the mapping successfully when page and mapping exist", async () => { seedPageMapper(1, 50, 1); const targetPage = makePage({ pageID: 50, properties: { versionID: 8 } }); mockGetPages.mockReturnValue([targetPage]); @@ -176,16 +179,16 @@ describe('updatePageMappingsAfterPublish', () => { // ─── updateMappingsAfterPublish ─────────────────────────────────────────────── -describe('updateMappingsAfterPublish', () => { - it('returns a result and logLines', async () => { +describe("updateMappingsAfterPublish", () => { + it("returns a result and logLines", async () => { const { result, logLines } = await updateMappingsAfterPublish([], [], SRC, TGT, LOCALE); - expect(result).toHaveProperty('contentMappingsUpdated'); - expect(result).toHaveProperty('pageMappingsUpdated'); - expect(result).toHaveProperty('errors'); + expect(result).toHaveProperty("contentMappingsUpdated"); + expect(result).toHaveProperty("pageMappingsUpdated"); + expect(result).toHaveProperty("errors"); expect(Array.isArray(logLines)).toBe(true); }); - it('processes both content and page IDs', async () => { + it("processes both content and page IDs", async () => { seedContentMapper(10, 20, 1); const targetContent = makeContentItem({ contentID: 20, properties: { versionID: 2 } }); mockGetContentItems.mockReturnValue([targetContent]); @@ -199,7 +202,7 @@ describe('updateMappingsAfterPublish', () => { expect(result.pageMappingsUpdated).toBe(1); }); - it('accumulates errors from both content and page processing', async () => { + it("accumulates errors from both content and page processing", async () => { mockGetContentItems.mockReturnValue([]); mockGetPages.mockReturnValue([]); const { result } = await updateMappingsAfterPublish([999], [888], SRC, TGT, LOCALE); @@ -209,8 +212,8 @@ describe('updateMappingsAfterPublish', () => { // ─── VersionChangeDetail helpers (formatVersionChange is private but we verify its effect via logLines) ─ -describe('VersionChangeDetail structure', () => { - it('includes expected fields in change objects', async () => { +describe("VersionChangeDetail structure", () => { + it("includes expected fields in change objects", async () => { seedContentMapper(10, 20, 5); const targetItem = makeContentItem({ contentID: 20, properties: { versionID: 9 } }); mockGetContentItems.mockReturnValue([targetItem]); diff --git a/src/lib/mappers/tests/model-mapper.test.ts b/src/lib/mappers/tests/model-mapper.test.ts index 33685d3..dcfbe65 100644 --- a/src/lib/mappers/tests/model-mapper.test.ts +++ b/src/lib/mappers/tests/model-mapper.test.ts @@ -1,13 +1,13 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, setState } from 'core/state'; -import { ModelMapper } from 'lib/mappers/model-mapper'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState } from "core/state"; +import { ModelMapper } from "lib/mappers/model-mapper"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-model-mapper-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-model-mapper-")); }); afterAll(() => { @@ -17,9 +17,9 @@ afterAll(() => { beforeEach(() => { resetState(); setState({ rootPath: tmpDir }); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -40,117 +40,117 @@ function makeMapper(): ModelMapper { function makeModel(overrides: Record = {}): any { return { id: 1, - referenceName: 'MyModel', - lastModifiedDate: '2024-01-01T00:00:00Z', + referenceName: "MyModel", + lastModifiedDate: "2024-01-01T00:00:00Z", ...overrides, }; } // ─── constructor ────────────────────────────────────────────────────────────── -describe('ModelMapper constructor', () => { - it('constructs without throwing', () => { +describe("ModelMapper constructor", () => { + it("constructs without throwing", () => { expect(() => makeMapper()).not.toThrow(); }); }); // ─── getModelMapping ────────────────────────────────────────────────────────── -describe('ModelMapper.getModelMapping', () => { - it('returns null when no mapping exists', () => { +describe("ModelMapper.getModelMapping", () => { + it("returns null when no mapping exists", () => { const mapper = makeMapper(); - expect(mapper.getModelMapping(makeModel({ id: 999 }), 'source')).toBeNull(); + expect(mapper.getModelMapping(makeModel({ id: 999 }), "source")).toBeNull(); }); - it('finds mapping by source id after addMapping', () => { + it("finds mapping by source id after addMapping", () => { const mapper = makeMapper(); mapper.addMapping(makeModel({ id: 10 }), makeModel({ id: 20 })); - expect(mapper.getModelMapping(makeModel({ id: 10 }), 'source')).not.toBeNull(); + expect(mapper.getModelMapping(makeModel({ id: 10 }), "source")).not.toBeNull(); }); - it('finds mapping by target id after addMapping', () => { + it("finds mapping by target id after addMapping", () => { const mapper = makeMapper(); mapper.addMapping(makeModel({ id: 10 }), makeModel({ id: 20 })); - const found = mapper.getModelMapping(makeModel({ id: 20 }), 'target'); + const found = mapper.getModelMapping(makeModel({ id: 20 }), "target"); expect(found!.targetID).toBe(20); }); }); // ─── getModelMappingByID ────────────────────────────────────────────────────── -describe('ModelMapper.getModelMappingByID', () => { - it('returns null for unknown ID', () => { +describe("ModelMapper.getModelMappingByID", () => { + it("returns null for unknown ID", () => { const mapper = makeMapper(); - expect(mapper.getModelMappingByID(999, 'source')).toBeNull(); + expect(mapper.getModelMappingByID(999, "source")).toBeNull(); }); - it('returns mapping by source ID', () => { + it("returns mapping by source ID", () => { const mapper = makeMapper(); mapper.addMapping(makeModel({ id: 5 }), makeModel({ id: 6 })); - expect(mapper.getModelMappingByID(5, 'source')).not.toBeNull(); + expect(mapper.getModelMappingByID(5, "source")).not.toBeNull(); }); - it('returns mapping by target ID', () => { + it("returns mapping by target ID", () => { const mapper = makeMapper(); mapper.addMapping(makeModel({ id: 5 }), makeModel({ id: 6 })); - expect(mapper.getModelMappingByID(6, 'target')).not.toBeNull(); + expect(mapper.getModelMappingByID(6, "target")).not.toBeNull(); }); }); // ─── getModelMappingByReferenceName ────────────────────────────────────────── -describe('ModelMapper.getModelMappingByReferenceName', () => { - it('returns null when no mapping exists', () => { +describe("ModelMapper.getModelMappingByReferenceName", () => { + it("returns null when no mapping exists", () => { const mapper = makeMapper(); - expect(mapper.getModelMappingByReferenceName('Unknown', 'source')).toBeNull(); + expect(mapper.getModelMappingByReferenceName("Unknown", "source")).toBeNull(); }); - it('finds by source referenceName (case insensitive)', () => { + it("finds by source referenceName (case insensitive)", () => { const mapper = makeMapper(); mapper.addMapping( - makeModel({ id: 10, referenceName: 'BlogPost' }), - makeModel({ id: 20, referenceName: 'BlogPostTarget' }), + makeModel({ id: 10, referenceName: "BlogPost" }), + makeModel({ id: 20, referenceName: "BlogPostTarget" }) ); - expect(mapper.getModelMappingByReferenceName('blogpost', 'source')).not.toBeNull(); - expect(mapper.getModelMappingByReferenceName('BLOGPOST', 'source')).not.toBeNull(); + expect(mapper.getModelMappingByReferenceName("blogpost", "source")).not.toBeNull(); + expect(mapper.getModelMappingByReferenceName("BLOGPOST", "source")).not.toBeNull(); }); - it('finds by target referenceName (case insensitive)', () => { + it("finds by target referenceName (case insensitive)", () => { const mapper = makeMapper(); mapper.addMapping( - makeModel({ id: 10, referenceName: 'BlogPost' }), - makeModel({ id: 20, referenceName: 'BlogPostTarget' }), + makeModel({ id: 10, referenceName: "BlogPost" }), + makeModel({ id: 20, referenceName: "BlogPostTarget" }) ); - expect(mapper.getModelMappingByReferenceName('blogposttarget', 'target')).not.toBeNull(); + expect(mapper.getModelMappingByReferenceName("blogposttarget", "target")).not.toBeNull(); }); }); // ─── getMappedEntity ────────────────────────────────────────────────────────── -describe('ModelMapper.getMappedEntity', () => { - it('returns null when mapping is null', () => { +describe("ModelMapper.getMappedEntity", () => { + it("returns null when mapping is null", () => { const mapper = makeMapper(); - expect(mapper.getMappedEntity(null as any, 'source')).toBeNull(); + expect(mapper.getMappedEntity(null as any, "source")).toBeNull(); }); - it('returns null when the model file does not exist on disk', () => { + it("returns null when the model file does not exist on disk", () => { const mapper = makeMapper(); mapper.addMapping(makeModel({ id: 10 }), makeModel({ id: 20 })); - const mapping = mapper.getModelMappingByID(20, 'target')!; - expect(mapper.getMappedEntity(mapping, 'target')).toBeNull(); + const mapping = mapper.getModelMappingByID(20, "target")!; + expect(mapper.getMappedEntity(mapping, "target")).toBeNull(); }); - it('returns model data when the file exists', () => { + it("returns model data when the file exists", () => { const mapper = makeMapper(); mapper.addMapping(makeModel({ id: 10 }), makeModel({ id: 20 })); - const mapping = mapper.getModelMappingByID(20, 'target')!; + const mapping = mapper.getModelMappingByID(20, "target")!; - const modelDir = path.join(tmpDir, currentTgt, 'models'); + const modelDir = path.join(tmpDir, currentTgt, "models"); fs.mkdirSync(modelDir, { recursive: true }); - const modelData = { id: 20, referenceName: 'MyModel', lastModifiedDate: '2024-01-01T00:00:00Z' }; - fs.writeFileSync(path.join(modelDir, '20.json'), JSON.stringify(modelData)); + const modelData = { id: 20, referenceName: "MyModel", lastModifiedDate: "2024-01-01T00:00:00Z" }; + fs.writeFileSync(path.join(modelDir, "20.json"), JSON.stringify(modelData)); - const result = mapper.getMappedEntity(mapping, 'target'); + const result = mapper.getMappedEntity(mapping, "target"); expect(result).not.toBeNull(); expect((result as any).id).toBe(20); }); @@ -158,78 +158,75 @@ describe('ModelMapper.getMappedEntity', () => { // ─── addMapping / updateMapping ─────────────────────────────────────────────── -describe('ModelMapper.addMapping', () => { - it('adds a new mapping', () => { +describe("ModelMapper.addMapping", () => { + it("adds a new mapping", () => { const mapper = makeMapper(); mapper.addMapping(makeModel({ id: 10 }), makeModel({ id: 20 })); - expect(mapper.getModelMappingByID(20, 'target')).not.toBeNull(); + expect(mapper.getModelMappingByID(20, "target")).not.toBeNull(); }); - it('updates existing mapping when target already exists', () => { + it("updates existing mapping when target already exists", () => { const mapper = makeMapper(); const tgt = makeModel({ id: 20 }); - mapper.addMapping(makeModel({ id: 10, referenceName: 'OldModel' }), tgt); - mapper.addMapping(makeModel({ id: 11, referenceName: 'NewModel' }), tgt); - const found = mapper.getModelMappingByID(20, 'target')!; + mapper.addMapping(makeModel({ id: 10, referenceName: "OldModel" }), tgt); + mapper.addMapping(makeModel({ id: 11, referenceName: "NewModel" }), tgt); + const found = mapper.getModelMappingByID(20, "target")!; expect(found.sourceID).toBe(11); - expect(found.sourceReferenceName).toBe('NewModel'); + expect(found.sourceReferenceName).toBe("NewModel"); }); }); // ─── hasSourceChanged ───────────────────────────────────────────────────────── -describe('ModelMapper.hasSourceChanged', () => { - it('returns false when sourceModel is null', () => { +describe("ModelMapper.hasSourceChanged", () => { + it("returns false when sourceModel is null", () => { const mapper = makeMapper(); expect(mapper.hasSourceChanged(null)).toBe(false); }); - it('returns false when no mapping exists', () => { + it("returns false when no mapping exists", () => { const mapper = makeMapper(); expect(mapper.hasSourceChanged(makeModel({ id: 999 }))).toBe(false); }); - it('returns false when date has not changed', () => { + it("returns false when date has not changed", () => { const mapper = makeMapper(); - const date = '2024-01-01T00:00:00Z'; + const date = "2024-01-01T00:00:00Z"; const src = makeModel({ id: 10, lastModifiedDate: date }); mapper.addMapping(src, makeModel({ id: 20 })); expect(mapper.hasSourceChanged(makeModel({ id: 10, lastModifiedDate: date }))).toBe(false); }); - it('returns true when source date is newer than mapped date', () => { + it("returns true when source date is newer than mapped date", () => { const mapper = makeMapper(); - mapper.addMapping( - makeModel({ id: 10, lastModifiedDate: '2024-01-01T00:00:00Z' }), - makeModel({ id: 20 }), - ); - expect(mapper.hasSourceChanged(makeModel({ id: 10, lastModifiedDate: '2025-06-01T00:00:00Z' }))).toBe(true); + mapper.addMapping(makeModel({ id: 10, lastModifiedDate: "2024-01-01T00:00:00Z" }), makeModel({ id: 20 })); + expect(mapper.hasSourceChanged(makeModel({ id: 10, lastModifiedDate: "2025-06-01T00:00:00Z" }))).toBe(true); }); }); // ─── hasTargetChanged ───────────────────────────────────────────────────────── -describe('ModelMapper.hasTargetChanged', () => { - it('returns false when targetModel is null', () => { +describe("ModelMapper.hasTargetChanged", () => { + it("returns false when targetModel is null", () => { const mapper = makeMapper(); expect(mapper.hasTargetChanged(null)).toBe(false); }); - it('returns false when no mapping exists', () => { + it("returns false when no mapping exists", () => { const mapper = makeMapper(); expect(mapper.hasTargetChanged(makeModel({ id: 999 }))).toBe(false); }); - it('returns false when date has not changed', () => { + it("returns false when date has not changed", () => { const mapper = makeMapper(); - const date = '2024-03-01T00:00:00Z'; + const date = "2024-03-01T00:00:00Z"; mapper.addMapping(makeModel({ id: 10 }), makeModel({ id: 20, lastModifiedDate: date })); expect(mapper.hasTargetChanged(makeModel({ id: 20, lastModifiedDate: date }))).toBe(false); }); - it('returns true when target date is newer than mapped date', () => { + it("returns true when target date is newer than mapped date", () => { const mapper = makeMapper(); - mapper.addMapping(makeModel({ id: 10 }), makeModel({ id: 20, lastModifiedDate: '2024-01-01T00:00:00Z' })); - expect(mapper.hasTargetChanged(makeModel({ id: 20, lastModifiedDate: '2025-12-01T00:00:00Z' }))).toBe(true); + mapper.addMapping(makeModel({ id: 10 }), makeModel({ id: 20, lastModifiedDate: "2024-01-01T00:00:00Z" })); + expect(mapper.hasTargetChanged(makeModel({ id: 20, lastModifiedDate: "2025-12-01T00:00:00Z" }))).toBe(true); }); }); diff --git a/src/lib/mappers/tests/page-mapper.test.ts b/src/lib/mappers/tests/page-mapper.test.ts index 917d7d3..0c414ac 100644 --- a/src/lib/mappers/tests/page-mapper.test.ts +++ b/src/lib/mappers/tests/page-mapper.test.ts @@ -1,13 +1,13 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, setState } from 'core/state'; -import { PageMapper } from 'lib/mappers/page-mapper'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState } from "core/state"; +import { PageMapper } from "lib/mappers/page-mapper"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-page-mapper-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-page-mapper-")); }); afterAll(() => { @@ -17,9 +17,9 @@ afterAll(() => { beforeEach(() => { resetState(); setState({ rootPath: tmpDir }); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -27,7 +27,7 @@ afterEach(() => { }); let testCounter = 0; -const LOCALE = 'en-us'; +const LOCALE = "en-us"; let currentSrc: string; let currentTgt: string; @@ -41,9 +41,9 @@ function makeMapper(): PageMapper { function makePage(overrides: Record = {}): any { return { pageID: 10, - title: 'Home', - name: 'home', - templateName: 'OneCol', + title: "Home", + name: "home", + templateName: "OneCol", properties: { versionID: 1 }, ...overrides, }; @@ -51,108 +51,108 @@ function makePage(overrides: Record = {}): any { // ─── constructor ────────────────────────────────────────────────────────────── -describe('PageMapper constructor', () => { - it('constructs without throwing', () => { +describe("PageMapper constructor", () => { + it("constructs without throwing", () => { expect(() => makeMapper()).not.toThrow(); }); }); // ─── getPageMapping ─────────────────────────────────────────────────────────── -describe('PageMapper.getPageMapping', () => { - it('returns null when no mapping exists', () => { +describe("PageMapper.getPageMapping", () => { + it("returns null when no mapping exists", () => { const mapper = makeMapper(); - expect(mapper.getPageMapping(makePage({ pageID: 999 }), 'source')).toBeNull(); + expect(mapper.getPageMapping(makePage({ pageID: 999 }), "source")).toBeNull(); }); - it('finds mapping by source pageID', () => { + it("finds mapping by source pageID", () => { const mapper = makeMapper(); mapper.addMapping(makePage({ pageID: 10 }), makePage({ pageID: 20 })); - expect(mapper.getPageMapping(makePage({ pageID: 10 }), 'source')).not.toBeNull(); + expect(mapper.getPageMapping(makePage({ pageID: 10 }), "source")).not.toBeNull(); }); - it('finds mapping by target pageID', () => { + it("finds mapping by target pageID", () => { const mapper = makeMapper(); mapper.addMapping(makePage({ pageID: 10 }), makePage({ pageID: 20 })); - const found = mapper.getPageMapping(makePage({ pageID: 20 }), 'target'); + const found = mapper.getPageMapping(makePage({ pageID: 20 }), "target"); expect(found!.targetPageID).toBe(20); }); }); // ─── getPageMappingByPageID ─────────────────────────────────────────────────── -describe('PageMapper.getPageMappingByPageID', () => { - it('returns null for unknown ID', () => { +describe("PageMapper.getPageMappingByPageID", () => { + it("returns null for unknown ID", () => { const mapper = makeMapper(); - expect(mapper.getPageMappingByPageID(999, 'source')).toBeNull(); + expect(mapper.getPageMappingByPageID(999, "source")).toBeNull(); }); - it('returns mapping by source pageID', () => { + it("returns mapping by source pageID", () => { const mapper = makeMapper(); mapper.addMapping(makePage({ pageID: 5 }), makePage({ pageID: 6 })); - expect(mapper.getPageMappingByPageID(5, 'source')).not.toBeNull(); + expect(mapper.getPageMappingByPageID(5, "source")).not.toBeNull(); }); - it('returns mapping by target pageID', () => { + it("returns mapping by target pageID", () => { const mapper = makeMapper(); mapper.addMapping(makePage({ pageID: 5 }), makePage({ pageID: 6 })); - expect(mapper.getPageMappingByPageID(6, 'target')).not.toBeNull(); + expect(mapper.getPageMappingByPageID(6, "target")).not.toBeNull(); }); }); // ─── getPageMappingByPageTemplateName ──────────────────────────────────────── -describe('PageMapper.getPageMappingByPageTemplateName', () => { - it('returns null when no mapping exists', () => { +describe("PageMapper.getPageMappingByPageTemplateName", () => { + it("returns null when no mapping exists", () => { const mapper = makeMapper(); - expect(mapper.getPageMappingByPageTemplateName('Unknown', 'source')).toBeNull(); + expect(mapper.getPageMappingByPageTemplateName("Unknown", "source")).toBeNull(); }); - it('finds by source templateName (exact match)', () => { + it("finds by source templateName (exact match)", () => { const mapper = makeMapper(); mapper.addMapping( - makePage({ pageID: 10, templateName: 'TwoCol' }), - makePage({ pageID: 20, templateName: 'TwoColTarget' }), + makePage({ pageID: 10, templateName: "TwoCol" }), + makePage({ pageID: 20, templateName: "TwoColTarget" }) ); - expect(mapper.getPageMappingByPageTemplateName('TwoCol', 'source')).not.toBeNull(); + expect(mapper.getPageMappingByPageTemplateName("TwoCol", "source")).not.toBeNull(); }); - it('finds by target templateName', () => { + it("finds by target templateName", () => { const mapper = makeMapper(); mapper.addMapping( - makePage({ pageID: 10, templateName: 'TwoCol' }), - makePage({ pageID: 20, templateName: 'TwoColTarget' }), + makePage({ pageID: 10, templateName: "TwoCol" }), + makePage({ pageID: 20, templateName: "TwoColTarget" }) ); - expect(mapper.getPageMappingByPageTemplateName('TwoColTarget', 'target')).not.toBeNull(); + expect(mapper.getPageMappingByPageTemplateName("TwoColTarget", "target")).not.toBeNull(); }); }); // ─── getMappedEntity ────────────────────────────────────────────────────────── -describe('PageMapper.getMappedEntity', () => { - it('returns null when mapping is null', () => { +describe("PageMapper.getMappedEntity", () => { + it("returns null when mapping is null", () => { const mapper = makeMapper(); - expect(mapper.getMappedEntity(null as any, 'source')).toBeNull(); + expect(mapper.getMappedEntity(null as any, "source")).toBeNull(); }); - it('returns null when the page file does not exist', () => { + it("returns null when the page file does not exist", () => { const mapper = makeMapper(); mapper.addMapping(makePage({ pageID: 10 }), makePage({ pageID: 20 })); - const mapping = mapper.getPageMappingByPageID(20, 'target')!; - expect(mapper.getMappedEntity(mapping, 'target')).toBeNull(); + const mapping = mapper.getPageMappingByPageID(20, "target")!; + expect(mapper.getMappedEntity(mapping, "target")).toBeNull(); }); - it('returns the page when the file exists', () => { + it("returns the page when the file exists", () => { const mapper = makeMapper(); mapper.addMapping(makePage({ pageID: 10 }), makePage({ pageID: 20 })); - const mapping = mapper.getPageMappingByPageID(20, 'target')!; + const mapping = mapper.getPageMappingByPageID(20, "target")!; - const pageDir = path.join(tmpDir, currentTgt, LOCALE, 'page'); + const pageDir = path.join(tmpDir, currentTgt, LOCALE, "page"); fs.mkdirSync(pageDir, { recursive: true }); - const pageData = { pageID: 20, title: 'Home', name: 'home', templateName: 'OneCol', properties: { versionID: 1 } }; - fs.writeFileSync(path.join(pageDir, '20.json'), JSON.stringify(pageData)); + const pageData = { pageID: 20, title: "Home", name: "home", templateName: "OneCol", properties: { versionID: 1 } }; + fs.writeFileSync(path.join(pageDir, "20.json"), JSON.stringify(pageData)); - const result = mapper.getMappedEntity(mapping, 'target'); + const result = mapper.getMappedEntity(mapping, "target"); expect(result).not.toBeNull(); expect((result as any).pageID).toBe(20); }); @@ -160,19 +160,19 @@ describe('PageMapper.getMappedEntity', () => { // ─── addMapping / updateMapping ─────────────────────────────────────────────── -describe('PageMapper.addMapping', () => { - it('adds a new mapping', () => { +describe("PageMapper.addMapping", () => { + it("adds a new mapping", () => { const mapper = makeMapper(); mapper.addMapping(makePage({ pageID: 10 }), makePage({ pageID: 20 })); - expect(mapper.getPageMappingByPageID(20, 'target')).not.toBeNull(); + expect(mapper.getPageMappingByPageID(20, "target")).not.toBeNull(); }); - it('updates existing mapping when target pageID already exists', () => { + it("updates existing mapping when target pageID already exists", () => { const mapper = makeMapper(); const tgt = makePage({ pageID: 20, properties: { versionID: 1 } }); mapper.addMapping(makePage({ pageID: 10, properties: { versionID: 1 } }), tgt); mapper.addMapping(makePage({ pageID: 11, properties: { versionID: 2 } }), tgt); - const found = mapper.getPageMappingByPageID(20, 'target')!; + const found = mapper.getPageMappingByPageID(20, "target")!; expect(found.sourcePageID).toBe(11); expect(found.sourceVersionID).toBe(2); }); @@ -180,70 +180,67 @@ describe('PageMapper.addMapping', () => { // ─── hasSourceChanged ───────────────────────────────────────────────────────── -describe('PageMapper.hasSourceChanged', () => { - it('returns true when no mapping exists (treat as new/changed)', () => { +describe("PageMapper.hasSourceChanged", () => { + it("returns true when no mapping exists (treat as new/changed)", () => { const mapper = makeMapper(); expect(mapper.hasSourceChanged(makePage({ pageID: 999, properties: { versionID: 1 } }))).toBe(true); }); - it('returns false when versionID matches', () => { + it("returns false when versionID matches", () => { const mapper = makeMapper(); const src = makePage({ pageID: 10, properties: { versionID: 5 } }); mapper.addMapping(src, makePage({ pageID: 20 })); expect(mapper.hasSourceChanged(makePage({ pageID: 10, properties: { versionID: 5 } }))).toBe(false); }); - it('returns true when source versionID is greater than mapped', () => { + it("returns true when source versionID is greater than mapped", () => { const mapper = makeMapper(); - mapper.addMapping( - makePage({ pageID: 10, properties: { versionID: 3 } }), - makePage({ pageID: 20 }), - ); + mapper.addMapping(makePage({ pageID: 10, properties: { versionID: 3 } }), makePage({ pageID: 20 })); expect(mapper.hasSourceChanged(makePage({ pageID: 10, properties: { versionID: 7 } }))).toBe(true); }); }); // ─── hasTargetChanged ───────────────────────────────────────────────────────── -describe('PageMapper.hasTargetChanged', () => { - it('returns null when mapping is null', () => { +describe("PageMapper.hasTargetChanged", () => { + it("returns null when mapping is null", () => { const mapper = makeMapper(); expect(mapper.hasTargetChanged(makePage(), null)).toBeNull(); }); - it('returns file_missing when targetPage is null but mapping exists', () => { + it("returns file_missing when targetPage is null but mapping exists", () => { const mapper = makeMapper(); mapper.addMapping(makePage({ pageID: 10 }), makePage({ pageID: 20 })); - const mapping = mapper.getPageMappingByPageID(20, 'target')!; - expect(mapper.hasTargetChanged(null, mapping)).toBe('file_missing'); + const mapping = mapper.getPageMappingByPageID(20, "target")!; + expect(mapper.hasTargetChanged(null, mapping)).toBe("file_missing"); }); - it('returns null when targetPage versionID equals mapping versionID', () => { + it("returns null when targetPage versionID equals mapping versionID", () => { const mapper = makeMapper(); mapper.addMapping(makePage({ pageID: 10 }), makePage({ pageID: 20, properties: { versionID: 5 } })); - const mapping = mapper.getPageMappingByPageID(20, 'target')!; + const mapping = mapper.getPageMappingByPageID(20, "target")!; const targetPage = makePage({ pageID: 20, properties: { versionID: 5 } }); expect(mapper.hasTargetChanged(targetPage, mapping)).toBeNull(); }); - it('returns version_changed when targetPage versionID is greater than mapping', () => { + it("returns version_changed when targetPage versionID is greater than mapping", () => { const mapper = makeMapper(); mapper.addMapping(makePage({ pageID: 10 }), makePage({ pageID: 20, properties: { versionID: 5 } })); - const mapping = mapper.getPageMappingByPageID(20, 'target')!; + const mapping = mapper.getPageMappingByPageID(20, "target")!; const targetPage = makePage({ pageID: 20, properties: { versionID: 10 } }); - expect(mapper.hasTargetChanged(targetPage, mapping)).toBe('version_changed'); + expect(mapper.hasTargetChanged(targetPage, mapping)).toBe("version_changed"); }); }); // ─── updateTargetVersionID ──────────────────────────────────────────────────── -describe('PageMapper.updateTargetVersionID', () => { - it('returns success:false when no mapping exists', () => { +describe("PageMapper.updateTargetVersionID", () => { + it("returns success:false when no mapping exists", () => { const mapper = makeMapper(); expect(mapper.updateTargetVersionID(999, 42)).toEqual({ success: false }); }); - it('returns success:true with old and new versionIDs', () => { + it("returns success:true with old and new versionIDs", () => { const mapper = makeMapper(); mapper.addMapping(makePage({ pageID: 10 }), makePage({ pageID: 20, properties: { versionID: 5 } })); const result = mapper.updateTargetVersionID(20, 10); @@ -252,18 +249,18 @@ describe('PageMapper.updateTargetVersionID', () => { expect(result.newVersionID).toBe(10); }); - it('does not call saveMapping when versionID is unchanged', () => { + it("does not call saveMapping when versionID is unchanged", () => { const mapper = makeMapper(); mapper.addMapping(makePage({ pageID: 10 }), makePage({ pageID: 20, properties: { versionID: 5 } })); - const saveSpy = jest.spyOn(mapper as any, 'saveMapping'); + const saveSpy = jest.spyOn(mapper as any, "saveMapping"); mapper.updateTargetVersionID(20, 5); expect(saveSpy).not.toHaveBeenCalled(); }); - it('calls saveMapping when versionID changes', () => { + it("calls saveMapping when versionID changes", () => { const mapper = makeMapper(); mapper.addMapping(makePage({ pageID: 10 }), makePage({ pageID: 20, properties: { versionID: 5 } })); - const saveSpy = jest.spyOn(mapper as any, 'saveMapping'); + const saveSpy = jest.spyOn(mapper as any, "saveMapping"); mapper.updateTargetVersionID(20, 99); expect(saveSpy).toHaveBeenCalledTimes(1); }); diff --git a/src/lib/mappers/tests/template-mapper.test.ts b/src/lib/mappers/tests/template-mapper.test.ts index e51fc67..85e98c8 100644 --- a/src/lib/mappers/tests/template-mapper.test.ts +++ b/src/lib/mappers/tests/template-mapper.test.ts @@ -1,13 +1,13 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, setState } from 'core/state'; -import { TemplateMapper } from 'lib/mappers/template-mapper'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState } from "core/state"; +import { TemplateMapper } from "lib/mappers/template-mapper"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-template-mapper-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-template-mapper-")); }); afterAll(() => { @@ -17,9 +17,9 @@ afterAll(() => { beforeEach(() => { resetState(); setState({ rootPath: tmpDir }); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -40,129 +40,129 @@ function makeMapper(): TemplateMapper { function makeTemplate(overrides: Record = {}): any { return { pageTemplateID: 1, - pageTemplateName: 'OneCol', + pageTemplateName: "OneCol", ...overrides, }; } // ─── constructor ────────────────────────────────────────────────────────────── -describe('TemplateMapper constructor', () => { - it('constructs without throwing', () => { +describe("TemplateMapper constructor", () => { + it("constructs without throwing", () => { expect(() => makeMapper()).not.toThrow(); }); }); // ─── getTemplateMapping ─────────────────────────────────────────────────────── -describe('TemplateMapper.getTemplateMapping', () => { - it('returns null when template is null', () => { +describe("TemplateMapper.getTemplateMapping", () => { + it("returns null when template is null", () => { const mapper = makeMapper(); - expect(mapper.getTemplateMapping(null as any, 'source')).toBeNull(); + expect(mapper.getTemplateMapping(null as any, "source")).toBeNull(); }); - it('returns null when no mapping exists for the template', () => { + it("returns null when no mapping exists for the template", () => { const mapper = makeMapper(); - expect(mapper.getTemplateMapping(makeTemplate({ pageTemplateID: 999 }), 'source')).toBeNull(); + expect(mapper.getTemplateMapping(makeTemplate({ pageTemplateID: 999 }), "source")).toBeNull(); }); - it('finds mapping by source pageTemplateID', () => { + it("finds mapping by source pageTemplateID", () => { const mapper = makeMapper(); mapper.addMapping(makeTemplate({ pageTemplateID: 10 }), makeTemplate({ pageTemplateID: 20 })); - expect(mapper.getTemplateMapping(makeTemplate({ pageTemplateID: 10 }), 'source')).not.toBeNull(); + expect(mapper.getTemplateMapping(makeTemplate({ pageTemplateID: 10 }), "source")).not.toBeNull(); }); - it('finds mapping by target pageTemplateID', () => { + it("finds mapping by target pageTemplateID", () => { const mapper = makeMapper(); mapper.addMapping(makeTemplate({ pageTemplateID: 10 }), makeTemplate({ pageTemplateID: 20 })); - const found = mapper.getTemplateMapping(makeTemplate({ pageTemplateID: 20 }), 'target'); + const found = mapper.getTemplateMapping(makeTemplate({ pageTemplateID: 20 }), "target"); expect(found!.targetPageTemplateID).toBe(20); }); }); // ─── getTemplateMappingByPageTemplateID ─────────────────────────────────────── -describe('TemplateMapper.getTemplateMappingByPageTemplateID', () => { - it('returns null for unknown ID', () => { +describe("TemplateMapper.getTemplateMappingByPageTemplateID", () => { + it("returns null for unknown ID", () => { const mapper = makeMapper(); - expect(mapper.getTemplateMappingByPageTemplateID(999, 'source')).toBeNull(); + expect(mapper.getTemplateMappingByPageTemplateID(999, "source")).toBeNull(); }); - it('returns mapping by source pageTemplateID', () => { + it("returns mapping by source pageTemplateID", () => { const mapper = makeMapper(); mapper.addMapping(makeTemplate({ pageTemplateID: 5 }), makeTemplate({ pageTemplateID: 6 })); - expect(mapper.getTemplateMappingByPageTemplateID(5, 'source')).not.toBeNull(); + expect(mapper.getTemplateMappingByPageTemplateID(5, "source")).not.toBeNull(); }); - it('returns mapping by target pageTemplateID', () => { + it("returns mapping by target pageTemplateID", () => { const mapper = makeMapper(); mapper.addMapping(makeTemplate({ pageTemplateID: 5 }), makeTemplate({ pageTemplateID: 6 })); - expect(mapper.getTemplateMappingByPageTemplateID(6, 'target')).not.toBeNull(); + expect(mapper.getTemplateMappingByPageTemplateID(6, "target")).not.toBeNull(); }); }); // ─── getTemplateMappingByPageTemplateName ───────────────────────────────────── -describe('TemplateMapper.getTemplateMappingByPageTemplateName', () => { - it('returns null when no mapping exists', () => { +describe("TemplateMapper.getTemplateMappingByPageTemplateName", () => { + it("returns null when no mapping exists", () => { const mapper = makeMapper(); - expect(mapper.getTemplateMappingByPageTemplateName('Unknown', 'source')).toBeNull(); + expect(mapper.getTemplateMappingByPageTemplateName("Unknown", "source")).toBeNull(); }); - it('finds by source pageTemplateName', () => { + it("finds by source pageTemplateName", () => { const mapper = makeMapper(); mapper.addMapping( - makeTemplate({ pageTemplateID: 10, pageTemplateName: 'TwoCol' }), - makeTemplate({ pageTemplateID: 20, pageTemplateName: 'TwoColTarget' }), + makeTemplate({ pageTemplateID: 10, pageTemplateName: "TwoCol" }), + makeTemplate({ pageTemplateID: 20, pageTemplateName: "TwoColTarget" }) ); - expect(mapper.getTemplateMappingByPageTemplateName('TwoCol', 'source')).not.toBeNull(); + expect(mapper.getTemplateMappingByPageTemplateName("TwoCol", "source")).not.toBeNull(); }); - it('finds by target pageTemplateName', () => { + it("finds by target pageTemplateName", () => { const mapper = makeMapper(); mapper.addMapping( - makeTemplate({ pageTemplateID: 10, pageTemplateName: 'TwoCol' }), - makeTemplate({ pageTemplateID: 20, pageTemplateName: 'TwoColTarget' }), + makeTemplate({ pageTemplateID: 10, pageTemplateName: "TwoCol" }), + makeTemplate({ pageTemplateID: 20, pageTemplateName: "TwoColTarget" }) ); - expect(mapper.getTemplateMappingByPageTemplateName('TwoColTarget', 'target')).not.toBeNull(); + expect(mapper.getTemplateMappingByPageTemplateName("TwoColTarget", "target")).not.toBeNull(); }); - it('returns null for a name that does not match any mapping', () => { + it("returns null for a name that does not match any mapping", () => { const mapper = makeMapper(); mapper.addMapping( - makeTemplate({ pageTemplateID: 10, pageTemplateName: 'TwoCol' }), - makeTemplate({ pageTemplateID: 20, pageTemplateName: 'TwoColTarget' }), + makeTemplate({ pageTemplateID: 10, pageTemplateName: "TwoCol" }), + makeTemplate({ pageTemplateID: 20, pageTemplateName: "TwoColTarget" }) ); - expect(mapper.getTemplateMappingByPageTemplateName('ThreeCol', 'source')).toBeNull(); + expect(mapper.getTemplateMappingByPageTemplateName("ThreeCol", "source")).toBeNull(); }); }); // ─── getMappedEntity ────────────────────────────────────────────────────────── -describe('TemplateMapper.getMappedEntity', () => { - it('returns null when mapping is null', () => { +describe("TemplateMapper.getMappedEntity", () => { + it("returns null when mapping is null", () => { const mapper = makeMapper(); - expect(mapper.getMappedEntity(null as any, 'source')).toBeNull(); + expect(mapper.getMappedEntity(null as any, "source")).toBeNull(); }); - it('returns null when the template file does not exist', () => { + it("returns null when the template file does not exist", () => { const mapper = makeMapper(); mapper.addMapping(makeTemplate({ pageTemplateID: 10 }), makeTemplate({ pageTemplateID: 20 })); - const mapping = mapper.getTemplateMappingByPageTemplateID(20, 'target')!; - expect(mapper.getMappedEntity(mapping, 'target')).toBeNull(); + const mapping = mapper.getTemplateMappingByPageTemplateID(20, "target")!; + expect(mapper.getMappedEntity(mapping, "target")).toBeNull(); }); - it('returns template data when the file exists', () => { + it("returns template data when the file exists", () => { const mapper = makeMapper(); mapper.addMapping(makeTemplate({ pageTemplateID: 10 }), makeTemplate({ pageTemplateID: 20 })); - const mapping = mapper.getTemplateMappingByPageTemplateID(20, 'target')!; + const mapping = mapper.getTemplateMappingByPageTemplateID(20, "target")!; - const tplDir = path.join(tmpDir, currentTgt, 'templates'); + const tplDir = path.join(tmpDir, currentTgt, "templates"); fs.mkdirSync(tplDir, { recursive: true }); - const tplData = { pageTemplateID: 20, pageTemplateName: 'OneCol' }; - fs.writeFileSync(path.join(tplDir, '20.json'), JSON.stringify(tplData)); + const tplData = { pageTemplateID: 20, pageTemplateName: "OneCol" }; + fs.writeFileSync(path.join(tplDir, "20.json"), JSON.stringify(tplData)); - const result = mapper.getMappedEntity(mapping, 'target'); + const result = mapper.getMappedEntity(mapping, "target"); expect(result).not.toBeNull(); expect((result as any).pageTemplateID).toBe(20); }); @@ -170,38 +170,38 @@ describe('TemplateMapper.getMappedEntity', () => { // ─── addMapping / updateMapping ─────────────────────────────────────────────── -describe('TemplateMapper.addMapping', () => { - it('adds a new mapping', () => { +describe("TemplateMapper.addMapping", () => { + it("adds a new mapping", () => { const mapper = makeMapper(); mapper.addMapping(makeTemplate({ pageTemplateID: 10 }), makeTemplate({ pageTemplateID: 20 })); - expect(mapper.getTemplateMappingByPageTemplateID(20, 'target')).not.toBeNull(); + expect(mapper.getTemplateMappingByPageTemplateID(20, "target")).not.toBeNull(); }); - it('updates existing mapping when target already exists', () => { + it("updates existing mapping when target already exists", () => { const mapper = makeMapper(); const tgt = makeTemplate({ pageTemplateID: 20 }); - mapper.addMapping(makeTemplate({ pageTemplateID: 10, pageTemplateName: 'OldTpl' }), tgt); - mapper.addMapping(makeTemplate({ pageTemplateID: 11, pageTemplateName: 'NewTpl' }), tgt); - const found = mapper.getTemplateMappingByPageTemplateID(20, 'target')!; + mapper.addMapping(makeTemplate({ pageTemplateID: 10, pageTemplateName: "OldTpl" }), tgt); + mapper.addMapping(makeTemplate({ pageTemplateID: 11, pageTemplateName: "NewTpl" }), tgt); + const found = mapper.getTemplateMappingByPageTemplateID(20, "target")!; expect(found.sourcePageTemplateID).toBe(11); - expect(found.sourcePageTemplateName).toBe('NewTpl'); + expect(found.sourcePageTemplateName).toBe("NewTpl"); }); }); // ─── hasTargetChanged ──────────────────────────────────────────────────────── -describe('TemplateMapper.hasTargetChanged', () => { - it('returns false when template is null/falsy', () => { +describe("TemplateMapper.hasTargetChanged", () => { + it("returns false when template is null/falsy", () => { const mapper = makeMapper(); expect(mapper.hasTargetChanged(null as any)).toBe(false); }); - it('returns false when no mapping exists', () => { + it("returns false when no mapping exists", () => { const mapper = makeMapper(); expect(mapper.hasTargetChanged(makeTemplate({ pageTemplateID: 999 }))).toBe(false); }); - it('returns false when pageTemplateID is unchanged', () => { + it("returns false when pageTemplateID is unchanged", () => { const mapper = makeMapper(); mapper.addMapping(makeTemplate({ pageTemplateID: 10 }), makeTemplate({ pageTemplateID: 20 })); expect(mapper.hasTargetChanged(makeTemplate({ pageTemplateID: 20 }))).toBe(false); @@ -210,13 +210,13 @@ describe('TemplateMapper.hasTargetChanged', () => { // ─── hasSourceChanged ──────────────────────────────────────────────────────── -describe('TemplateMapper.hasSourceChanged', () => { - it('returns false when no mapping exists', () => { +describe("TemplateMapper.hasSourceChanged", () => { + it("returns false when no mapping exists", () => { const mapper = makeMapper(); expect(mapper.hasSourceChanged(makeTemplate({ pageTemplateID: 999 }))).toBe(false); }); - it('returns false when pageTemplateID is unchanged', () => { + it("returns false when pageTemplateID is unchanged", () => { const mapper = makeMapper(); mapper.addMapping(makeTemplate({ pageTemplateID: 10 }), makeTemplate({ pageTemplateID: 20 })); expect(mapper.hasSourceChanged(makeTemplate({ pageTemplateID: 10 }))).toBe(false); diff --git a/src/lib/models/model-dependency-tree-builder.ts b/src/lib/models/model-dependency-tree-builder.ts index 4c27b58..6e217be 100644 --- a/src/lib/models/model-dependency-tree-builder.ts +++ b/src/lib/models/model-dependency-tree-builder.ts @@ -8,26 +8,26 @@ * Phase 21: Selective Model-Based Sync Implementation */ -import { SourceData } from '../../types/sourceData'; -import ansiColors from 'ansi-colors'; -import { SitemapHierarchy } from '../pushers/page-pusher/sitemap-hierarchy'; -import { AssetReferenceExtractor } from '../assets/asset-reference-extractor'; +import { SourceData } from "../../types/sourceData"; +import ansiColors from "ansi-colors"; +import { SitemapHierarchy } from "../pushers/page-pusher/sitemap-hierarchy"; +import { AssetReferenceExtractor } from "../assets/asset-reference-extractor"; export interface ModelDependencyTree { - models: Set; // Model reference names + models: Set; // Model reference names containers: Set; - lists: Set; // Container IDs using these models - content: Set; // Content item IDs of these models - templates: Set; // Template IDs using these containers - pages: Set; // Page IDs using these templates/content - assets: Set; // Asset URLs referenced in content/pages - galleries: Set; // Gallery IDs referenced in content/pages + lists: Set; // Container IDs using these models + content: Set; // Content item IDs of these models + templates: Set; // Template IDs using these containers + pages: Set; // Page IDs using these templates/content + assets: Set; // Asset URLs referenced in content/pages + galleries: Set; // Gallery IDs referenced in content/pages } export class ModelDependencyTreeBuilder { private static hasLoggedBreakdown = false; private assetExtractor: AssetReferenceExtractor; - + constructor(private sourceData: SourceData) { this.assetExtractor = new AssetReferenceExtractor(); } @@ -44,7 +44,7 @@ export class ModelDependencyTreeBuilder { */ buildDependencyTree(modelNames: string[], channel: string): ModelDependencyTree { if (!modelNames || modelNames.length === 0) { - throw new Error('Model names are required for dependency tree building'); + throw new Error("Model names are required for dependency tree building"); } // console.log(ansiColors.cyan(`🌳 Building dependency tree for models: ${modelNames.join(', ')}`)); @@ -57,7 +57,7 @@ export class ModelDependencyTreeBuilder { templates: new Set(), pages: new Set(), assets: new Set(), - galleries: new Set() + galleries: new Set(), }; // Build dependency tree in CORRECTED logical order @@ -99,23 +99,20 @@ export class ModelDependencyTreeBuilder { // Create model reference name to ID mapping const modelMap = new Map(); - this.sourceData.models.forEach(model => { + this.sourceData.models.forEach((model) => { modelMap.set(model.referenceName, model.id); }); // Find containers that use these models - modelNames.forEach(modelName => { + modelNames.forEach((modelName) => { const modelId = modelMap.get(modelName); if (modelId) { - const containers = this.sourceData.containers.filter(c => - c.contentDefinitionID === modelId - ); - containers.forEach(container => { + const containers = this.sourceData.containers.filter((c) => c.contentDefinitionID === modelId); + containers.forEach((container) => { tree.containers.add(container.contentViewID); }); } }); - } /** @@ -124,15 +121,12 @@ export class ModelDependencyTreeBuilder { private findContentForModels(modelNames: string[], tree: ModelDependencyTree): void { if (!this.sourceData.content) return; - modelNames.forEach(modelName => { - const contentItems = this.sourceData.content.filter(c => - c.properties?.definitionName === modelName - ); - contentItems.forEach(content => { + modelNames.forEach((modelName) => { + const contentItems = this.sourceData.content.filter((c) => c.properties?.definitionName === modelName); + contentItems.forEach((content) => { tree.content.add(content.contentID); }); }); - } /** @@ -142,7 +136,7 @@ export class ModelDependencyTreeBuilder { if (!this.sourceData.templates) return; // Find templates that use discovered containers through contentSectionDefinitions - this.sourceData.templates.forEach(template => { + this.sourceData.templates.forEach((template) => { if (template.contentSectionDefinitions) { template.contentSectionDefinitions.forEach((section: any) => { // Check if section references discovered containers @@ -166,7 +160,7 @@ export class ModelDependencyTreeBuilder { private findPagesForTemplatesAndContent(tree: ModelDependencyTree): void { if (!this.sourceData.pages) return; - this.sourceData.pages.forEach(page => { + this.sourceData.pages.forEach((page) => { let shouldIncludePage = false; const pageAny = page as any; // Use defensive typing for complex Agility CMS structures @@ -179,7 +173,7 @@ export class ModelDependencyTreeBuilder { // Check if page content references discovered content (page zones/content areas) if (pageAny.zones) { const zones = pageAny.zones; - if (zones && typeof zones === 'object') { + if (zones && typeof zones === "object") { // Zones is an object with zone names as keys Object.values(zones).forEach((zoneModules: any) => { if (Array.isArray(zoneModules)) { @@ -210,10 +204,10 @@ export class ModelDependencyTreeBuilder { private findTemplatesUsedByPages(tree: ModelDependencyTree): void { if (!this.sourceData.pages) return; - this.sourceData.pages.forEach(page => { + this.sourceData.pages.forEach((page) => { if (tree.pages.has(page.pageID)) { const templateIds = this.extractTemplateIdsFromPage(page); - templateIds.forEach(id => tree.templates.add(id)); + templateIds.forEach((id) => tree.templates.add(id)); } }); @@ -228,15 +222,13 @@ export class ModelDependencyTreeBuilder { // Check multiple possible property names for template reference const templateId = page.pageTemplateID || page.templateID || page.templateId; - if (templateId && typeof templateId === 'number') { + if (templateId && typeof templateId === "number") { templateIds.push(templateId); } // Also check if templateName exists and try to resolve to ID if (page.templateName && this.sourceData.templates) { - const template = this.sourceData.templates.find(t => - t.pageTemplateName === page.templateName - ); + const template = this.sourceData.templates.find((t) => t.pageTemplateName === page.templateName); if (template && template.pageTemplateID) { templateIds.push(template.pageTemplateID); } @@ -254,11 +246,11 @@ export class ModelDependencyTreeBuilder { const initialContentSize = tree.content.size; - this.sourceData.pages.forEach(page => { + this.sourceData.pages.forEach((page) => { if (tree.pages.has(page.pageID)) { // Extract all content IDs from page zones const contentIds = this.extractContentIdsFromPage(page); - contentIds.forEach(id => tree.content.add(id)); + contentIds.forEach((id) => tree.content.add(id)); } }); @@ -279,11 +271,11 @@ export class ModelDependencyTreeBuilder { const pagesToProcess = new Set(); // Start with all currently discovered pages - tree.pages.forEach(pageId => pagesToProcess.add(pageId)); + tree.pages.forEach((pageId) => pagesToProcess.add(pageId)); // Process each page and find all its ancestors - pagesToProcess.forEach(pageId => { - const page = this.sourceData.pages!.find(p => p.pageID === pageId); + pagesToProcess.forEach((pageId) => { + const page = this.sourceData.pages!.find((p) => p.pageID === pageId); if (page) { this.findAllAncestorPages(page, tree, channel); } @@ -301,7 +293,11 @@ export class ModelDependencyTreeBuilder { if (parentPage && !tree.pages.has(parentPage.pageID)) { // Add this parent to the tree tree.pages.add(parentPage.pageID); - console.log(ansiColors.gray(` 📑 [ANCESTOR] Added parent page ${parentPage.name} (ID:${parentPage.pageID}) for child ${page.name} (ID:${page.pageID})`)); + console.log( + ansiColors.gray( + ` 📑 [ANCESTOR] Added parent page ${parentPage.name} (ID:${parentPage.pageID}) for child ${page.name} (ID:${page.pageID})` + ) + ); // Recursively find this parent's ancestors this.findAllAncestorPages(parentPage, tree, channel); @@ -322,7 +318,7 @@ export class ModelDependencyTreeBuilder { if (!parentResult.parentId) return null; // Find the actual page object by ID - const parentPage = this.sourceData.pages.find(p => p.pageID === parentResult.parentId); + const parentPage = this.sourceData.pages.find((p) => p.pageID === parentResult.parentId); return parentPage || null; } @@ -335,7 +331,7 @@ export class ModelDependencyTreeBuilder { const initialModelSize = tree.models.size; // Find models for all content in the tree - this.sourceData.content.forEach(contentItem => { + this.sourceData.content.forEach((contentItem) => { if (tree.content.has(contentItem.contentID)) { // Find the model for this content item const modelName = contentItem.properties?.definitionName; @@ -359,18 +355,16 @@ export class ModelDependencyTreeBuilder { // Create model reference name to ID mapping const modelMap = new Map(); - this.sourceData.models.forEach(model => { + this.sourceData.models.forEach((model) => { modelMap.set(model.referenceName, model.id); }); // Find containers for all models in the tree - tree.models.forEach(modelName => { + tree.models.forEach((modelName) => { const modelId = modelMap.get(modelName); if (modelId) { - const containers = this.sourceData.containers.filter(c => - c.contentDefinitionID === modelId - ); - containers.forEach(container => { + const containers = this.sourceData.containers.filter((c) => c.contentDefinitionID === modelId); + containers.forEach((container) => { tree.containers.add(container.contentViewID); }); } @@ -393,7 +387,7 @@ export class ModelDependencyTreeBuilder { // Create a map of content reference names (lowercase) to content IDs const contentReferenceMap = new Map(); - this.sourceData.content.forEach(contentItem => { + this.sourceData.content.forEach((contentItem) => { if (tree.content.has(contentItem.contentID)) { const referenceName = contentItem.properties?.referenceName; if (referenceName) { @@ -403,7 +397,7 @@ export class ModelDependencyTreeBuilder { }); // Find containers with case-insensitive matching - this.sourceData.containers.forEach(container => { + this.sourceData.containers.forEach((container) => { const containerRefLower = container.referenceName?.toLowerCase(); if (containerRefLower && contentReferenceMap.has(containerRefLower)) { tree.containers.add(container.contentViewID); @@ -423,14 +417,14 @@ export class ModelDependencyTreeBuilder { if (page.zones) { const zones = page.zones; - if (zones && typeof zones === 'object') { + if (zones && typeof zones === "object") { // Zones is an object with zone names as keys Object.values(zones).forEach((zoneModules: any) => { if (Array.isArray(zoneModules)) { zoneModules.forEach((module: any) => { if (module.item && (module.item.contentid || module.item.contentID)) { const contentId = module.item.contentid || module.item.contentID; - if (typeof contentId === 'number') { + if (typeof contentId === "number") { contentIds.push(contentId); } } @@ -448,7 +442,7 @@ export class ModelDependencyTreeBuilder { */ private findAssetsInContent(tree: ModelDependencyTree): void { if (!this.sourceData.content) return; - + // Note: We don't require assets to exist in sourceData to extract URLs from content // The assets might not be loaded yet, but we should still extract the URLs @@ -456,13 +450,13 @@ export class ModelDependencyTreeBuilder { let contentItemsScanned = 0; // Extract asset URLs from content items in the tree - this.sourceData.content.forEach(contentItem => { + this.sourceData.content.forEach((contentItem) => { if (tree.content.has(contentItem.contentID)) { contentItemsScanned++; const assetUrls = this.extractAssetUrlsFromContent(contentItem); if (assetUrls.length > 0) { totalUrlsFound += assetUrls.length; - assetUrls.forEach(url => { + assetUrls.forEach((url) => { tree.assets.add(url); // Also try to find matching asset and add all its URL variations // This ensures we match assets even if content has different URL format @@ -479,13 +473,12 @@ export class ModelDependencyTreeBuilder { } }); - // Also check pages for asset references if (this.sourceData.pages) { - this.sourceData.pages.forEach(page => { + this.sourceData.pages.forEach((page) => { if (tree.pages.has(page.pageID)) { const assetUrls = this.extractAssetUrlsFromPage(page); - assetUrls.forEach(url => { + assetUrls.forEach((url) => { tree.assets.add(url); // Also try to find matching asset and add all its URL variations const matchingAsset = this.findMatchingAsset(url); @@ -508,34 +501,34 @@ export class ModelDependencyTreeBuilder { */ private findMatchingAsset(url: string): any | null { if (!this.sourceData.assets || !url) return null; - + // First try exact URL match let matchingAsset = this.sourceData.assets.find((asset: any) => { - return asset.url === url || - asset.originUrl === url || - asset.edgeUrl === url; + return asset.url === url || asset.originUrl === url || asset.edgeUrl === url; }); - + if (matchingAsset) return matchingAsset; - + // If no exact match, try to match by file path // Extract the file path from the URL (everything after the last /) const urlPath = this.extractFilePathFromUrl(url); if (!urlPath) return null; - + // Try to find asset by matching file path in any of its URLs - return this.sourceData.assets.find((asset: any) => { - const assetUrlPath = this.extractFilePathFromUrl(asset.url || asset.originUrl || asset.edgeUrl || ''); - return assetUrlPath && assetUrlPath === urlPath; - }) || null; + return ( + this.sourceData.assets.find((asset: any) => { + const assetUrlPath = this.extractFilePathFromUrl(asset.url || asset.originUrl || asset.edgeUrl || ""); + return assetUrlPath && assetUrlPath === urlPath; + }) || null + ); } /** * Extract file path from a URL (the path portion after the domain) */ private extractFilePathFromUrl(url: string): string | null { - if (!url || typeof url !== 'string') return null; - + if (!url || typeof url !== "string") return null; + try { const urlObj = new URL(url); return urlObj.pathname; @@ -552,10 +545,10 @@ export class ModelDependencyTreeBuilder { private findGalleriesInContent(tree: ModelDependencyTree): void { if (!this.sourceData.content || !this.sourceData.galleries) return; - this.sourceData.content.forEach(contentItem => { + this.sourceData.content.forEach((contentItem) => { if (tree.content.has(contentItem.contentID)) { const galleryIds = this.extractGalleryIdsFromContent(contentItem); - galleryIds.forEach(id => tree.galleries.add(id)); + galleryIds.forEach((id) => tree.galleries.add(id)); } }); @@ -571,12 +564,11 @@ export class ModelDependencyTreeBuilder { if (contentItem.fields) { const assetReferences = this.assetExtractor.extractAssetReferences(contentItem.fields); - assetReferences.forEach(ref => { + assetReferences.forEach((ref) => { if (ref.url) { urls.push(ref.url); } }); - } return urls; @@ -592,7 +584,7 @@ export class ModelDependencyTreeBuilder { // Scan page zones for asset references if (page.zones) { const zoneReferences = this.assetExtractor.extractAssetReferences(page.zones); - zoneReferences.forEach(ref => { + zoneReferences.forEach((ref) => { if (ref.url) { urls.push(ref.url); } @@ -602,7 +594,7 @@ export class ModelDependencyTreeBuilder { // Scan page content if it exists if (page.content) { const contentReferences = this.assetExtractor.extractAssetReferences(page.content); - contentReferences.forEach(ref => { + contentReferences.forEach((ref) => { if (ref.url) { urls.push(ref.url); } @@ -625,19 +617,18 @@ export class ModelDependencyTreeBuilder { return galleryIds; } - /** * Recursively scan object for gallery ID references */ - private scanObjectForGalleryIds(obj: any, galleryIds: number[], path: string = ''): void { - if (typeof obj === 'object' && obj !== null) { + private scanObjectForGalleryIds(obj: any, galleryIds: number[], path: string = ""): void { + if (typeof obj === "object" && obj !== null) { // Look for gallery field patterns - if (obj.mediaGroupingID && typeof obj.mediaGroupingID === 'number') { + if (obj.mediaGroupingID && typeof obj.mediaGroupingID === "number") { galleryIds.push(obj.mediaGroupingID); } // Look for gallery reference patterns in field values - if (obj.galleryID && typeof obj.galleryID === 'number') { + if (obj.galleryID && typeof obj.galleryID === "number") { galleryIds.push(obj.galleryID); } @@ -647,7 +638,7 @@ export class ModelDependencyTreeBuilder { this.scanObjectForGalleryIds(item, galleryIds, `${path}[${index}]`); }); } else { - Object.keys(obj).forEach(key => { + Object.keys(obj).forEach((key) => { this.scanObjectForGalleryIds(obj[key], galleryIds, `${path}.${key}`); }); } @@ -658,8 +649,14 @@ export class ModelDependencyTreeBuilder { * Get a summary string of the dependency tree */ private getTreeSummary(tree: ModelDependencyTree): string { - const total = tree.models.size + tree.containers.size + tree.content.size + - tree.templates.size + tree.pages.size + tree.assets.size + tree.galleries.size; + const total = + tree.models.size + + tree.containers.size + + tree.content.size + + tree.templates.size + + tree.pages.size + + tree.assets.size + + tree.galleries.size; return `${total} total entities across 7 types`; } @@ -678,7 +675,7 @@ export class ModelDependencyTreeBuilder { const availableModels = new Set(models.map((m: any) => m.referenceName.toLowerCase().trim())); - modelNames.forEach(modelName => { + modelNames.forEach((modelName) => { if (availableModels.has(modelName.toLowerCase().trim())) { valid.push(modelName); } else { @@ -700,25 +697,25 @@ export class ModelDependencyTreeBuilder { ` 🎨 ${tree.templates.size} templates`, ` 📑 ${tree.pages.size} pages`, ` 🖼️ ${tree.assets.size} assets`, - ` 🗂️ ${tree.galleries.size} galleries` - ].filter(line => { + ` 🗂️ ${tree.galleries.size} galleries`, + ].filter((line) => { // Only show non-zero counts - const count = parseInt(line.match(/\d+/)?.[0] || '0'); + const count = parseInt(line.match(/\d+/)?.[0] || "0"); return count > 0; }); if (breakdown.length > 0) { - console.log(ansiColors.gray(breakdown.join('\n'))); - + console.log(ansiColors.gray(breakdown.join("\n"))); + // Add explanatory notes for missing dependencies if (tree.templates.size === 0 && tree.containers.size > 0) { - console.log(ansiColors.yellow(' ℹ️ No templates found that use these containers')); + console.log(ansiColors.yellow(" ℹ️ No templates found that use these containers")); } if (tree.pages.size === 0 && (tree.templates.size > 0 || tree.content.size > 0)) { - console.log(ansiColors.yellow(' ℹ️ No pages found that use these templates/content')); + console.log(ansiColors.yellow(" ℹ️ No pages found that use these templates/content")); } } else { - console.log(ansiColors.yellow(' ⚠️ No dependencies found')); + console.log(ansiColors.yellow(" ⚠️ No dependencies found")); } } -} \ No newline at end of file +} diff --git a/src/lib/models/tests/model-dependency-tree-builder.test.ts b/src/lib/models/tests/model-dependency-tree-builder.test.ts index 3ae0151..015d095 100644 --- a/src/lib/models/tests/model-dependency-tree-builder.test.ts +++ b/src/lib/models/tests/model-dependency-tree-builder.test.ts @@ -1,23 +1,23 @@ -import { resetState } from 'core/state'; -import { ModelDependencyTreeBuilder, ModelDependencyTree } from 'lib/models/model-dependency-tree-builder'; -import { SitemapHierarchy } from 'lib/pushers/page-pusher/sitemap-hierarchy'; +import { resetState } from "core/state"; +import { ModelDependencyTreeBuilder, ModelDependencyTree } from "lib/models/model-dependency-tree-builder"; +import { SitemapHierarchy } from "lib/pushers/page-pusher/sitemap-hierarchy"; // Mock SitemapHierarchy to avoid filesystem access -jest.mock('lib/pushers/page-pusher/sitemap-hierarchy'); +jest.mock("lib/pushers/page-pusher/sitemap-hierarchy"); const MockedSitemapHierarchy = SitemapHierarchy as jest.MockedClass; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); // Default: no parent found (root-level pages) MockedSitemapHierarchy.prototype.findPageParentInSourceSitemap = jest.fn().mockReturnValue({ parentId: null, parentName: null, - foundIn: 'root-level', + foundIn: "root-level", }); // Reset static logging flag between tests @@ -34,11 +34,20 @@ function makeModel(id: number, referenceName: string): any { return { id, referenceName }; } -function makeContainer(contentViewID: number, contentDefinitionID: number, referenceName = `ref-${contentViewID}`): any { +function makeContainer( + contentViewID: number, + contentDefinitionID: number, + referenceName = `ref-${contentViewID}` +): any { return { contentViewID, contentDefinitionID, referenceName }; } -function makeContent(contentID: number, definitionName: string, referenceName = `ref-${contentID}`, fields: any = {}): any { +function makeContent( + contentID: number, + definitionName: string, + referenceName = `ref-${contentID}`, + fields: any = {} +): any { return { contentID, properties: { definitionName, referenceName }, @@ -55,7 +64,11 @@ function makePage(pageID: number, opts: { pageTemplateID?: number; zones?: any; }; } -function makeTemplate(pageTemplateID: number, contentSectionDefinitions: any[] = [], pageTemplateName = `tmpl-${pageTemplateID}`): any { +function makeTemplate( + pageTemplateID: number, + contentSectionDefinitions: any[] = [], + pageTemplateName = `tmpl-${pageTemplateID}` +): any { return { pageTemplateID, pageTemplateName, contentSectionDefinitions }; } @@ -83,69 +96,69 @@ function makeSourceData(overrides: Partial = {}): any { // ─── resetLoggingFlags ──────────────────────────────────────────────────────── -describe('ModelDependencyTreeBuilder.resetLoggingFlags', () => { - it('does not throw', () => { +describe("ModelDependencyTreeBuilder.resetLoggingFlags", () => { + it("does not throw", () => { expect(() => ModelDependencyTreeBuilder.resetLoggingFlags()).not.toThrow(); }); - it('allows the breakdown log to fire again on a fresh builder', () => { + it("allows the breakdown log to fire again on a fresh builder", () => { const builder = new ModelDependencyTreeBuilder( - makeSourceData({ models: [makeModel(1, 'Post')], content: [makeContent(10, 'Post')] }) + makeSourceData({ models: [makeModel(1, "Post")], content: [makeContent(10, "Post")] }) ); - const logSpy = jest.spyOn(console, 'log'); + const logSpy = jest.spyOn(console, "log"); - builder.buildDependencyTree(['Post'], 'website'); + builder.buildDependencyTree(["Post"], "website"); const callsAfterFirst = logSpy.mock.calls.length; // Log should not fire again without reset - builder.buildDependencyTree(['Post'], 'website'); + builder.buildDependencyTree(["Post"], "website"); expect(logSpy.mock.calls.length).toBe(callsAfterFirst); // After reset, log fires again ModelDependencyTreeBuilder.resetLoggingFlags(); - builder.buildDependencyTree(['Post'], 'website'); + builder.buildDependencyTree(["Post"], "website"); expect(logSpy.mock.calls.length).toBeGreaterThan(callsAfterFirst); }); }); // ─── constructor ────────────────────────────────────────────────────────────── -describe('ModelDependencyTreeBuilder constructor', () => { - it('does not throw with a valid sourceData object', () => { +describe("ModelDependencyTreeBuilder constructor", () => { + it("does not throw with a valid sourceData object", () => { expect(() => new ModelDependencyTreeBuilder(makeSourceData())).not.toThrow(); }); }); // ─── buildDependencyTree — guard clauses ───────────────────────────────────── -describe('ModelDependencyTreeBuilder.buildDependencyTree — guard clauses', () => { +describe("ModelDependencyTreeBuilder.buildDependencyTree — guard clauses", () => { let builder: ModelDependencyTreeBuilder; beforeEach(() => { builder = new ModelDependencyTreeBuilder(makeSourceData()); }); - it('throws when modelNames is null', () => { - expect(() => builder.buildDependencyTree(null as any, 'website')).toThrow( - 'Model names are required for dependency tree building' + it("throws when modelNames is null", () => { + expect(() => builder.buildDependencyTree(null as any, "website")).toThrow( + "Model names are required for dependency tree building" ); }); - it('throws when modelNames is an empty array', () => { - expect(() => builder.buildDependencyTree([], 'website')).toThrow( - 'Model names are required for dependency tree building' + it("throws when modelNames is an empty array", () => { + expect(() => builder.buildDependencyTree([], "website")).toThrow( + "Model names are required for dependency tree building" ); }); }); // ─── buildDependencyTree — empty source data ───────────────────────────────── -describe('ModelDependencyTreeBuilder.buildDependencyTree — empty source data', () => { - it('returns tree with only the requested model names when source data is empty', () => { +describe("ModelDependencyTreeBuilder.buildDependencyTree — empty source data", () => { + it("returns tree with only the requested model names when source data is empty", () => { const builder = new ModelDependencyTreeBuilder(makeSourceData()); - const tree = builder.buildDependencyTree(['Post'], 'website'); + const tree = builder.buildDependencyTree(["Post"], "website"); - expect(tree.models).toEqual(new Set(['Post'])); + expect(tree.models).toEqual(new Set(["Post"])); expect(tree.containers.size).toBe(0); expect(tree.content.size).toBe(0); expect(tree.templates.size).toBe(0); @@ -154,110 +167,117 @@ describe('ModelDependencyTreeBuilder.buildDependencyTree — empty source data', expect(tree.galleries.size).toBe(0); }); - it('seeds the models set with all supplied model names', () => { + it("seeds the models set with all supplied model names", () => { const builder = new ModelDependencyTreeBuilder(makeSourceData()); - const tree = builder.buildDependencyTree(['Alpha', 'Beta', 'Gamma'], 'website'); - expect(tree.models).toEqual(new Set(['Alpha', 'Beta', 'Gamma'])); + const tree = builder.buildDependencyTree(["Alpha", "Beta", "Gamma"], "website"); + expect(tree.models).toEqual(new Set(["Alpha", "Beta", "Gamma"])); }); - it('returns a tree with all required keys', () => { + it("returns a tree with all required keys", () => { const builder = new ModelDependencyTreeBuilder(makeSourceData()); - const tree = builder.buildDependencyTree(['M'], 'website'); + const tree = builder.buildDependencyTree(["M"], "website"); const keys: Array = [ - 'models', 'containers', 'lists', 'content', 'templates', 'pages', 'assets', 'galleries', + "models", + "containers", + "lists", + "content", + "templates", + "pages", + "assets", + "galleries", ]; - keys.forEach(key => expect(tree).toHaveProperty(key)); + keys.forEach((key) => expect(tree).toHaveProperty(key)); }); }); // ─── buildDependencyTree — container discovery ─────────────────────────────── -describe('ModelDependencyTreeBuilder.buildDependencyTree — container discovery', () => { - it('finds containers that reference a matching model', () => { +describe("ModelDependencyTreeBuilder.buildDependencyTree — container discovery", () => { + it("finds containers that reference a matching model", () => { const sourceData = makeSourceData({ - models: [makeModel(1, 'Post')], + models: [makeModel(1, "Post")], containers: [makeContainer(100, 1)], }); const builder = new ModelDependencyTreeBuilder(sourceData); - const tree = builder.buildDependencyTree(['Post'], 'website'); + const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.containers.has(100)).toBe(true); }); - it('does not include containers for unrelated models', () => { + it("does not include containers for unrelated models", () => { const sourceData = makeSourceData({ - models: [makeModel(1, 'Post'), makeModel(2, 'Author')], + models: [makeModel(1, "Post"), makeModel(2, "Author")], containers: [makeContainer(100, 1), makeContainer(200, 2)], }); const builder = new ModelDependencyTreeBuilder(sourceData); - const tree = builder.buildDependencyTree(['Post'], 'website'); + const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.containers.has(100)).toBe(true); expect(tree.containers.has(200)).toBe(false); }); - it('discovers multiple containers for the same model', () => { + it("discovers multiple containers for the same model", () => { const sourceData = makeSourceData({ - models: [makeModel(1, 'Post')], + models: [makeModel(1, "Post")], containers: [makeContainer(100, 1), makeContainer(101, 1)], }); const builder = new ModelDependencyTreeBuilder(sourceData); - const tree = builder.buildDependencyTree(['Post'], 'website'); + const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.containers.has(100)).toBe(true); expect(tree.containers.has(101)).toBe(true); }); - it('handles missing containers array gracefully', () => { - const sourceData = makeSourceData({ models: [makeModel(1, 'Post')], containers: undefined }); + it("handles missing containers array gracefully", () => { + const sourceData = makeSourceData({ models: [makeModel(1, "Post")], containers: undefined }); const builder = new ModelDependencyTreeBuilder(sourceData); - expect(() => builder.buildDependencyTree(['Post'], 'website')).not.toThrow(); + expect(() => builder.buildDependencyTree(["Post"], "website")).not.toThrow(); }); - it('handles missing models array gracefully', () => { + it("handles missing models array gracefully", () => { const sourceData = makeSourceData({ models: undefined, containers: [makeContainer(100, 1)] }); const builder = new ModelDependencyTreeBuilder(sourceData); - expect(() => builder.buildDependencyTree(['Post'], 'website')).not.toThrow(); + expect(() => builder.buildDependencyTree(["Post"], "website")).not.toThrow(); }); - it('ignores model names that do not exist in the models list', () => { + it("ignores model names that do not exist in the models list", () => { const sourceData = makeSourceData({ - models: [makeModel(1, 'Post')], + models: [makeModel(1, "Post")], containers: [makeContainer(100, 1)], }); const builder = new ModelDependencyTreeBuilder(sourceData); - const tree = builder.buildDependencyTree(['NonExistent'], 'website'); + const tree = builder.buildDependencyTree(["NonExistent"], "website"); expect(tree.containers.size).toBe(0); }); }); // ─── buildDependencyTree — content discovery ───────────────────────────────── -describe('ModelDependencyTreeBuilder.buildDependencyTree — content discovery', () => { - it('finds content items whose definitionName matches a requested model', () => { +describe("ModelDependencyTreeBuilder.buildDependencyTree — content discovery", () => { + it("finds content items whose definitionName matches a requested model", () => { const sourceData = makeSourceData({ - content: [makeContent(10, 'Post'), makeContent(11, 'Post'), makeContent(12, 'Author')], + content: [makeContent(10, "Post"), makeContent(11, "Post"), makeContent(12, "Author")], }); const builder = new ModelDependencyTreeBuilder(sourceData); - const tree = builder.buildDependencyTree(['Post'], 'website'); + const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.content.has(10)).toBe(true); expect(tree.content.has(11)).toBe(true); expect(tree.content.has(12)).toBe(false); }); - it('handles missing content array gracefully', () => { + it("handles missing content array gracefully", () => { const sourceData = makeSourceData({ content: undefined }); const builder = new ModelDependencyTreeBuilder(sourceData); - expect(() => builder.buildDependencyTree(['Post'], 'website')).not.toThrow(); + expect(() => builder.buildDependencyTree(["Post"], "website")).not.toThrow(); }); - it('collects content for multiple model names', () => { + it("collects content for multiple model names", () => { const sourceData = makeSourceData({ - content: [makeContent(10, 'Post'), makeContent(20, 'Author')], + content: [makeContent(10, "Post"), makeContent(20, "Author")], }); const builder = new ModelDependencyTreeBuilder(sourceData); - const tree = builder.buildDependencyTree(['Post', 'Author'], 'website'); + const tree = builder.buildDependencyTree(["Post", "Author"], "website"); expect(tree.content.has(10)).toBe(true); expect(tree.content.has(20)).toBe(true); @@ -266,62 +286,64 @@ describe('ModelDependencyTreeBuilder.buildDependencyTree — content discovery', // ─── buildDependencyTree — template discovery ──────────────────────────────── -describe('ModelDependencyTreeBuilder.buildDependencyTree — template discovery via containers', () => { - it('finds a template referencing a discovered container via contentViewID', () => { +describe("ModelDependencyTreeBuilder.buildDependencyTree — template discovery via containers", () => { + it("finds a template referencing a discovered container via contentViewID", () => { const sourceData = makeSourceData({ - models: [makeModel(1, 'Post')], + models: [makeModel(1, "Post")], containers: [makeContainer(100, 1)], templates: [makeTemplate(500, [{ contentViewID: 100 }])], }); const builder = new ModelDependencyTreeBuilder(sourceData); - const tree = builder.buildDependencyTree(['Post'], 'website'); + const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.templates.has(500)).toBe(true); }); - it('finds a template referencing a discovered container via itemContainerID', () => { + it("finds a template referencing a discovered container via itemContainerID", () => { const sourceData = makeSourceData({ - models: [makeModel(1, 'Post')], + models: [makeModel(1, "Post")], containers: [makeContainer(100, 1)], templates: [makeTemplate(501, [{ itemContainerID: 100 }])], }); const builder = new ModelDependencyTreeBuilder(sourceData); - const tree = builder.buildDependencyTree(['Post'], 'website'); + const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.templates.has(501)).toBe(true); }); - it('does not include templates that reference undiscovered containers', () => { + it("does not include templates that reference undiscovered containers", () => { const sourceData = makeSourceData({ - models: [makeModel(1, 'Post')], + models: [makeModel(1, "Post")], containers: [makeContainer(100, 1)], templates: [makeTemplate(500, [{ contentViewID: 999 }])], }); const builder = new ModelDependencyTreeBuilder(sourceData); - const tree = builder.buildDependencyTree(['Post'], 'website'); + const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.templates.has(500)).toBe(false); }); - it('handles missing templates array gracefully', () => { + it("handles missing templates array gracefully", () => { const sourceData = makeSourceData({ - models: [makeModel(1, 'Post')], + models: [makeModel(1, "Post")], containers: [makeContainer(100, 1)], templates: undefined, }); const builder = new ModelDependencyTreeBuilder(sourceData); - expect(() => builder.buildDependencyTree(['Post'], 'website')).not.toThrow(); + expect(() => builder.buildDependencyTree(["Post"], "website")).not.toThrow(); }); - it('resolves template by name when page has templateName instead of ID', () => { + it("resolves template by name when page has templateName instead of ID", () => { const sourceData = makeSourceData({ - models: [makeModel(1, 'Post')], + models: [makeModel(1, "Post")], containers: [makeContainer(100, 1)], - templates: [{ pageTemplateID: 600, pageTemplateName: 'MainLayout', contentSectionDefinitions: [{ contentViewID: 100 }] }], - pages: [{ pageID: 300, name: 'blog', templateName: 'MainLayout' }], + templates: [ + { pageTemplateID: 600, pageTemplateName: "MainLayout", contentSectionDefinitions: [{ contentViewID: 100 }] }, + ], + pages: [{ pageID: 300, name: "blog", templateName: "MainLayout" }], }); const builder = new ModelDependencyTreeBuilder(sourceData); - const tree = builder.buildDependencyTree(['Post'], 'website'); + const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.templates.has(600)).toBe(true); }); @@ -329,137 +351,137 @@ describe('ModelDependencyTreeBuilder.buildDependencyTree — template discovery // ─── buildDependencyTree — page discovery ──────────────────────────────────── -describe('ModelDependencyTreeBuilder.buildDependencyTree — page discovery', () => { - it('finds a page that uses a discovered template', () => { +describe("ModelDependencyTreeBuilder.buildDependencyTree — page discovery", () => { + it("finds a page that uses a discovered template", () => { const sourceData = makeSourceData({ - models: [makeModel(1, 'Post')], + models: [makeModel(1, "Post")], containers: [makeContainer(100, 1)], templates: [makeTemplate(500, [{ contentViewID: 100 }])], pages: [makePage(300, { pageTemplateID: 500 })], }); const builder = new ModelDependencyTreeBuilder(sourceData); - const tree = builder.buildDependencyTree(['Post'], 'website'); + const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.pages.has(300)).toBe(true); }); - it('finds a page whose zone references a discovered content item', () => { + it("finds a page whose zone references a discovered content item", () => { const zones = { main: [{ item: { contentid: 10 } }] }; const sourceData = makeSourceData({ - content: [makeContent(10, 'Post')], + content: [makeContent(10, "Post")], pages: [makePage(300, { zones })], }); const builder = new ModelDependencyTreeBuilder(sourceData); - const tree = builder.buildDependencyTree(['Post'], 'website'); + const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.pages.has(300)).toBe(true); }); - it('supports contentID (uppercase) in zone module items', () => { + it("supports contentID (uppercase) in zone module items", () => { const zones = { sidebar: [{ item: { contentID: 20 } }] }; const sourceData = makeSourceData({ - content: [makeContent(20, 'Widget')], + content: [makeContent(20, "Widget")], pages: [makePage(400, { zones })], }); const builder = new ModelDependencyTreeBuilder(sourceData); - const tree = builder.buildDependencyTree(['Widget'], 'website'); + const tree = builder.buildDependencyTree(["Widget"], "website"); expect(tree.pages.has(400)).toBe(true); }); - it('does not include pages that neither match a template nor reference discovered content', () => { + it("does not include pages that neither match a template nor reference discovered content", () => { const sourceData = makeSourceData({ - models: [makeModel(1, 'Post')], + models: [makeModel(1, "Post")], containers: [makeContainer(100, 1)], templates: [makeTemplate(500, [{ contentViewID: 100 }])], pages: [makePage(300, { pageTemplateID: 500 }), makePage(999, { pageTemplateID: 888 })], }); const builder = new ModelDependencyTreeBuilder(sourceData); - const tree = builder.buildDependencyTree(['Post'], 'website'); + const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.pages.has(300)).toBe(true); expect(tree.pages.has(999)).toBe(false); }); - it('handles missing pages array gracefully', () => { + it("handles missing pages array gracefully", () => { const sourceData = makeSourceData({ pages: undefined }); const builder = new ModelDependencyTreeBuilder(sourceData); - expect(() => builder.buildDependencyTree(['Post'], 'website')).not.toThrow(); + expect(() => builder.buildDependencyTree(["Post"], "website")).not.toThrow(); }); }); // ─── buildDependencyTree — content pulled from page zones ──────────────────── -describe('ModelDependencyTreeBuilder.buildDependencyTree — content pulled from page zones', () => { - it('adds content IDs found in discovered page zones to the content set', () => { +describe("ModelDependencyTreeBuilder.buildDependencyTree — content pulled from page zones", () => { + it("adds content IDs found in discovered page zones to the content set", () => { const zones = { main: [{ item: { contentid: 10 } }, { item: { contentid: 99 } }] }; const sourceData = makeSourceData({ - content: [makeContent(10, 'Post'), makeContent(99, 'Promo')], + content: [makeContent(10, "Post"), makeContent(99, "Promo")], pages: [makePage(300, { zones })], }); const builder = new ModelDependencyTreeBuilder(sourceData); - const tree = builder.buildDependencyTree(['Post'], 'website'); + const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.content.has(99)).toBe(true); }); - it('does not add non-numeric content IDs from page zones', () => { - const zones = { main: [{ item: { contentid: 'bad-id' } }] }; + it("does not add non-numeric content IDs from page zones", () => { + const zones = { main: [{ item: { contentid: "bad-id" } }] }; const sourceData = makeSourceData({ - content: [makeContent(10, 'Post')], + content: [makeContent(10, "Post")], pages: [makePage(300, { zones })], }); const builder = new ModelDependencyTreeBuilder(sourceData); // Should not throw - expect(() => builder.buildDependencyTree(['Post'], 'website')).not.toThrow(); + expect(() => builder.buildDependencyTree(["Post"], "website")).not.toThrow(); }); }); // ─── buildDependencyTree — model back-discovery from content ───────────────── -describe('ModelDependencyTreeBuilder.buildDependencyTree — model back-discovery', () => { - it('adds the model of a content item discovered through a page zone', () => { +describe("ModelDependencyTreeBuilder.buildDependencyTree — model back-discovery", () => { + it("adds the model of a content item discovered through a page zone", () => { const zones = { main: [{ item: { contentid: 10 } }, { item: { contentid: 99 } }] }; const sourceData = makeSourceData({ - content: [makeContent(10, 'Post'), makeContent(99, 'Promo')], - models: [makeModel(1, 'Post'), makeModel(2, 'Promo')], + content: [makeContent(10, "Post"), makeContent(99, "Promo")], + models: [makeModel(1, "Post"), makeModel(2, "Promo")], pages: [makePage(300, { zones })], }); const builder = new ModelDependencyTreeBuilder(sourceData); - const tree = builder.buildDependencyTree(['Post'], 'website'); + const tree = builder.buildDependencyTree(["Post"], "website"); - expect(tree.models.has('Promo')).toBe(true); + expect(tree.models.has("Promo")).toBe(true); }); }); // ─── buildDependencyTree — container back-discovery from content ────────────── -describe('ModelDependencyTreeBuilder.buildDependencyTree — container discovery for content', () => { - it('adds containers whose referenceName (case-insensitive) matches a content referenceName', () => { +describe("ModelDependencyTreeBuilder.buildDependencyTree — container discovery for content", () => { + it("adds containers whose referenceName (case-insensitive) matches a content referenceName", () => { const sourceData = makeSourceData({ - models: [makeModel(1, 'Post')], + models: [makeModel(1, "Post")], containers: [ - makeContainer(100, 1, 'news1_PostList'), // different casing + makeContainer(100, 1, "news1_PostList"), // different casing ], - content: [makeContent(10, 'Post', 'news1_postlist')], + content: [makeContent(10, "Post", "news1_postlist")], }); const builder = new ModelDependencyTreeBuilder(sourceData); - const tree = builder.buildDependencyTree(['Post'], 'website'); + const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.containers.has(100)).toBe(true); }); - it('does not add containers whose reference name does not match any content reference', () => { + it("does not add containers whose reference name does not match any content reference", () => { // Model ID 99 does not match any model in the models array, so container 200 is // only eligible for inclusion via the case-insensitive referenceName path — which // should not fire because 'unrelated_container' != 'news1_postlist'. const sourceData = makeSourceData({ - models: [makeModel(1, 'Post')], - containers: [makeContainer(200, 99, 'unrelated_container')], - content: [makeContent(10, 'Post', 'news1_postlist')], + models: [makeModel(1, "Post")], + containers: [makeContainer(200, 99, "unrelated_container")], + content: [makeContent(10, "Post", "news1_postlist")], }); const builder = new ModelDependencyTreeBuilder(sourceData); - const tree = builder.buildDependencyTree(['Post'], 'website'); + const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.containers.has(200)).toBe(false); }); @@ -467,115 +489,121 @@ describe('ModelDependencyTreeBuilder.buildDependencyTree — container discovery // ─── buildDependencyTree — asset discovery ─────────────────────────────────── -describe('ModelDependencyTreeBuilder.buildDependencyTree — asset discovery', () => { - it('adds asset URLs found in content fields', () => { +describe("ModelDependencyTreeBuilder.buildDependencyTree — asset discovery", () => { + it("adds asset URLs found in content fields", () => { const sourceData = makeSourceData({ - content: [makeContent(10, 'Post', 'ref-10', { image: 'https://cdn.aglty.io/my-img.jpg' })], + content: [makeContent(10, "Post", "ref-10", { image: "https://cdn.aglty.io/my-img.jpg" })], }); const builder = new ModelDependencyTreeBuilder(sourceData); - const tree = builder.buildDependencyTree(['Post'], 'website'); + const tree = builder.buildDependencyTree(["Post"], "website"); - expect(tree.assets.has('https://cdn.aglty.io/my-img.jpg')).toBe(true); + expect(tree.assets.has("https://cdn.aglty.io/my-img.jpg")).toBe(true); }); - it('adds asset URL variations from matching assets in sourceData', () => { + it("adds asset URL variations from matching assets in sourceData", () => { const sourceData = makeSourceData({ - content: [makeContent(10, 'Post', 'ref-10', { image: 'https://cdn.aglty.io/my-img.jpg' })], - assets: [makeAsset('https://cdn.aglty.io/my-img.jpg', 'https://origin.aglty.io/my-img.jpg', 'https://edge.aglty.io/my-img.jpg')], + content: [makeContent(10, "Post", "ref-10", { image: "https://cdn.aglty.io/my-img.jpg" })], + assets: [ + makeAsset( + "https://cdn.aglty.io/my-img.jpg", + "https://origin.aglty.io/my-img.jpg", + "https://edge.aglty.io/my-img.jpg" + ), + ], }); const builder = new ModelDependencyTreeBuilder(sourceData); - const tree = builder.buildDependencyTree(['Post'], 'website'); + const tree = builder.buildDependencyTree(["Post"], "website"); - expect(tree.assets.has('https://origin.aglty.io/my-img.jpg')).toBe(true); - expect(tree.assets.has('https://edge.aglty.io/my-img.jpg')).toBe(true); + expect(tree.assets.has("https://origin.aglty.io/my-img.jpg")).toBe(true); + expect(tree.assets.has("https://edge.aglty.io/my-img.jpg")).toBe(true); }); - it('does not add asset URLs for content items not in the tree', () => { + it("does not add asset URLs for content items not in the tree", () => { const sourceData = makeSourceData({ content: [ - makeContent(10, 'Post', 'ref-10', { image: 'https://cdn.aglty.io/included.jpg' }), - makeContent(20, 'Other', 'ref-20', { image: 'https://cdn.aglty.io/excluded.jpg' }), + makeContent(10, "Post", "ref-10", { image: "https://cdn.aglty.io/included.jpg" }), + makeContent(20, "Other", "ref-20", { image: "https://cdn.aglty.io/excluded.jpg" }), ], }); const builder = new ModelDependencyTreeBuilder(sourceData); - const tree = builder.buildDependencyTree(['Post'], 'website'); + const tree = builder.buildDependencyTree(["Post"], "website"); - expect(tree.assets.has('https://cdn.aglty.io/included.jpg')).toBe(true); - expect(tree.assets.has('https://cdn.aglty.io/excluded.jpg')).toBe(false); + expect(tree.assets.has("https://cdn.aglty.io/included.jpg")).toBe(true); + expect(tree.assets.has("https://cdn.aglty.io/excluded.jpg")).toBe(false); }); - it('handles missing content array gracefully for asset discovery', () => { + it("handles missing content array gracefully for asset discovery", () => { const sourceData = makeSourceData({ content: undefined }); const builder = new ModelDependencyTreeBuilder(sourceData); - expect(() => builder.buildDependencyTree(['Post'], 'website')).not.toThrow(); + expect(() => builder.buildDependencyTree(["Post"], "website")).not.toThrow(); }); - it('extracts asset URLs from agilitycms.com domain', () => { + it("extracts asset URLs from agilitycms.com domain", () => { const sourceData = makeSourceData({ - content: [makeContent(10, 'Post', 'ref-10', { banner: 'https://static.agilitycms.com/banner.png' })], + content: [makeContent(10, "Post", "ref-10", { banner: "https://static.agilitycms.com/banner.png" })], }); const builder = new ModelDependencyTreeBuilder(sourceData); - const tree = builder.buildDependencyTree(['Post'], 'website'); + const tree = builder.buildDependencyTree(["Post"], "website"); - expect(tree.assets.has('https://static.agilitycms.com/banner.png')).toBe(true); + expect(tree.assets.has("https://static.agilitycms.com/banner.png")).toBe(true); }); }); // ─── buildDependencyTree — gallery discovery ───────────────────────────────── -describe('ModelDependencyTreeBuilder.buildDependencyTree — gallery discovery', () => { - it('adds gallery IDs found via mediaGroupingID in content fields', () => { +describe("ModelDependencyTreeBuilder.buildDependencyTree — gallery discovery", () => { + it("adds gallery IDs found via mediaGroupingID in content fields", () => { const sourceData = makeSourceData({ - content: [makeContent(10, 'Post', 'ref-10', { gallery: { mediaGroupingID: 55 } })], + content: [makeContent(10, "Post", "ref-10", { gallery: { mediaGroupingID: 55 } })], galleries: [makeGallery(55)], }); const builder = new ModelDependencyTreeBuilder(sourceData); - const tree = builder.buildDependencyTree(['Post'], 'website'); + const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.galleries.has(55)).toBe(true); }); - it('adds gallery IDs found via galleryID in content fields', () => { + it("adds gallery IDs found via galleryID in content fields", () => { const sourceData = makeSourceData({ - content: [makeContent(10, 'Post', 'ref-10', { pics: { galleryID: 77 } })], + content: [makeContent(10, "Post", "ref-10", { pics: { galleryID: 77 } })], galleries: [makeGallery(77)], }); const builder = new ModelDependencyTreeBuilder(sourceData); - const tree = builder.buildDependencyTree(['Post'], 'website'); + const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.galleries.has(77)).toBe(true); }); - it('does not add gallery IDs from content not in the tree', () => { + it("does not add gallery IDs from content not in the tree", () => { const sourceData = makeSourceData({ content: [ - makeContent(10, 'Post', 'ref-10', {}), - makeContent(20, 'Other', 'ref-20', { g: { mediaGroupingID: 99 } }), + makeContent(10, "Post", "ref-10", {}), + makeContent(20, "Other", "ref-20", { g: { mediaGroupingID: 99 } }), ], galleries: [makeGallery(99)], }); const builder = new ModelDependencyTreeBuilder(sourceData); - const tree = builder.buildDependencyTree(['Post'], 'website'); + const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.galleries.has(99)).toBe(false); }); - it('handles missing galleries array gracefully', () => { + it("handles missing galleries array gracefully", () => { const sourceData = makeSourceData({ - content: [makeContent(10, 'Post', 'ref-10', { g: { mediaGroupingID: 55 } })], + content: [makeContent(10, "Post", "ref-10", { g: { mediaGroupingID: 55 } })], galleries: undefined, }); const builder = new ModelDependencyTreeBuilder(sourceData); - expect(() => builder.buildDependencyTree(['Post'], 'website')).not.toThrow(); + expect(() => builder.buildDependencyTree(["Post"], "website")).not.toThrow(); }); - it('recursively finds gallery IDs nested in arrays', () => { + it("recursively finds gallery IDs nested in arrays", () => { const sourceData = makeSourceData({ - content: [makeContent(10, 'Post', 'ref-10', { items: [{ mediaGroupingID: 42 }] })], + content: [makeContent(10, "Post", "ref-10", { items: [{ mediaGroupingID: 42 }] })], galleries: [makeGallery(42)], }); const builder = new ModelDependencyTreeBuilder(sourceData); - const tree = builder.buildDependencyTree(['Post'], 'website'); + const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.galleries.has(42)).toBe(true); }); @@ -583,64 +611,60 @@ describe('ModelDependencyTreeBuilder.buildDependencyTree — gallery discovery', // ─── buildDependencyTree — ancestor page discovery ─────────────────────────── -describe('ModelDependencyTreeBuilder.buildDependencyTree — ancestor page discovery', () => { - it('adds the parent page when findPageParentInSourceSitemap returns a parent ID', () => { - MockedSitemapHierarchy.prototype.findPageParentInSourceSitemap = jest.fn() - .mockImplementation((pageId: number) => { - // child page 300 has parent 200 - if (pageId === 300) return { parentId: 200, parentName: 'parent-page', foundIn: 'direct-match' }; - return { parentId: null, parentName: null, foundIn: 'root-level' }; - }); +describe("ModelDependencyTreeBuilder.buildDependencyTree — ancestor page discovery", () => { + it("adds the parent page when findPageParentInSourceSitemap returns a parent ID", () => { + MockedSitemapHierarchy.prototype.findPageParentInSourceSitemap = jest.fn().mockImplementation((pageId: number) => { + // child page 300 has parent 200 + if (pageId === 300) return { parentId: 200, parentName: "parent-page", foundIn: "direct-match" }; + return { parentId: null, parentName: null, foundIn: "root-level" }; + }); const zones = { main: [{ item: { contentid: 10 } }] }; const sourceData = makeSourceData({ - content: [makeContent(10, 'Post')], - pages: [ - makePage(300, { zones, name: 'child-page' }), - makePage(200, { name: 'parent-page' }), - ], + content: [makeContent(10, "Post")], + pages: [makePage(300, { zones, name: "child-page" }), makePage(200, { name: "parent-page" })], }); const builder = new ModelDependencyTreeBuilder(sourceData); - const tree = builder.buildDependencyTree(['Post'], 'website'); + const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.pages.has(300)).toBe(true); expect(tree.pages.has(200)).toBe(true); }); - it('does not add a parent page that has already been included', () => { - MockedSitemapHierarchy.prototype.findPageParentInSourceSitemap = jest.fn() - .mockReturnValue({ parentId: null, parentName: null, foundIn: 'root-level' }); + it("does not add a parent page that has already been included", () => { + MockedSitemapHierarchy.prototype.findPageParentInSourceSitemap = jest + .fn() + .mockReturnValue({ parentId: null, parentName: null, foundIn: "root-level" }); const zones = { main: [{ item: { contentid: 10 } }] }; const sourceData = makeSourceData({ - content: [makeContent(10, 'Post')], + content: [makeContent(10, "Post")], pages: [makePage(300, { zones })], }); const builder = new ModelDependencyTreeBuilder(sourceData); - const tree = builder.buildDependencyTree(['Post'], 'website'); + const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.pages.size).toBe(1); }); - it('recursively adds grandparent pages', () => { - MockedSitemapHierarchy.prototype.findPageParentInSourceSitemap = jest.fn() - .mockImplementation((pageId: number) => { - if (pageId === 300) return { parentId: 200, parentName: 'parent', foundIn: 'direct-match' }; - if (pageId === 200) return { parentId: 100, parentName: 'grandparent', foundIn: 'direct-match' }; - return { parentId: null, parentName: null, foundIn: 'root-level' }; - }); + it("recursively adds grandparent pages", () => { + MockedSitemapHierarchy.prototype.findPageParentInSourceSitemap = jest.fn().mockImplementation((pageId: number) => { + if (pageId === 300) return { parentId: 200, parentName: "parent", foundIn: "direct-match" }; + if (pageId === 200) return { parentId: 100, parentName: "grandparent", foundIn: "direct-match" }; + return { parentId: null, parentName: null, foundIn: "root-level" }; + }); const zones = { main: [{ item: { contentid: 10 } }] }; const sourceData = makeSourceData({ - content: [makeContent(10, 'Post')], + content: [makeContent(10, "Post")], pages: [ - makePage(300, { zones, name: 'child' }), - makePage(200, { name: 'parent' }), - makePage(100, { name: 'grandparent' }), + makePage(300, { zones, name: "child" }), + makePage(200, { name: "parent" }), + makePage(100, { name: "grandparent" }), ], }); const builder = new ModelDependencyTreeBuilder(sourceData); - const tree = builder.buildDependencyTree(['Post'], 'website'); + const tree = builder.buildDependencyTree(["Post"], "website"); expect(tree.pages.has(100)).toBe(true); expect(tree.pages.has(200)).toBe(true); @@ -650,71 +674,71 @@ describe('ModelDependencyTreeBuilder.buildDependencyTree — ancestor page disco // ─── buildDependencyTree — hasLoggedBreakdown deduplication ────────────────── -describe('ModelDependencyTreeBuilder.buildDependencyTree — breakdown log deduplication', () => { - it('only logs the breakdown once across multiple calls on the same builder', () => { - const sourceData = makeSourceData({ content: [makeContent(10, 'Post')] }); +describe("ModelDependencyTreeBuilder.buildDependencyTree — breakdown log deduplication", () => { + it("only logs the breakdown once across multiple calls on the same builder", () => { + const sourceData = makeSourceData({ content: [makeContent(10, "Post")] }); const builder = new ModelDependencyTreeBuilder(sourceData); - const logSpy = jest.spyOn(console, 'log'); + const logSpy = jest.spyOn(console, "log"); - builder.buildDependencyTree(['Post'], 'website'); + builder.buildDependencyTree(["Post"], "website"); const firstCallCount = logSpy.mock.calls.length; - builder.buildDependencyTree(['Post'], 'website'); + builder.buildDependencyTree(["Post"], "website"); expect(logSpy.mock.calls.length).toBe(firstCallCount); }); }); // ─── validateModels ─────────────────────────────────────────────────────────── -describe('ModelDependencyTreeBuilder.validateModels', () => { +describe("ModelDependencyTreeBuilder.validateModels", () => { let builder: ModelDependencyTreeBuilder; beforeEach(() => { builder = new ModelDependencyTreeBuilder(makeSourceData()); }); - it('returns all names as invalid when models list is empty', () => { - const result = builder.validateModels(['Post', 'Author'], []); + it("returns all names as invalid when models list is empty", () => { + const result = builder.validateModels(["Post", "Author"], []); expect(result.valid).toHaveLength(0); - expect(result.invalid).toEqual(['Post', 'Author']); + expect(result.invalid).toEqual(["Post", "Author"]); }); - it('returns all names as invalid when models is null', () => { - const result = builder.validateModels(['Post'], null as any); + it("returns all names as invalid when models is null", () => { + const result = builder.validateModels(["Post"], null as any); expect(result.valid).toHaveLength(0); - expect(result.invalid).toEqual(['Post']); + expect(result.invalid).toEqual(["Post"]); }); - it('validates a model name that exists (exact case)', () => { - const models = [makeModel(1, 'Post')]; - const result = builder.validateModels(['Post'], models); - expect(result.valid).toEqual(['Post']); + it("validates a model name that exists (exact case)", () => { + const models = [makeModel(1, "Post")]; + const result = builder.validateModels(["Post"], models); + expect(result.valid).toEqual(["Post"]); expect(result.invalid).toHaveLength(0); }); - it('validates a model name case-insensitively', () => { - const models = [makeModel(1, 'Post')]; - const result = builder.validateModels(['post'], models); - expect(result.valid).toEqual(['post']); + it("validates a model name case-insensitively", () => { + const models = [makeModel(1, "Post")]; + const result = builder.validateModels(["post"], models); + expect(result.valid).toEqual(["post"]); expect(result.invalid).toHaveLength(0); }); - it('trims whitespace when comparing model names', () => { - const models = [makeModel(1, 'Post')]; - const result = builder.validateModels([' Post '], models); - expect(result.valid).toEqual([' Post ']); + it("trims whitespace when comparing model names", () => { + const models = [makeModel(1, "Post")]; + const result = builder.validateModels([" Post "], models); + expect(result.valid).toEqual([" Post "]); expect(result.invalid).toHaveLength(0); }); - it('returns correct valid/invalid split for a mixed list', () => { - const models = [makeModel(1, 'Post'), makeModel(2, 'Author')]; - const result = builder.validateModels(['Post', 'NonExistent', 'Author'], models); - expect(result.valid).toEqual(['Post', 'Author']); - expect(result.invalid).toEqual(['NonExistent']); + it("returns correct valid/invalid split for a mixed list", () => { + const models = [makeModel(1, "Post"), makeModel(2, "Author")]; + const result = builder.validateModels(["Post", "NonExistent", "Author"], models); + expect(result.valid).toEqual(["Post", "Author"]); + expect(result.invalid).toEqual(["NonExistent"]); }); - it('handles an empty modelNames array', () => { - const models = [makeModel(1, 'Post')]; + it("handles an empty modelNames array", () => { + const models = [makeModel(1, "Post")]; const result = builder.validateModels([], models); expect(result.valid).toHaveLength(0); expect(result.invalid).toHaveLength(0); @@ -723,26 +747,26 @@ describe('ModelDependencyTreeBuilder.validateModels', () => { // ─── integration: full pipeline ────────────────────────────────────────────── -describe('ModelDependencyTreeBuilder — full pipeline integration', () => { - it('builds a complete tree linking models → containers → templates → pages → content → assets', () => { +describe("ModelDependencyTreeBuilder — full pipeline integration", () => { + it("builds a complete tree linking models → containers → templates → pages → content → assets", () => { const zones = { main: [{ item: { contentid: 10 } }] }; const sourceData = makeSourceData({ - models: [makeModel(1, 'Post')], + models: [makeModel(1, "Post")], containers: [makeContainer(100, 1)], - content: [makeContent(10, 'Post', 'ref-10', { hero: 'https://cdn.aglty.io/hero.jpg' })], + content: [makeContent(10, "Post", "ref-10", { hero: "https://cdn.aglty.io/hero.jpg" })], templates: [makeTemplate(500, [{ contentViewID: 100 }])], pages: [makePage(300, { pageTemplateID: 500, zones })], - assets: [makeAsset('https://cdn.aglty.io/hero.jpg')], + assets: [makeAsset("https://cdn.aglty.io/hero.jpg")], }); const builder = new ModelDependencyTreeBuilder(sourceData); - const tree = builder.buildDependencyTree(['Post'], 'website'); + const tree = builder.buildDependencyTree(["Post"], "website"); - expect(tree.models.has('Post')).toBe(true); + expect(tree.models.has("Post")).toBe(true); expect(tree.containers.has(100)).toBe(true); expect(tree.content.has(10)).toBe(true); expect(tree.templates.has(500)).toBe(true); expect(tree.pages.has(300)).toBe(true); - expect(tree.assets.has('https://cdn.aglty.io/hero.jpg')).toBe(true); + expect(tree.assets.has("https://cdn.aglty.io/hero.jpg")).toBe(true); }); }); diff --git a/src/lib/publishers/batch-publisher.ts b/src/lib/publishers/batch-publisher.ts index b3e0d95..2a513af 100644 --- a/src/lib/publishers/batch-publisher.ts +++ b/src/lib/publishers/batch-publisher.ts @@ -6,20 +6,18 @@ import { state } from "../../core/state"; * @param batchId - Target batch ID to publish * @returns Promise with batch publish result */ -export async function publishBatch( - batchId: number -): Promise<{ success: boolean; batchId: string; error?: string }> { +export async function publishBatch(batchId: number): Promise<{ success: boolean; batchId: string; error?: string }> { try { // Get state values instead of parameters - const { getApiClient } = await import('../../core/state'); -const apiClient = getApiClient(); + const { getApiClient } = await import("../../core/state"); + const apiClient = getApiClient(); const targetGuid = state.targetGuid; if (!apiClient) { - throw new Error('API client not available in state'); + throw new Error("API client not available in state"); } if (!targetGuid?.length) { - throw new Error('Target GUID not available in state'); + throw new Error("Target GUID not available in state"); } // Try different batch publishing API methods depending on SDK version @@ -29,9 +27,8 @@ const apiClient = getApiClient(); return { success: true, - batchId: batchId.toString() + batchId: batchId.toString(), }; - } catch (error: any) { return { success: false, diff --git a/src/lib/publishers/content-item-publisher.ts b/src/lib/publishers/content-item-publisher.ts index 1266c8b..ffc9121 100644 --- a/src/lib/publishers/content-item-publisher.ts +++ b/src/lib/publishers/content-item-publisher.ts @@ -1,48 +1,48 @@ /** * Simple Content Item Publisher Function - * + * * Mirrors the SDK pattern: apiClient.contentMethods.publishContent(id) */ -import { state } from '../../core/state'; +import { state } from "../../core/state"; /** * Simple content item publisher function - mirrors apiClient.contentMethods.publishContent - * + * * @param contentId - Target content ID to publish * @returns Promise with publish result */ export async function publishContentItem( - contentId: number, - locale: string + contentId: number, + locale: string ): Promise<{ success: boolean; contentId: number; error?: string }> { - try { - // Get state values instead of parameters - const { getApiClient } = await import('../../core/state'); -const apiClient = getApiClient(); - const targetGuid = state.targetGuid; + try { + // Get state values instead of parameters + const { getApiClient } = await import("../../core/state"); + const apiClient = getApiClient(); + const targetGuid = state.targetGuid; - if (!apiClient) { - throw new Error('API client not available in state'); - } - if (!targetGuid?.length) { - throw new Error('Target GUID not available in state'); - } - if (!locale) { - throw new Error('Locale not available in state'); - } - - const result = await apiClient.contentMethods.publishContent(contentId, targetGuid[0], locale); - - return { - success: true, - contentId: contentId - }; - } catch (error: any) { - return { - success: false, - contentId: contentId, - error: error.message || 'Unknown publishing error' - }; + if (!apiClient) { + throw new Error("API client not available in state"); + } + if (!targetGuid?.length) { + throw new Error("Target GUID not available in state"); + } + if (!locale) { + throw new Error("Locale not available in state"); } -} + + const result = await apiClient.contentMethods.publishContent(contentId, targetGuid[0], locale); + + return { + success: true, + contentId: contentId, + }; + } catch (error: any) { + return { + success: false, + contentId: contentId, + error: error.message || "Unknown publishing error", + }; + } +} diff --git a/src/lib/publishers/content-list-publisher.ts b/src/lib/publishers/content-list-publisher.ts index 5c6c6ff..f5485dd 100644 --- a/src/lib/publishers/content-list-publisher.ts +++ b/src/lib/publishers/content-list-publisher.ts @@ -1,49 +1,49 @@ /** * Simple Content List Publisher Function - * + * * Mirrors the SDK pattern: apiClient.contentMethods.publishContent(id) for content lists */ -import { state } from '../../core/state'; +import { state } from "../../core/state"; /** * Simple content list publisher function - mirrors apiClient.contentMethods.publishContent for lists - * + * * @param contentListId - Target content list ID to publish * @returns Promise with publish result */ export async function publishContentList( - contentListId: number, - locale: string + contentListId: number, + locale: string ): Promise<{ success: boolean; contentListId: number; error?: string }> { - try { - // Get state values instead of parameters - const { getApiClient } = await import('../../core/state'); -const apiClient = getApiClient(); - const { targetGuid } = state; + try { + // Get state values instead of parameters + const { getApiClient } = await import("../../core/state"); + const apiClient = getApiClient(); + const { targetGuid } = state; - if (!apiClient) { - throw new Error('API client not available in state'); - } - if (!targetGuid?.length) { - throw new Error('Target GUID not available in state'); - } - if (!locale) { - throw new Error('Locale not available in state'); - } - - // Content lists use the same publish API as content items - await apiClient.contentMethods.publishContent(contentListId, targetGuid[0], locale); - - return { - success: true, - contentListId: contentListId - }; - } catch (error: any) { - return { - success: false, - contentListId: contentListId, - error: error.message || 'Unknown publishing error' - }; + if (!apiClient) { + throw new Error("API client not available in state"); + } + if (!targetGuid?.length) { + throw new Error("Target GUID not available in state"); + } + if (!locale) { + throw new Error("Locale not available in state"); } -} + + // Content lists use the same publish API as content items + await apiClient.contentMethods.publishContent(contentListId, targetGuid[0], locale); + + return { + success: true, + contentListId: contentListId, + }; + } catch (error: any) { + return { + success: false, + contentListId: contentListId, + error: error.message || "Unknown publishing error", + }; + } +} diff --git a/src/lib/publishers/index.ts b/src/lib/publishers/index.ts index 507bd86..f2180bb 100644 --- a/src/lib/publishers/index.ts +++ b/src/lib/publishers/index.ts @@ -1,65 +1,53 @@ /** * Publisher Functions - Simple SDK Mirroring - * + * * This module provides simple publisher functions that mirror the SDK patterns exactly. * These functions are lightweight wrappers around the Management SDK publishing methods. - * + * * NOTE: Batch workflow operations have been consolidated into src/core/batch-workflows.ts * The exports below are re-exported from their new locations. */ // Simple publisher functions - mirror SDK patterns -export { publishContentItem } from './content-item-publisher'; -export { publishPage } from './page-publisher'; -export { publishContentList } from './content-list-publisher'; -export { publishBatch } from './batch-publisher'; +export { publishContentItem } from "./content-item-publisher"; +export { publishPage } from "./page-publisher"; +export { publishContentList } from "./content-list-publisher"; +export { publishBatch } from "./batch-publisher"; // Re-export from consolidated batch-workflows service in core -export { - batchWorkflow, - type BatchItemType, - createBatches -} from '../../core/batch-workflows'; +export { batchWorkflow, type BatchItemType, createBatches } from "../../core/batch-workflows"; // Re-export workflow module -export { - workflowOrchestrator, - parseWorkflowOptions, - getOperationName -} from '../workflows'; +export { workflowOrchestrator, parseWorkflowOptions, getOperationName } from "../workflows"; // Re-export all workflow types from central types folder export { - WorkflowOperationType, - BatchWorkflowResult, - WorkflowOrchestratorResult, - WorkflowOptions, - ContentMapping, - PageMapping, - MappingReadResult, - MappingUpdateResult, - ItemState, - SourceItemData, - PublishStatusResult -} from '../../types'; + WorkflowOperationType, + BatchWorkflowResult, + WorkflowOrchestratorResult, + WorkflowOptions, + ContentMapping, + PageMapping, + MappingReadResult, + MappingUpdateResult, + ItemState, + SourceItemData, + PublishStatusResult, +} from "../../types"; // Re-export mapping utilities from mappers (moved from publishers) export { - updateMappingsAfterPublish, - updateContentMappingsAfterPublish, - updatePageMappingsAfterPublish -} from '../mappers/mapping-version-updater'; + updateMappingsAfterPublish, + updateContentMappingsAfterPublish, + updatePageMappingsAfterPublish, +} from "../mappers/mapping-version-updater"; -export { - readMappingsForGuidPair, - listAvailableMappingPairs, - getMappingSummary -} from '../mappers/mapping-reader'; +export { readMappingsForGuidPair, listAvailableMappingPairs, getMappingSummary } from "../mappers/mapping-reader"; // Re-export source publish status checker functions from shared (moved from publishers) export { - checkSourcePublishStatus, - filterPublishedContent, - filterPublishedPages, - isPublished -} from '../shared/source-publish-status-checker'; \ No newline at end of file + checkSourcePublishStatus, + filterPublishedContent, + filterPublishedPages, + isPublished, +} from "../shared/source-publish-status-checker"; diff --git a/src/lib/publishers/page-publisher.ts b/src/lib/publishers/page-publisher.ts index 79f53b5..175b5ec 100644 --- a/src/lib/publishers/page-publisher.ts +++ b/src/lib/publishers/page-publisher.ts @@ -1,42 +1,42 @@ -import { state } from '../../core/state'; +import { state } from "../../core/state"; /** * Simple page publisher function - mirrors apiClient.pageMethods.publishPage - * + * * @param pageId - Target page ID to publish * @returns Promise with publish result */ export async function publishPage( - pageId: number, - locale: string + pageId: number, + locale: string ): Promise<{ success: boolean; pageId: number; error?: string }> { - try { - // Get state values instead of parameters - const { getApiClient } = await import('../../core/state'); -const apiClient = getApiClient(); - const { targetGuid } = state; + try { + // Get state values instead of parameters + const { getApiClient } = await import("../../core/state"); + const apiClient = getApiClient(); + const { targetGuid } = state; - if (!apiClient) { - throw new Error('API client not available in state'); - } - if (!targetGuid?.length) { - throw new Error('Target GUID not available in state'); - } - if (!locale) { - throw new Error('Locale not available in state'); - } - - const result = await apiClient.pageMethods.publishPage(pageId, targetGuid[0], locale); - - return { - success: true, - pageId: pageId - }; - } catch (error: any) { - return { - success: false, - pageId: pageId, - error: error.message || 'Unknown publishing error' - }; + if (!apiClient) { + throw new Error("API client not available in state"); + } + if (!targetGuid?.length) { + throw new Error("Target GUID not available in state"); + } + if (!locale) { + throw new Error("Locale not available in state"); } -} + + const result = await apiClient.pageMethods.publishPage(pageId, targetGuid[0], locale); + + return { + success: true, + pageId: pageId, + }; + } catch (error: any) { + return { + success: false, + pageId: pageId, + error: error.message || "Unknown publishing error", + }; + } +} diff --git a/src/lib/publishers/tests/batch-publisher.test.ts b/src/lib/publishers/tests/batch-publisher.test.ts index a796309..57153db 100644 --- a/src/lib/publishers/tests/batch-publisher.test.ts +++ b/src/lib/publishers/tests/batch-publisher.test.ts @@ -1,87 +1,87 @@ -import { resetState, setState } from 'core/state'; +import { resetState, setState } from "core/state"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { jest.restoreAllMocks(); }); -import { publishBatch } from 'lib/publishers/batch-publisher'; +import { publishBatch } from "lib/publishers/batch-publisher"; // ─── publishBatch ───────────────────────────────────────────────────────────── -describe('publishBatch', () => { - describe('guard clause: no API client', () => { - it('returns success:false with an error message when getApiClient throws', async () => { +describe("publishBatch", () => { + describe("guard clause: no API client", () => { + it("returns success:false with an error message when getApiClient throws", async () => { // resetState leaves token null and mgmtApiOptions undefined → getApiClient throws const result = await publishBatch(42); expect(result.success).toBe(false); - expect(result.batchId).toBe('42'); + expect(result.batchId).toBe("42"); expect(result.error).toBeDefined(); }); }); - describe('guard clause: targetGuid array is empty', () => { - it('returns success:false with error message when targetGuid is []', async () => { - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + describe("guard clause: targetGuid array is empty", () => { + it("returns success:false with error message when targetGuid is []", async () => { + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ batchMethods: { publishBatch: jest.fn() }, }); const result = await publishBatch(10); expect(result.success).toBe(false); - expect(result.error).toContain('Target GUID not available in state'); + expect(result.error).toContain("Target GUID not available in state"); }); }); - describe('happy path', () => { - it('returns success:true with stringified batchId when API resolves', async () => { - setState({ targetGuid: 'test-guid-u' }); - const mockPublishBatch = jest.fn().mockResolvedValue({ status: 'ok' }); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + describe("happy path", () => { + it("returns success:true with stringified batchId when API resolves", async () => { + setState({ targetGuid: "test-guid-u" }); + const mockPublishBatch = jest.fn().mockResolvedValue({ status: "ok" }); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ batchMethods: { publishBatch: mockPublishBatch }, }); const result = await publishBatch(99); expect(result.success).toBe(true); - expect(result.batchId).toBe('99'); + expect(result.batchId).toBe("99"); expect(result.error).toBeUndefined(); }); - it('calls batchMethods.publishBatch with correct batchId, targetGuid, and true', async () => { - setState({ targetGuid: 'my-guid' }); + it("calls batchMethods.publishBatch with correct batchId, targetGuid, and true", async () => { + setState({ targetGuid: "my-guid" }); const mockPublishBatch = jest.fn().mockResolvedValue({}); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ batchMethods: { publishBatch: mockPublishBatch }, }); await publishBatch(7); - expect(mockPublishBatch).toHaveBeenCalledWith(7, 'my-guid', true); + expect(mockPublishBatch).toHaveBeenCalledWith(7, "my-guid", true); }); }); - describe('API error handling', () => { - it('returns success:false with the error message when API rejects', async () => { - setState({ targetGuid: 'test-guid-u' }); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + describe("API error handling", () => { + it("returns success:false with the error message when API rejects", async () => { + setState({ targetGuid: "test-guid-u" }); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ batchMethods: { - publishBatch: jest.fn().mockRejectedValue(new Error('Batch API failure')), + publishBatch: jest.fn().mockRejectedValue(new Error("Batch API failure")), }, }); const result = await publishBatch(5); expect(result.success).toBe(false); - expect(result.batchId).toBe('5'); - expect(result.error).toBe('Batch API failure'); + expect(result.batchId).toBe("5"); + expect(result.error).toBe("Batch API failure"); }); it('returns "Unknown batch publishing error" when error has no message', async () => { - setState({ targetGuid: 'test-guid-u' }); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + setState({ targetGuid: "test-guid-u" }); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ batchMethods: { publishBatch: jest.fn().mockRejectedValue({}), }, @@ -89,14 +89,14 @@ describe('publishBatch', () => { const result = await publishBatch(3); expect(result.success).toBe(false); - expect(result.error).toBe('Unknown batch publishing error'); + expect(result.error).toBe("Unknown batch publishing error"); }); }); - describe('return shape', () => { - it.each([1, 100, 99999])('always returns batchId as string for input %i', async (id) => { - setState({ targetGuid: 'test-guid-u' }); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + describe("return shape", () => { + it.each([1, 100, 99999])("always returns batchId as string for input %i", async (id) => { + setState({ targetGuid: "test-guid-u" }); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ batchMethods: { publishBatch: jest.fn().mockResolvedValue({}) }, }); diff --git a/src/lib/publishers/tests/content-item-publisher.test.ts b/src/lib/publishers/tests/content-item-publisher.test.ts index 736de3c..85bfcf0 100644 --- a/src/lib/publishers/tests/content-item-publisher.test.ts +++ b/src/lib/publishers/tests/content-item-publisher.test.ts @@ -1,120 +1,120 @@ -import { resetState, setState } from 'core/state'; +import { resetState, setState } from "core/state"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { jest.restoreAllMocks(); }); -import { publishContentItem } from 'lib/publishers/content-item-publisher'; +import { publishContentItem } from "lib/publishers/content-item-publisher"; // ─── publishContentItem ─────────────────────────────────────────────────────── -describe('publishContentItem', () => { - describe('guard clause: no API client', () => { - it('returns success:false when getApiClient throws (no token set)', async () => { - const result = await publishContentItem(1, 'en-us'); +describe("publishContentItem", () => { + describe("guard clause: no API client", () => { + it("returns success:false when getApiClient throws (no token set)", async () => { + const result = await publishContentItem(1, "en-us"); expect(result.success).toBe(false); expect(result.contentId).toBe(1); expect(result.error).toBeDefined(); }); }); - describe('guard clause: targetGuid array is empty', () => { - it('returns success:false with error message when targetGuid is []', async () => { - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + describe("guard clause: targetGuid array is empty", () => { + it("returns success:false with error message when targetGuid is []", async () => { + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ contentMethods: { publishContent: jest.fn() }, }); - const result = await publishContentItem(50, 'en-us'); + const result = await publishContentItem(50, "en-us"); expect(result.success).toBe(false); - expect(result.error).toContain('Target GUID not available in state'); + expect(result.error).toContain("Target GUID not available in state"); }); }); - describe('guard clause: empty locale', () => { - it('returns success:false when locale is an empty string', async () => { - setState({ targetGuid: 'test-guid-u' }); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + describe("guard clause: empty locale", () => { + it("returns success:false when locale is an empty string", async () => { + setState({ targetGuid: "test-guid-u" }); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ contentMethods: { publishContent: jest.fn().mockResolvedValue({}) }, }); - const result = await publishContentItem(10, ''); + const result = await publishContentItem(10, ""); expect(result.success).toBe(false); expect(result.contentId).toBe(10); - expect(result.error).toContain('Locale'); + expect(result.error).toContain("Locale"); }); }); - describe('happy path', () => { - it('returns success:true with original contentId when API resolves', async () => { - setState({ targetGuid: 'test-guid-u' }); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + describe("happy path", () => { + it("returns success:true with original contentId when API resolves", async () => { + setState({ targetGuid: "test-guid-u" }); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ contentMethods: { publishContent: jest.fn().mockResolvedValue({ ok: true }) }, }); - const result = await publishContentItem(200, 'en-us'); + const result = await publishContentItem(200, "en-us"); expect(result.success).toBe(true); expect(result.contentId).toBe(200); expect(result.error).toBeUndefined(); }); - it('calls contentMethods.publishContent with (contentId, targetGuid[0], locale)', async () => { - setState({ targetGuid: 'my-target' }); + it("calls contentMethods.publishContent with (contentId, targetGuid[0], locale)", async () => { + setState({ targetGuid: "my-target" }); const mockPublish = jest.fn().mockResolvedValue({}); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ contentMethods: { publishContent: mockPublish }, }); - await publishContentItem(42, 'fr-ca'); - expect(mockPublish).toHaveBeenCalledWith(42, 'my-target', 'fr-ca'); + await publishContentItem(42, "fr-ca"); + expect(mockPublish).toHaveBeenCalledWith(42, "my-target", "fr-ca"); }); }); - describe('API error handling', () => { - it('returns success:false with the error message when API rejects', async () => { - setState({ targetGuid: 'test-guid-u' }); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + describe("API error handling", () => { + it("returns success:false with the error message when API rejects", async () => { + setState({ targetGuid: "test-guid-u" }); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ contentMethods: { - publishContent: jest.fn().mockRejectedValue(new Error('Content publish failed')), + publishContent: jest.fn().mockRejectedValue(new Error("Content publish failed")), }, }); - const result = await publishContentItem(7, 'en-us'); + const result = await publishContentItem(7, "en-us"); expect(result.success).toBe(false); expect(result.contentId).toBe(7); - expect(result.error).toBe('Content publish failed'); + expect(result.error).toBe("Content publish failed"); }); it('returns "Unknown publishing error" when error has no message', async () => { - setState({ targetGuid: 'test-guid-u' }); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + setState({ targetGuid: "test-guid-u" }); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ contentMethods: { publishContent: jest.fn().mockRejectedValue({}), }, }); - const result = await publishContentItem(8, 'en-us'); + const result = await publishContentItem(8, "en-us"); expect(result.success).toBe(false); - expect(result.error).toBe('Unknown publishing error'); + expect(result.error).toBe("Unknown publishing error"); }); }); - describe('return shape', () => { - it.each([1, 500, 99999])('preserves contentId %i as a number in the result', async (id) => { - setState({ targetGuid: 'test-guid-u' }); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + describe("return shape", () => { + it.each([1, 500, 99999])("preserves contentId %i as a number in the result", async (id) => { + setState({ targetGuid: "test-guid-u" }); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ contentMethods: { publishContent: jest.fn().mockResolvedValue({}) }, }); - const result = await publishContentItem(id, 'en-us'); + const result = await publishContentItem(id, "en-us"); expect(result.contentId).toBe(id); - expect(typeof result.contentId).toBe('number'); + expect(typeof result.contentId).toBe("number"); }); }); }); diff --git a/src/lib/publishers/tests/content-list-publisher.test.ts b/src/lib/publishers/tests/content-list-publisher.test.ts index 00beef7..d5e4bda 100644 --- a/src/lib/publishers/tests/content-list-publisher.test.ts +++ b/src/lib/publishers/tests/content-list-publisher.test.ts @@ -1,120 +1,120 @@ -import { resetState, setState } from 'core/state'; +import { resetState, setState } from "core/state"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { jest.restoreAllMocks(); }); -import { publishContentList } from 'lib/publishers/content-list-publisher'; +import { publishContentList } from "lib/publishers/content-list-publisher"; // ─── publishContentList ─────────────────────────────────────────────────────── -describe('publishContentList', () => { - describe('guard clause: no API client', () => { - it('returns success:false when getApiClient throws (no token set)', async () => { - const result = await publishContentList(1, 'en-us'); +describe("publishContentList", () => { + describe("guard clause: no API client", () => { + it("returns success:false when getApiClient throws (no token set)", async () => { + const result = await publishContentList(1, "en-us"); expect(result.success).toBe(false); expect(result.contentListId).toBe(1); expect(result.error).toBeDefined(); }); }); - describe('guard clause: targetGuid array is empty', () => { - it('returns success:false with error message when targetGuid is []', async () => { - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + describe("guard clause: targetGuid array is empty", () => { + it("returns success:false with error message when targetGuid is []", async () => { + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ contentMethods: { publishContent: jest.fn() }, }); - const result = await publishContentList(20, 'en-us'); + const result = await publishContentList(20, "en-us"); expect(result.success).toBe(false); - expect(result.error).toContain('Target GUID not available in state'); + expect(result.error).toContain("Target GUID not available in state"); }); }); - describe('guard clause: empty locale', () => { - it('returns success:false when locale is an empty string', async () => { - setState({ targetGuid: 'test-guid-u' }); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + describe("guard clause: empty locale", () => { + it("returns success:false when locale is an empty string", async () => { + setState({ targetGuid: "test-guid-u" }); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ contentMethods: { publishContent: jest.fn().mockResolvedValue({}) }, }); - const result = await publishContentList(30, ''); + const result = await publishContentList(30, ""); expect(result.success).toBe(false); expect(result.contentListId).toBe(30); - expect(result.error).toContain('Locale'); + expect(result.error).toContain("Locale"); }); }); - describe('happy path', () => { - it('returns success:true with original contentListId when API resolves', async () => { - setState({ targetGuid: 'test-guid-u' }); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + describe("happy path", () => { + it("returns success:true with original contentListId when API resolves", async () => { + setState({ targetGuid: "test-guid-u" }); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ contentMethods: { publishContent: jest.fn().mockResolvedValue({ ok: true }) }, }); - const result = await publishContentList(300, 'en-us'); + const result = await publishContentList(300, "en-us"); expect(result.success).toBe(true); expect(result.contentListId).toBe(300); expect(result.error).toBeUndefined(); }); - it('calls contentMethods.publishContent with (contentListId, targetGuid[0], locale)', async () => { - setState({ targetGuid: 'list-target' }); + it("calls contentMethods.publishContent with (contentListId, targetGuid[0], locale)", async () => { + setState({ targetGuid: "list-target" }); const mockPublish = jest.fn().mockResolvedValue({}); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ contentMethods: { publishContent: mockPublish }, }); - await publishContentList(55, 'de-de'); - expect(mockPublish).toHaveBeenCalledWith(55, 'list-target', 'de-de'); + await publishContentList(55, "de-de"); + expect(mockPublish).toHaveBeenCalledWith(55, "list-target", "de-de"); }); }); - describe('API error handling', () => { - it('returns success:false with the error message when API rejects', async () => { - setState({ targetGuid: 'test-guid-u' }); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + describe("API error handling", () => { + it("returns success:false with the error message when API rejects", async () => { + setState({ targetGuid: "test-guid-u" }); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ contentMethods: { - publishContent: jest.fn().mockRejectedValue(new Error('List publish failed')), + publishContent: jest.fn().mockRejectedValue(new Error("List publish failed")), }, }); - const result = await publishContentList(9, 'en-us'); + const result = await publishContentList(9, "en-us"); expect(result.success).toBe(false); expect(result.contentListId).toBe(9); - expect(result.error).toBe('List publish failed'); + expect(result.error).toBe("List publish failed"); }); it('returns "Unknown publishing error" when error has no message', async () => { - setState({ targetGuid: 'test-guid-u' }); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + setState({ targetGuid: "test-guid-u" }); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ contentMethods: { publishContent: jest.fn().mockRejectedValue({}), }, }); - const result = await publishContentList(11, 'en-us'); + const result = await publishContentList(11, "en-us"); expect(result.success).toBe(false); - expect(result.error).toBe('Unknown publishing error'); + expect(result.error).toBe("Unknown publishing error"); }); }); - describe('return shape', () => { - it.each([1, 250, 88888])('preserves contentListId %i as a number in the result', async (id) => { - setState({ targetGuid: 'test-guid-u' }); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + describe("return shape", () => { + it.each([1, 250, 88888])("preserves contentListId %i as a number in the result", async (id) => { + setState({ targetGuid: "test-guid-u" }); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ contentMethods: { publishContent: jest.fn().mockResolvedValue({}) }, }); - const result = await publishContentList(id, 'en-us'); + const result = await publishContentList(id, "en-us"); expect(result.contentListId).toBe(id); - expect(typeof result.contentListId).toBe('number'); + expect(typeof result.contentListId).toBe("number"); }); }); }); diff --git a/src/lib/publishers/tests/page-publisher.test.ts b/src/lib/publishers/tests/page-publisher.test.ts index 6a2d2db..4cad7a1 100644 --- a/src/lib/publishers/tests/page-publisher.test.ts +++ b/src/lib/publishers/tests/page-publisher.test.ts @@ -1,120 +1,120 @@ -import { resetState, setState } from 'core/state'; +import { resetState, setState } from "core/state"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { jest.restoreAllMocks(); }); -import { publishPage } from 'lib/publishers/page-publisher'; +import { publishPage } from "lib/publishers/page-publisher"; // ─── publishPage ────────────────────────────────────────────────────────────── -describe('publishPage', () => { - describe('guard clause: no API client', () => { - it('returns success:false when getApiClient throws (no token set)', async () => { - const result = await publishPage(1, 'en-us'); +describe("publishPage", () => { + describe("guard clause: no API client", () => { + it("returns success:false when getApiClient throws (no token set)", async () => { + const result = await publishPage(1, "en-us"); expect(result.success).toBe(false); expect(result.pageId).toBe(1); expect(result.error).toBeDefined(); }); }); - describe('guard clause: targetGuid array is empty', () => { - it('returns success:false with error message when targetGuid is []', async () => { - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + describe("guard clause: targetGuid array is empty", () => { + it("returns success:false with error message when targetGuid is []", async () => { + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ pageMethods: { publishPage: jest.fn() }, }); - const result = await publishPage(15, 'en-us'); + const result = await publishPage(15, "en-us"); expect(result.success).toBe(false); - expect(result.error).toContain('Target GUID not available in state'); + expect(result.error).toContain("Target GUID not available in state"); }); }); - describe('guard clause: empty locale', () => { - it('returns success:false when locale is an empty string', async () => { - setState({ targetGuid: 'test-guid-u' }); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + describe("guard clause: empty locale", () => { + it("returns success:false when locale is an empty string", async () => { + setState({ targetGuid: "test-guid-u" }); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ pageMethods: { publishPage: jest.fn().mockResolvedValue({}) }, }); - const result = await publishPage(20, ''); + const result = await publishPage(20, ""); expect(result.success).toBe(false); expect(result.pageId).toBe(20); - expect(result.error).toContain('Locale'); + expect(result.error).toContain("Locale"); }); }); - describe('happy path', () => { - it('returns success:true with original pageId when API resolves', async () => { - setState({ targetGuid: 'test-guid-u' }); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + describe("happy path", () => { + it("returns success:true with original pageId when API resolves", async () => { + setState({ targetGuid: "test-guid-u" }); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ pageMethods: { publishPage: jest.fn().mockResolvedValue({ ok: true }) }, }); - const result = await publishPage(400, 'en-us'); + const result = await publishPage(400, "en-us"); expect(result.success).toBe(true); expect(result.pageId).toBe(400); expect(result.error).toBeUndefined(); }); - it('calls pageMethods.publishPage with (pageId, targetGuid[0], locale)', async () => { - setState({ targetGuid: 'page-target' }); + it("calls pageMethods.publishPage with (pageId, targetGuid[0], locale)", async () => { + setState({ targetGuid: "page-target" }); const mockPublish = jest.fn().mockResolvedValue({}); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ pageMethods: { publishPage: mockPublish }, }); - await publishPage(77, 'es-es'); - expect(mockPublish).toHaveBeenCalledWith(77, 'page-target', 'es-es'); + await publishPage(77, "es-es"); + expect(mockPublish).toHaveBeenCalledWith(77, "page-target", "es-es"); }); }); - describe('API error handling', () => { - it('returns success:false with the error message when API rejects', async () => { - setState({ targetGuid: 'test-guid-u' }); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + describe("API error handling", () => { + it("returns success:false with the error message when API rejects", async () => { + setState({ targetGuid: "test-guid-u" }); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ pageMethods: { - publishPage: jest.fn().mockRejectedValue(new Error('Page publish failed')), + publishPage: jest.fn().mockRejectedValue(new Error("Page publish failed")), }, }); - const result = await publishPage(13, 'en-us'); + const result = await publishPage(13, "en-us"); expect(result.success).toBe(false); expect(result.pageId).toBe(13); - expect(result.error).toBe('Page publish failed'); + expect(result.error).toBe("Page publish failed"); }); it('returns "Unknown publishing error" when error has no message', async () => { - setState({ targetGuid: 'test-guid-u' }); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + setState({ targetGuid: "test-guid-u" }); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ pageMethods: { publishPage: jest.fn().mockRejectedValue({}), }, }); - const result = await publishPage(14, 'en-us'); + const result = await publishPage(14, "en-us"); expect(result.success).toBe(false); - expect(result.error).toBe('Unknown publishing error'); + expect(result.error).toBe("Unknown publishing error"); }); }); - describe('return shape', () => { - it.each([1, 150, 77777])('preserves pageId %i as a number in the result', async (id) => { - setState({ targetGuid: 'test-guid-u' }); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ + describe("return shape", () => { + it.each([1, 150, 77777])("preserves pageId %i as a number in the result", async (id) => { + setState({ targetGuid: "test-guid-u" }); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ pageMethods: { publishPage: jest.fn().mockResolvedValue({}) }, }); - const result = await publishPage(id, 'en-us'); + const result = await publishPage(id, "en-us"); expect(result.pageId).toBe(id); - expect(typeof result.pageId).toBe('number'); + expect(typeof result.pageId).toBe("number"); }); }); }); diff --git a/src/lib/pushers/asset-pusher.ts b/src/lib/pushers/asset-pusher.ts index 684097e..94998fc 100644 --- a/src/lib/pushers/asset-pusher.ts +++ b/src/lib/pushers/asset-pusher.ts @@ -41,7 +41,7 @@ function extractErrorMessage(error: any): string { export async function pushAssets( sourceData: mgmtApi.Media[], // TODO: Type these targetData: mgmtApi.Media[], // TODO: Type these - onProgress?: (processed: number, total: number, status?: "success" | "error") => void, + onProgress?: (processed: number, total: number, status?: "success" | "error") => void ): Promise<{ status: "success" | "error"; successful: number; failed: number; skipped: number }> { // Extract data from sourceData - unified parameter pattern const assets: mgmtApi.Media[] = sourceData || []; @@ -142,7 +142,7 @@ export async function pushAssets( sourceGuid[0], targetGuid[0], referenceMapper, - logger, + logger ); referenceMapper.addMapping(media, createdAsset); successful++; @@ -156,7 +156,7 @@ export async function pushAssets( sourceGuid[0], targetGuid[0], referenceMapper, - logger, + logger ); referenceMapper.addMapping(media, updatedAsset); successful++; @@ -182,7 +182,7 @@ export async function pushAssets( } console.log( - ansiColors.yellow(`Processed ${successful}/${totalAssets} assets (${failed} failed, ${skipped} skipped)`), + ansiColors.yellow(`Processed ${successful}/${totalAssets} assets (${failed} failed, ${skipped} skipped)`) ); return { status: overallStatus, successful, failed, skipped }; } @@ -199,7 +199,7 @@ async function createAsset( sourceGuid: string, targetGuid: string, referenceMapper: AssetMapper, - logger: Logs, + logger: Logs ): Promise { // Handle gallery if present let targetMediaGroupingID = await resolveGalleryMapping(media, apiClient, sourceGuid, targetGuid); @@ -286,7 +286,7 @@ async function updateAsset( sourceGuid: string, targetGuid: string, referenceMapper: AssetMapper, - logger: Logs, + logger: Logs ): Promise { // Handle gallery if present let targetMediaGroupingID = await resolveGalleryMapping(media, apiClient, sourceGuid, targetGuid); @@ -354,7 +354,7 @@ async function resolveGalleryMapping( apiClient: mgmtApi.ApiClient, sourceGuid: string, - targetGuid: string, + targetGuid: string // referenceMapper: AssetMapper, ): Promise { let targetMediaGroupingID = -1; @@ -381,7 +381,7 @@ async function resolveGalleryMapping( } catch (error: any) { // Gallery doesn't exist - this is normal, asset will upload without gallery console.log( - `[Asset] Gallery ${media.mediaGroupingName} not found - asset will upload without gallery association`, + `[Asset] Gallery ${media.mediaGroupingName} not found - asset will upload without gallery association` ); } } diff --git a/src/lib/pushers/batch-polling.ts b/src/lib/pushers/batch-polling.ts index f392094..005aa2f 100644 --- a/src/lib/pushers/batch-polling.ts +++ b/src/lib/pushers/batch-polling.ts @@ -1,97 +1,97 @@ -import * as mgmtApi from '@agility/management-sdk'; -import ansiColors from 'ansi-colors'; +import * as mgmtApi from "@agility/management-sdk"; +import ansiColors from "ansi-colors"; export type CompletedBatch = mgmtApi.Batch & { - failedItems: FailedBatchItemFromApi[]; - totalItems?: number; - successCount?: number; - failureCount?: number; - durationMs?: number; + failedItems: FailedBatchItemFromApi[]; + totalItems?: number; + successCount?: number; + failureCount?: number; + durationMs?: number; }; export type FailedBatchItemFromApi = { - batchItemId: number; - itemId: number; - itemType: string; - errorType: string; - errorMessage: string; -} - + batchItemId: number; + itemId: number; + itemType: string; + errorType: string; + errorMessage: string; +}; /** * Extract the error message from a JSON error response or plain text * Handles both JSON format {"message":"..."} and plain text errors */ function extractErrorMessage(errorText: string): string { - if (!errorText) return 'Unknown error'; - - try { - // Try to parse as JSON - const parsed = JSON.parse(errorText); - // Return just the message field if it exists - if (parsed.message) { - return parsed.message; - } - // Fallback to exceptionType if no message - if (parsed.exceptionType) { - return `${parsed.exceptionType}: ${parsed.message || 'No details'}`; - } - return errorText; - } catch { - // Not JSON, return as-is but truncate if too long - // Also extract message from exception format: "ExceptionType: Message" - const exceptionMatch = errorText.match(/^[\w.]+Exception:\s*(.+?)(?:\r?\n|$)/); - if (exceptionMatch) { - return exceptionMatch[1].trim(); - } - return errorText.length > 200 ? errorText.substring(0, 200) + '...' : errorText; + if (!errorText) return "Unknown error"; + + try { + // Try to parse as JSON + const parsed = JSON.parse(errorText); + // Return just the message field if it exists + if (parsed.message) { + return parsed.message; + } + // Fallback to exceptionType if no message + if (parsed.exceptionType) { + return `${parsed.exceptionType}: ${parsed.message || "No details"}`; } + return errorText; + } catch { + // Not JSON, return as-is but truncate if too long + // Also extract message from exception format: "ExceptionType: Message" + const exceptionMatch = errorText.match(/^[\w.]+Exception:\s*(.+?)(?:\r?\n|$)/); + if (exceptionMatch) { + return exceptionMatch[1].trim(); + } + return errorText.length > 200 ? errorText.substring(0, 200) + "..." : errorText; + } } /** * Create a simple progress bar string */ function createProgressBar(percent: number, width: number = 20): string { - const filled = Math.round((percent / 100) * width); - const empty = width - filled; - return `[${'█'.repeat(filled)}${'░'.repeat(empty)}]`; + const filled = Math.round((percent / 100) * width); + const empty = width - filled; + return `[${"█".repeat(filled)}${"░".repeat(empty)}]`; } function logBatchErrors(batchStatus: CompletedBatch, originalPayloads?: any[]): void { - // Prefer structured failedItems array from new API - if (Array.isArray(batchStatus.failedItems) && batchStatus.failedItems.length > 0) { - batchStatus.failedItems.forEach((failed: any) => { - const batchItemId = failed.batchItemId ?? failed.batchItemID ?? '?'; - const errorType = failed.errorType || 'Error'; - const errorMessage = failed.errorMessage || 'Unknown error'; - const itemType = failed.itemType || 'Item'; - - // Try to find the original payload by batchItemId to get referenceName - let referenceName = 'unknown'; - if (originalPayloads) { - // batchItemId typically corresponds to index in the batch - const payload = originalPayloads.find((p, idx) => p?.batchItemId === batchItemId) - || originalPayloads[batchStatus.failedItems.indexOf(failed)]; - referenceName = payload?.properties?.referenceName || payload?.referenceName || 'unknown'; - } - - console.error(ansiColors.red(` ✗ ${itemType} ${batchItemId} (${referenceName}): ${errorMessage}`)); - }); - return; - } - - // Fallback to legacy items array with errorMessage field - if (Array.isArray(batchStatus.items)) { - batchStatus.items.forEach((item: any, index: number) => { - if (item.errorMessage) { - const errorMessage = extractErrorMessage(item.errorMessage); - const batchItemId = item.batchItemID || item.batchItemId || `idx:${index}`; - const referenceName = originalPayloads?.[index]?.properties?.referenceName || 'unknown'; - - console.error(ansiColors.red(` ✗ Batch item ${batchItemId} (${referenceName}): ${errorMessage}`)); - } - }); - } + // Prefer structured failedItems array from new API + if (Array.isArray(batchStatus.failedItems) && batchStatus.failedItems.length > 0) { + batchStatus.failedItems.forEach((failed: any) => { + const batchItemId = failed.batchItemId ?? failed.batchItemID ?? "?"; + const errorType = failed.errorType || "Error"; + const errorMessage = failed.errorMessage || "Unknown error"; + const itemType = failed.itemType || "Item"; + + // Try to find the original payload by batchItemId to get referenceName + let referenceName = "unknown"; + if (originalPayloads) { + // batchItemId typically corresponds to index in the batch + const payload = + originalPayloads.find((p, idx) => p?.batchItemId === batchItemId) || + originalPayloads[batchStatus.failedItems.indexOf(failed)]; + referenceName = payload?.properties?.referenceName || payload?.referenceName || "unknown"; + } + + console.error(ansiColors.red(` ✗ ${itemType} ${batchItemId} (${referenceName}): ${errorMessage}`)); + }); + return; + } + + // Fallback to legacy items array with errorMessage field + if (Array.isArray(batchStatus.items)) { + batchStatus.items.forEach((item: any, index: number) => { + if (item.errorMessage) { + const errorMessage = extractErrorMessage(item.errorMessage); + const batchItemId = item.batchItemID || item.batchItemId || `idx:${index}`; + const referenceName = originalPayloads?.[index]?.properties?.referenceName || "unknown"; + + console.error(ansiColors.red(` ✗ Batch item ${batchItemId} (${referenceName}): ${errorMessage}`)); + } + }); + } } /** BatchState.Processed = 3 */ @@ -101,15 +101,15 @@ const BATCH_STATE_PROCESSED = 3; * Normalize batch response so we handle both camelCase (SDK) and PascalCase (.NET API). */ function normalizeBatchStatus(batchStatus: any): CompletedBatch { - if (!batchStatus) return batchStatus; - return { - ...batchStatus, - batchState: batchStatus.batchState ?? batchStatus.BatchState, - numItemsProcessed: batchStatus.numItemsProcessed ?? batchStatus.NumItemsProcessed, - percentComplete: batchStatus.percentComplete ?? batchStatus.PercentComplete, - items: batchStatus.items ?? batchStatus.Items ?? [], - errorData: batchStatus.errorData ?? batchStatus.ErrorData, - }; + if (!batchStatus) return batchStatus; + return { + ...batchStatus, + batchState: batchStatus.batchState ?? batchStatus.BatchState, + numItemsProcessed: batchStatus.numItemsProcessed ?? batchStatus.NumItemsProcessed, + percentComplete: batchStatus.percentComplete ?? batchStatus.PercentComplete, + items: batchStatus.items ?? batchStatus.Items ?? [], + errorData: batchStatus.errorData ?? batchStatus.ErrorData, + }; } /** @@ -118,271 +118,278 @@ function normalizeBatchStatus(batchStatus: any): CompletedBatch { * (items with itemID > 0 or processedItemVersionID set are considered processed). */ function getNumProcessed(batchStatus: any): number { - const num = batchStatus?.numItemsProcessed ?? batchStatus?.NumItemsProcessed; - if (typeof num === 'number' && num >= 0) { - return num; - } - const items = batchStatus?.items ?? batchStatus?.Items; - if (!Array.isArray(items)) return 0; - return items.filter((item: any) => { - const id = item?.itemID ?? item?.itemId; - const versionId = item?.processedItemVersionID ?? item?.processedItemVersionId; - return (typeof id === 'number' && id > 0) || (versionId != null && versionId !== ''); - }).length; + const num = batchStatus?.numItemsProcessed ?? batchStatus?.NumItemsProcessed; + if (typeof num === "number" && num >= 0) { + return num; + } + const items = batchStatus?.items ?? batchStatus?.Items; + if (!Array.isArray(items)) return 0; + return items.filter((item: any) => { + const id = item?.itemID ?? item?.itemId; + const versionId = item?.processedItemVersionID ?? item?.processedItemVersionId; + return (typeof id === "number" && id > 0) || (versionId != null && versionId !== ""); + }).length; } /** * Simple batch polling function - polls until batch status is 3 (complete) */ export async function pollBatchUntilComplete( - apiClient: mgmtApi.ApiClient, - batchID: number, - targetGuid: string, - originalPayloads?: any[], // Original payloads for error matching - maxAttempts: number = 300, // 10 minutes at 2s intervals - increased from 120 - intervalMs: number = 2000, // 2 seconds - batchType?: string, // Type of batch for better logging - totalItems?: number // Total items in batch for progress display + apiClient: mgmtApi.ApiClient, + batchID: number, + targetGuid: string, + originalPayloads?: any[], // Original payloads for error matching + maxAttempts: number = 300, // 10 minutes at 2s intervals - increased from 120 + intervalMs: number = 2000, // 2 seconds + batchType?: string, // Type of batch for better logging + totalItems?: number // Total items in batch for progress display ): Promise { - let attempts = 0; - let consecutiveErrors = 0; - const startTime = Date.now(); - // Default totalItems from originalPayloads if not provided - const itemCount = totalItems ?? originalPayloads?.length ?? 0; - - // Show progress starting at 0 immediately so the bar doesn't jump on first poll - const batchTypeStr = batchType ? `${batchType} batch` : 'Batch'; - const initialBar = createProgressBar(0); - const initialLine = - itemCount > 0 - ? `${batchTypeStr} ${batchID}: ${initialBar} 0/${itemCount} (0s)` - : `${batchTypeStr} ${batchID}: ${initialBar} 0% (0s)`; - process.stdout.write(ansiColors.yellow.dim(initialLine) + ' '); - - while (attempts < maxAttempts) { + let attempts = 0; + let consecutiveErrors = 0; + const startTime = Date.now(); + // Default totalItems from originalPayloads if not provided + const itemCount = totalItems ?? originalPayloads?.length ?? 0; + + // Show progress starting at 0 immediately so the bar doesn't jump on first poll + const batchTypeStr = batchType ? `${batchType} batch` : "Batch"; + const initialBar = createProgressBar(0); + const initialLine = + itemCount > 0 + ? `${batchTypeStr} ${batchID}: ${initialBar} 0/${itemCount} (0s)` + : `${batchTypeStr} ${batchID}: ${initialBar} 0% (0s)`; + process.stdout.write(ansiColors.yellow.dim(initialLine) + " "); + + while (attempts < maxAttempts) { + try { + // Poll: get current batch status from API (expandItems=true by default in SDK) + const raw = await apiClient.batchMethods.getBatch(batchID, targetGuid); + const batchStatus = normalizeBatchStatus(raw); + + // Reset consecutive errors on successful API call + consecutiveErrors = 0; + + if (!batchStatus) { + attempts++; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + continue; + } + + const state = batchStatus.batchState; + if (state === BATCH_STATE_PROCESSED) { + // Clear the in-place progress line before logging completion/errors + process.stdout.write("\r" + " ".repeat(80) + "\r"); + // Batch complete - log errors using structured failedItems if available + logBatchErrors(batchStatus, originalPayloads); + return batchStatus; + } + + // Still in progress: show progress from this poll's response + const batchTypeStr = batchType ? `${batchType} batch` : "Batch"; + const numProcessed = getNumProcessed(batchStatus); + const elapsed = Math.round((Date.now() - startTime) / 1000); + const pct = batchStatus.percentComplete; + let line: string; + if (itemCount > 0) { + const percentComplete = Math.round((numProcessed / itemCount) * 100); + const progressBar = createProgressBar(percentComplete); + line = `${batchTypeStr} ${batchID}: ${progressBar} ${numProcessed}/${itemCount} (${elapsed}s)`; + } else { + const percentComplete = typeof pct === "number" && pct >= 0 ? pct : 0; + const progressBar = createProgressBar(percentComplete); + line = `${batchTypeStr} ${batchID}: ${progressBar} ${percentComplete}% (${elapsed}s)`; + } + process.stdout.write("\r" + ansiColors.yellow.dim(line) + " "); + if (batchStatus.errorData) { + console.log(`Error: ${batchStatus.errorData}`); + } + + attempts++; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } catch (error: any) { + consecutiveErrors++; + console.warn( + `⚠️ Error checking batch status (attempt ${attempts + 1}/${maxAttempts}, consecutive errors: ${consecutiveErrors}): ${error.message}` + ); + + // If we get too many consecutive errors, the batch might have failed + if (consecutiveErrors >= 10) { + console.warn(`⚠️ ${consecutiveErrors} consecutive errors - batch ${batchID} may have failed or been deleted`); + + // Try one more time with extended timeout before giving up try { - // Poll: get current batch status from API (expandItems=true by default in SDK) - const raw = await apiClient.batchMethods.getBatch(batchID, targetGuid); - const batchStatus = normalizeBatchStatus(raw); - - // Reset consecutive errors on successful API call - consecutiveErrors = 0; - - if (!batchStatus) { - attempts++; - await new Promise(resolve => setTimeout(resolve, intervalMs)); - continue; - } - - const state = batchStatus.batchState; - if (state === BATCH_STATE_PROCESSED) { - // Clear the in-place progress line before logging completion/errors - process.stdout.write('\r' + ' '.repeat(80) + '\r'); - // Batch complete - log errors using structured failedItems if available - logBatchErrors(batchStatus, originalPayloads); - return batchStatus; - } - - // Still in progress: show progress from this poll's response - const batchTypeStr = batchType ? `${batchType} batch` : 'Batch'; - const numProcessed = getNumProcessed(batchStatus); - const elapsed = Math.round((Date.now() - startTime) / 1000); - const pct = batchStatus.percentComplete; - let line: string; - if (itemCount > 0) { - const percentComplete = Math.round((numProcessed / itemCount) * 100); - const progressBar = createProgressBar(percentComplete); - line = `${batchTypeStr} ${batchID}: ${progressBar} ${numProcessed}/${itemCount} (${elapsed}s)`; - } else { - const percentComplete = typeof pct === 'number' && pct >= 0 ? pct : 0; - const progressBar = createProgressBar(percentComplete); - line = `${batchTypeStr} ${batchID}: ${progressBar} ${percentComplete}% (${elapsed}s)`; - } - process.stdout.write('\r' + ansiColors.yellow.dim(line) + ' '); - if (batchStatus.errorData) { - console.log(`Error: ${batchStatus.errorData}`); - } - - attempts++; - await new Promise(resolve => setTimeout(resolve, intervalMs)); - - } catch (error: any) { - consecutiveErrors++; - console.warn(`⚠️ Error checking batch status (attempt ${attempts + 1}/${maxAttempts}, consecutive errors: ${consecutiveErrors}): ${error.message}`); - - // If we get too many consecutive errors, the batch might have failed - if (consecutiveErrors >= 10) { - console.warn(`⚠️ ${consecutiveErrors} consecutive errors - batch ${batchID} may have failed or been deleted`); - - // Try one more time with extended timeout before giving up - try { - const finalRaw = await apiClient.batchMethods.getBatch(batchID, targetGuid); - const finalCheck = normalizeBatchStatus(finalRaw); - if (finalCheck?.batchState === BATCH_STATE_PROCESSED) { - console.log(`✅ Batch ${batchID} was actually successful! Polling errors were transient.`); - return finalCheck; - } - } catch (finalError) { - console.warn(`Final batch check also failed: ${finalError.message}`); - } - } - - attempts++; - if (attempts >= maxAttempts) { - process.stdout.write('\r' + ' '.repeat(80) + '\r\n'); - throw new Error(`Failed to poll batch ${batchID} after ${maxAttempts} attempts (${consecutiveErrors} consecutive errors): ${error.message}`); - } - - // Exponential backoff for errors, but cap at 10 seconds - const backoffMs = Math.min(intervalMs * Math.pow(1.5, consecutiveErrors), 10000); - await new Promise(resolve => setTimeout(resolve, backoffMs)); + const finalRaw = await apiClient.batchMethods.getBatch(batchID, targetGuid); + const finalCheck = normalizeBatchStatus(finalRaw); + if (finalCheck?.batchState === BATCH_STATE_PROCESSED) { + console.log(`✅ Batch ${batchID} was actually successful! Polling errors were transient.`); + return finalCheck; + } + } catch (finalError) { + console.warn(`Final batch check also failed: ${finalError.message}`); } + } + + attempts++; + if (attempts >= maxAttempts) { + process.stdout.write("\r" + " ".repeat(80) + "\r\n"); + throw new Error( + `Failed to poll batch ${batchID} after ${maxAttempts} attempts (${consecutiveErrors} consecutive errors): ${error.message}` + ); + } + + // Exponential backoff for errors, but cap at 10 seconds + const backoffMs = Math.min(intervalMs * Math.pow(1.5, consecutiveErrors), 10000); + await new Promise((resolve) => setTimeout(resolve, backoffMs)); } + } - process.stdout.write('\r' + ' '.repeat(80) + '\r\n'); - throw new Error(`Batch ${batchID} polling timed out after ${maxAttempts} attempts (~${Math.round(maxAttempts * intervalMs / 60000)} minutes)`); + process.stdout.write("\r" + " ".repeat(80) + "\r\n"); + throw new Error( + `Batch ${batchID} polling timed out after ${maxAttempts} attempts (~${Math.round((maxAttempts * intervalMs) / 60000)} minutes)` + ); } interface BatchSummary { - totalItems: number; - successCount: number; - failureCount: number; - durationMs: number; + totalItems: number; + successCount: number; + failureCount: number; + durationMs: number; } interface BatchSuccessItem { - originalItem: T; - newId: number; - newItem: mgmtApi.BatchItem; - index: number; + originalItem: T; + newId: number; + newItem: mgmtApi.BatchItem; + index: number; } interface BatchFailureItem { - originalItem: T | null; - newItem: null; - error: string; - errorType?: string; - itemType?: string; - batchItemId?: number; - index: number; + originalItem: T | null; + newItem: null; + error: string; + errorType?: string; + itemType?: string; + batchItemId?: number; + index: number; } interface BatchExtractResult { - successfulItems: BatchSuccessItem[]; - failedItems: BatchFailureItem[]; - summary?: BatchSummary; + successfulItems: BatchSuccessItem[]; + failedItems: BatchFailureItem[]; + summary?: BatchSummary; } function extractBatchResultsImpl(batch: CompletedBatch, originalItems: T[]): BatchExtractResult { - const successfulItems: BatchSuccessItem[] = []; - const failedItems: BatchFailureItem[] = []; - - const summary = batch?.totalItems !== undefined ? { - totalItems: batch.totalItems, - successCount: batch.successCount ?? 0, - failureCount: batch.failureCount ?? 0, - durationMs: batch.durationMs ?? 0 - } : undefined; - - // Fallback to legacy items array processing - if (!batch?.items || !Array.isArray(batch.items)) { - return { - successfulItems: [], - failedItems: originalItems.map((item, index) => ({ - originalItem: item, - newItem: null, - error: 'No batch items returned', - index - })), - summary - }; - } - - batch.items.forEach((item, index: number) => { - const originalItem = originalItems[index]; - - if (item.itemID > 0 && !item.itemNull) { - successfulItems.push({ originalItem, newId: item.itemID, newItem: item, index }); - } else { - let errorMsg = 'Failed to create item'; - if (item.errorMessage) { - errorMsg = extractErrorMessage(item.errorMessage); - } else if (!item.itemNull) { - errorMsg = `Invalid ID: ${item.itemID}`; - } - - // replace with new api error message if we can - const newApiFailedItem = batch.failedItems && batch.failedItems.length > 0 && batch.failedItems.find(fi => fi.batchItemId == item.batchItemID); - if(newApiFailedItem){ - errorMsg = newApiFailedItem.errorMessage; - } - - failedItems.push({ originalItem, newItem: null, error: errorMsg, index }); + const successfulItems: BatchSuccessItem[] = []; + const failedItems: BatchFailureItem[] = []; + + const summary = + batch?.totalItems !== undefined + ? { + totalItems: batch.totalItems, + successCount: batch.successCount ?? 0, + failureCount: batch.failureCount ?? 0, + durationMs: batch.durationMs ?? 0, } - }); + : undefined; - return { successfulItems, failedItems, summary }; -} + // Fallback to legacy items array processing + if (!batch?.items || !Array.isArray(batch.items)) { + return { + successfulItems: [], + failedItems: originalItems.map((item, index) => ({ + originalItem: item, + newItem: null, + error: "No batch items returned", + index, + })), + summary, + }; + } + + batch.items.forEach((item, index: number) => { + const originalItem = originalItems[index]; + + if (item.itemID > 0 && !item.itemNull) { + successfulItems.push({ originalItem, newId: item.itemID, newItem: item, index }); + } else { + let errorMsg = "Failed to create item"; + if (item.errorMessage) { + errorMsg = extractErrorMessage(item.errorMessage); + } else if (!item.itemNull) { + errorMsg = `Invalid ID: ${item.itemID}`; + } + + // replace with new api error message if we can + const newApiFailedItem = + batch.failedItems && + batch.failedItems.length > 0 && + batch.failedItems.find((fi) => fi.batchItemId == item.batchItemID); + if (newApiFailedItem) { + errorMsg = newApiFailedItem.errorMessage; + } + + failedItems.push({ originalItem, newItem: null, error: errorMsg, index }); + } + }); -export function extractContentBatchResults(batch: CompletedBatch, originalItems: mgmtApi.ContentItem[]): BatchExtractResult { - return extractBatchResultsImpl(batch, originalItems); + return { successfulItems, failedItems, summary }; } -export function extractPageBatchResults(batch: CompletedBatch, originalItems: mgmtApi.PageItem[]): BatchExtractResult { - return extractBatchResultsImpl(batch, originalItems); +export function extractContentBatchResults( + batch: CompletedBatch, + originalItems: mgmtApi.ContentItem[] +): BatchExtractResult { + return extractBatchResultsImpl(batch, originalItems); } +export function extractPageBatchResults( + batch: CompletedBatch, + originalItems: mgmtApi.PageItem[] +): BatchExtractResult { + return extractBatchResultsImpl(batch, originalItems); +} export function prettyException(error: string) { - -// TODO: regex out the exception type and message -// Item -1 failed with error: Agility.Shared.Exceptions.ManagementValidationException: The maximum length for the Message field is 1500 characters. -// at Agility.Shared.Engines.BatchProcessing.BatchInsertContentitem(String languageCode, BatchImportContentItem batchImportContentItem) in D:\a\_work\1\s\Agility CMS 2014\Agility.Shared\Engines\BatchProcessing\BatchProcessing_InsertContentItem.cs:line 398 -// at Agility.Shared.Engines.BatchProcessing.BatchInsertContent(Batch batch) in D:\a\_work\1\s\Agility CMS 2014\Agility.Shared\Engines\BatchProcessing\BatchProcessing.cs:line 1212 - - - - + // TODO: regex out the exception type and message + // Item -1 failed with error: Agility.Shared.Exceptions.ManagementValidationException: The maximum length for the Message field is 1500 characters. + // at Agility.Shared.Engines.BatchProcessing.BatchInsertContentitem(String languageCode, BatchImportContentItem batchImportContentItem) in D:\a\_work\1\s\Agility CMS 2014\Agility.Shared\Engines\BatchProcessing\BatchProcessing_InsertContentItem.cs:line 398 + // at Agility.Shared.Engines.BatchProcessing.BatchInsertContent(Batch batch) in D:\a\_work\1\s\Agility CMS 2014\Agility.Shared\Engines\BatchProcessing\BatchProcessing.cs:line 1212 } /** * Enhanced error logging for batch items with payload matching * This helps identify which specific payload caused the error using FIFO matching */ -export function logBatchError( - batchItem: any, - index: number, - originalPayload?: any -): void { - console.error(ansiColors.red(`⚠️ Item ${batchItem.itemID} (index ${index}) failed with error:`)); - console.error(ansiColors.red(batchItem.errorMessage)); - - // Clean batch item for display - const itemClean = { ...batchItem }; - delete itemClean.errorMessage; - console.log(ansiColors.gray.italic('📋 Batch Item Details:')); - console.log(ansiColors.gray.italic(JSON.stringify(itemClean, null, 2))); - - // Show the original payload that caused this error (FIFO matching) - if (originalPayload) { - console.log(ansiColors.yellow.italic('🔍 Original Payload that Failed:')); - - // Highlight key fields that might be causing issues - const keyFields = ['properties', 'fields', 'contentID', 'referenceName']; - const highlightedPayload: any = {}; - - keyFields.forEach(field => { - if (originalPayload[field] !== undefined) { - highlightedPayload[field] = originalPayload[field]; - } - }); - - // Show highlighted fields first - console.log(ansiColors.yellow.italic('Key Fields:')); - console.log(ansiColors.yellow.italic(JSON.stringify(highlightedPayload, null, 2))); - - // Show full payload if needed for debugging - console.log(ansiColors.gray.italic('Full Payload:')); - console.log(ansiColors.gray.italic(JSON.stringify(originalPayload, null, 2))); - } +export function logBatchError(batchItem: any, index: number, originalPayload?: any): void { + console.error(ansiColors.red(`⚠️ Item ${batchItem.itemID} (index ${index}) failed with error:`)); + console.error(ansiColors.red(batchItem.errorMessage)); + + // Clean batch item for display + const itemClean = { ...batchItem }; + delete itemClean.errorMessage; + console.log(ansiColors.gray.italic("📋 Batch Item Details:")); + console.log(ansiColors.gray.italic(JSON.stringify(itemClean, null, 2))); + + // Show the original payload that caused this error (FIFO matching) + if (originalPayload) { + console.log(ansiColors.yellow.italic("🔍 Original Payload that Failed:")); + + // Highlight key fields that might be causing issues + const keyFields = ["properties", "fields", "contentID", "referenceName"]; + const highlightedPayload: any = {}; + + keyFields.forEach((field) => { + if (originalPayload[field] !== undefined) { + highlightedPayload[field] = originalPayload[field]; + } + }); + + // Show highlighted fields first + console.log(ansiColors.yellow.italic("Key Fields:")); + console.log(ansiColors.yellow.italic(JSON.stringify(highlightedPayload, null, 2))); + + // Show full payload if needed for debugging + console.log(ansiColors.gray.italic("Full Payload:")); + console.log(ansiColors.gray.italic(JSON.stringify(originalPayload, null, 2))); + } } diff --git a/src/lib/pushers/container-pusher.ts b/src/lib/pushers/container-pusher.ts index 0e2103a..6c69252 100644 --- a/src/lib/pushers/container-pusher.ts +++ b/src/lib/pushers/container-pusher.ts @@ -52,7 +52,7 @@ export async function pushContainers( let currentStatus: "success" | "error" = "success"; let shouldCreate = false; let shouldSkip = false; - let shouldUpdate= false; + let shouldUpdate = false; const modelMapping = modelMapper.getModelMappingByID(sourceContainer.contentDefinitionID, "source"); let targetModelID = -1; @@ -68,17 +68,16 @@ export async function pushContainers( } try { - // STEP 1: Find existing mapping const existingMapping = containerMapper.getContainerMappingByContentViewID( sourceContainer.contentViewID, - "source", + "source" ); - // if no mapping found, we should be creating the container + // if no mapping found, we should be creating the container shouldCreate = existingMapping === null; - if(!shouldCreate){ + if (!shouldCreate) { // get the target container, check if the source and targets need updates const targetContainer: mgmtApi.Container = targetData.find( @@ -86,9 +85,13 @@ export async function pushContainers( targetContainer.contentViewID === existingMapping.targetContentViewID ) || null; - if(!targetContainer){ + if (!targetContainer) { // Container exists and is up to date - skip - logger.container.error(sourceContainer, `target container: ${existingMapping.targetReferenceName} was deleted, skipping!`, targetGuid[0]); + logger.container.error( + sourceContainer, + `target container: ${existingMapping.targetReferenceName} was deleted, skipping!`, + targetGuid[0] + ); skipped++; continue; } @@ -106,16 +109,15 @@ export async function pushContainers( if (targetModelID < 1) { logger.container.skipped(sourceContainer, "Target model mapping not found", targetGuid[0]); skipped++; - }else if (shouldSkip) { + } else if (shouldSkip) { // Container exists and is up to date - skip logger.container.skipped(sourceContainer, "up to date, skipping", targetGuid[0]); skipped++; - }else if (hasTargetChanges && !overwrite) { + } else if (hasTargetChanges && !overwrite) { // Container exists and is up to date - skip logger.container.error(sourceContainer, "Conflict detected, use --overwrite to force changes", targetGuid[0]); skipped++; - }else if (shouldUpdate) { - + } else if (shouldUpdate) { // Container exists but needs updating const updateResult = await updateExistingContainer( sourceContainer, @@ -123,7 +125,7 @@ export async function pushContainers( apiClient, targetGuid[0], targetModelID, - logger, + logger ); if (updateResult) { @@ -133,7 +135,7 @@ export async function pushContainers( if (sourceMapping !== targetMapping) { throw new Error( - `Invalid Mappings detected! Source containerID: ${sourceContainer.contentViewID}, Target containerID: ${targetContainer.contentViewID}`, + `Invalid Mappings detected! Source containerID: ${sourceContainer.contentViewID}, Target containerID: ${targetContainer.contentViewID}` ); } @@ -159,14 +161,13 @@ export async function pushContainers( logger.container.skipped(sourceContainer, "Target model mapping not found", targetGuid[0]); skipped++; } else { - // Container doesn't exist - create new one const createResult = await createNewContainer( sourceContainer, apiClient, targetGuid[0], targetModelID, - logger, + logger ); if (createResult) { @@ -213,7 +214,7 @@ async function updateExistingContainer( apiClient: ApiClient, targetGuid: string, targetModelId: number, - logger: Logs, + logger: Logs ): Promise { // Prepare update payload const updatePayload = { @@ -235,7 +236,7 @@ async function createNewContainer( apiClient: ApiClient, targetGuid: string, targetModelId: number, - logger: Logs, + logger: Logs ): Promise { // Prepare creation payload const createPayload = { diff --git a/src/lib/pushers/content-pusher/content-batch-processor.ts b/src/lib/pushers/content-pusher/content-batch-processor.ts index bab0a1c..893a99e 100644 --- a/src/lib/pushers/content-pusher/content-batch-processor.ts +++ b/src/lib/pushers/content-pusher/content-batch-processor.ts @@ -39,13 +39,13 @@ export class ContentBatchProcessor { async processBatches( contentItems: mgmtApi.ContentItem[], logger: Logs, - batchType?: string, + batchType?: string ): Promise { const batchSize = this.config.batchSize!; const contentBatches = this.createContentBatches(contentItems, batchSize); console.log( - `Processing ${contentItems.length || 0} content items in ${contentBatches.length} bulk ${batchType || ""} batches`, + `Processing ${contentItems.length || 0} content items in ${contentBatches.length} bulk ${batchType || ""} batches` ); let totalSuccessCount = 0; @@ -69,7 +69,7 @@ export class ContentBatchProcessor { const progress = Math.round((batchNumber / contentBatches.length) * 100); console.log( - `[${progress}%] Bulk batch ${batchNumber}/${contentBatches.length}: Processing ${contentBatch.length} ${batchType} content items (ETA: ${etaMinutes}m)...`, + `[${progress}%] Bulk batch ${batchNumber}/${contentBatches.length}: Processing ${contentBatch.length} ${batchType} content items (ETA: ${etaMinutes}m)...` ); try { @@ -89,7 +89,7 @@ export class ContentBatchProcessor { contentPayloads, this.config.targetGuid, this.config.locale, - true, // returnBatchID flag + true // returnBatchID flag ); // Extract batch ID from array response @@ -104,7 +104,7 @@ export class ContentBatchProcessor { contentPayloads, // Pass original payloads for FIFO error matching 300, // maxAttempts 2000, // intervalMs - batchType || "Content", // Use provided batch type or default to 'Content' + batchType || "Content" // Use provided batch type or default to 'Content' ); // Extract results from completed batch using only items that were actually sent to the API. @@ -125,8 +125,8 @@ export class ContentBatchProcessor { if (stagingItems.length > 0 && state.autoPublish) { console.log( ansiColors.gray( - ` 📋 Skipping auto-publish for ${stagingItems.length} content item(s) (not published in source)`, - ), + ` 📋 Skipping auto-publish for ${stagingItems.length} content item(s) (not published in source)` + ) ); } @@ -165,7 +165,7 @@ export class ContentBatchProcessor { item.originalContent, `Type: ${batchType} - created`, this.config.locale, - state.targetGuid[0], + state.targetGuid[0] ); }); } @@ -240,7 +240,7 @@ export class ContentBatchProcessor { private async prepareContentPayloads( contentBatch: mgmtApi.ContentItem[], sourceGuid: string, - targetGuid: string, + targetGuid: string ): Promise<{ payloads: any[]; skippedCount: number; includedItems: mgmtApi.ContentItem[] }> { const payloads: any[] = []; const includedItems: mgmtApi.ContentItem[] = []; @@ -258,7 +258,7 @@ export class ContentBatchProcessor { //see if it's already mapped const existingMapping = this.config.referenceMapper.getContentItemMappingByContentID( contentItem.contentID, - "source", + "source" ); const payload = { @@ -272,7 +272,7 @@ export class ContentBatchProcessor { //map the content item to the target instance const modelMapping = modelMapper.getModelMappingByReferenceName( contentItem.properties.definitionName, - "source", + "source" ); try { @@ -293,7 +293,7 @@ export class ContentBatchProcessor { ].join("\n "); throw new Error( - `Source model not found for content definition: ${contentItem.properties.definitionName}\n ${errorDetails}`, + `Source model not found for content definition: ${contentItem.properties.definitionName}\n ${errorDetails}` ); } @@ -313,7 +313,7 @@ export class ContentBatchProcessor { // STEP 3: Find container using reference mapper (simplified) const containerMapping = containerMapper.getContainerMappingByReferenceName( contentItem.properties.referenceName, - "source", + "source" ); if (!containerMapping) { @@ -325,7 +325,7 @@ export class ContentBatchProcessor { // STEP 4: Check if content already exists using reference mapper (since filtering already happened) const existingMapping = this.config.referenceMapper.getContentItemMappingByContentID( contentItem.contentID, - "source", + "source" ); const existingTargetContentItem = this.config.referenceMapper.getMappedEntity(existingMapping, "target"); const isTargetContentItemDeleted = existingMapping && existingTargetContentItem == null; @@ -361,7 +361,7 @@ export class ContentBatchProcessor { // Only log field mapper issues if there are actual errors (not warnings) if (mappingResult.validationErrors > 0) { console.warn( - `⚠️ Field mapping errors for ${contentItem.properties.referenceName}: ${mappingResult.validationErrors} errors`, + `⚠️ Field mapping errors for ${contentItem.properties.referenceName}: ${mappingResult.validationErrors} errors` ); } @@ -417,8 +417,8 @@ export class ContentBatchProcessor { } catch (error: any) { console.error( ansiColors.yellow( - `✗ Orphaned content item ${contentItem.contentID}, skipping - ${error.message || "payload preparation failed"}.`, - ), + `✗ Orphaned content item ${contentItem.contentID}, skipping - ${error.message || "payload preparation failed"}.` + ) ); // Track skipped item and continue with the rest of the batch diff --git a/src/lib/pushers/content-pusher/content-pusher.ts b/src/lib/pushers/content-pusher/content-pusher.ts index 7b78a0c..b6db3d5 100644 --- a/src/lib/pushers/content-pusher/content-pusher.ts +++ b/src/lib/pushers/content-pusher/content-pusher.ts @@ -1,207 +1,195 @@ - // Removed finder imports - using mapper directly import ansiColors from "ansi-colors"; // Removed ContentBatchProcessor import - individual pusher only handles individual processing -import { getLoggerForGuid, state, registerFailedContent } from 'core/state'; +import { getLoggerForGuid, state, registerFailedContent } from "core/state"; import { ContentItemMapper } from "lib/mappers/content-item-mapper"; -import { filterContentItemsForProcessing } from './util/filter-content-items-for-processing'; -import { getContentItemTypes } from './util/get-content-item-types'; +import { filterContentItemsForProcessing } from "./util/filter-content-items-for-processing"; +import { getContentItemTypes } from "./util/get-content-item-types"; import { ModelMapper } from "lib/mappers/model-mapper"; import { ContentItem, Model } from "@agility/management-sdk"; import { ContainerMapper } from "lib/mappers/container-mapper"; -import { getApiClient } from 'core/state'; +import { getApiClient } from "core/state"; /** * Push content to the target instance */ -export async function pushContent( - sourceData: ContentItem[], - targetData: ContentItem[], - locale: string -): Promise { - - // Use batch pusher for better performance (default behavior) - const { ContentBatchProcessor } = await import('./content-batch-processor'); - - const { sourceGuid, targetGuid, overwrite, cachedApiClient: apiClient } = state; - const logger = getLoggerForGuid(sourceGuid[0]); - - const sourceGuidStr = sourceGuid[0]; - const targetGuidStr = targetGuid[0]; - - const modelMapper = new ModelMapper(sourceGuidStr, targetGuidStr); - const containerMapper = new ContainerMapper(sourceGuidStr, targetGuidStr); - const referenceMapper = new ContentItemMapper(sourceGuidStr, targetGuidStr, locale); - const contentItems = sourceData || []; +export async function pushContent(sourceData: ContentItem[], targetData: ContentItem[], locale: string): Promise { + // Use batch pusher for better performance (default behavior) + const { ContentBatchProcessor } = await import("./content-batch-processor"); + + const { sourceGuid, targetGuid, overwrite, cachedApiClient: apiClient } = state; + const logger = getLoggerForGuid(sourceGuid[0]); + + const sourceGuidStr = sourceGuid[0]; + const targetGuidStr = targetGuid[0]; + + const modelMapper = new ModelMapper(sourceGuidStr, targetGuidStr); + const containerMapper = new ContainerMapper(sourceGuidStr, targetGuidStr); + const referenceMapper = new ContentItemMapper(sourceGuidStr, targetGuidStr, locale); + const contentItems = sourceData || []; + + if (contentItems.length === 0) { + return { status: "success" as const, successful: 0, failed: 0, skipped: 0, publishableIds: [], failureDetails: [] }; + } + + // Deterministically classify content items based on list references (fulllist=true) + const { normalContentItems, linkedContentItems, skippedItems } = getContentItemTypes(contentItems, { + containerMapper, + modelMapper, + referenceMapper, + logger: logger as any, + }); + + let totalSuccessful = 0; + let totalFailed = 0; + let totalSkipped = 0; + const allPublishableIds: number[] = []; + const allFailureDetails: Array<{ + name: string; + error: string; + type?: "content" | "page"; + contentID?: number; + guid?: string; + locale?: string; + }> = []; + + try { + // Import getApiClient for both batch configurations + + // Account for pre-classification skips (missing mappings) + if (skippedItems && skippedItems.length > 0) { + totalSkipped += skippedItems.length; + } - if (contentItems.length === 0) { - return { status: "success" as const, successful: 0, failed: 0, skipped: 0, publishableIds: [], failureDetails: [] }; + // Process linked content items second (with dependencies) + if (linkedContentItems.length > 0) { + const linkedBatchConfig = { + apiClient: getApiClient(), + targetGuid: targetGuidStr, + sourceGuid: sourceGuidStr, + locale, + referenceMapper, + batchSize: 250, + useContentFieldMapper: true, + defaultAssetUrl: "", + }; + + const filteredLinkedContentItems = await filterContentItemsForProcessing({ + contentItems: linkedContentItems, + apiClient: getApiClient(), + targetGuid: targetGuidStr, + locale, + referenceMapper, + targetData, + logger, + }); + + const linkedBatchProcessor = new ContentBatchProcessor(linkedBatchConfig); + const linkedResult = await linkedBatchProcessor.processBatches( + filteredLinkedContentItems.itemsToProcess.reverse(), + logger, + "Linked Content" + ); + + totalSuccessful += linkedResult.successCount; + totalFailed += linkedResult.failureCount; + totalSkipped += filteredLinkedContentItems.skippedCount; + totalSkipped += linkedResult.skippedCount; + allPublishableIds.push(...linkedResult.publishableIds); + // Collect failure details for error summary and register in failed content registry + if (linkedResult.failedItems && linkedResult.failedItems.length > 0) { + linkedResult.failedItems.forEach((item) => { + const name = item.originalContent?.properties?.referenceName || "Unknown"; + const contentID = item.originalContent?.contentID; + allFailureDetails.push({ + name, + error: item.error, + type: "content", + contentID, + guid: sourceGuidStr, + locale, + }); + // Register in global registry so page pusher can provide better error messages + if (contentID) { + registerFailedContent(contentID, name, item.error, locale); + } + }); + } } - // Deterministically classify content items based on list references (fulllist=true) - const { normalContentItems, linkedContentItems, skippedItems } = getContentItemTypes(contentItems, { - containerMapper, - modelMapper, + // Process normal content items first (no dependencies) + if (normalContentItems.length > 0) { + const normalBatchConfig = { + apiClient: getApiClient(), + targetGuid: targetGuidStr, + sourceGuid: sourceGuidStr, + locale, + referenceMapper, + batchSize: 100, // Smaller batches for linked content due to complexity + useContentFieldMapper: true, + defaultAssetUrl: "", + }; + + const filteredNormalContentItems = await filterContentItemsForProcessing({ + contentItems: normalContentItems, + apiClient: getApiClient(), + targetGuid: targetGuidStr, + locale, referenceMapper, - logger: logger as any - }); - - - - let totalSuccessful = 0; - let totalFailed = 0; - let totalSkipped = 0; - const allPublishableIds: number[] = []; - const allFailureDetails: Array<{ name: string; error: string; type?: 'content' | 'page'; contentID?: number; guid?: string; locale?: string }> = []; - - try { - // Import getApiClient for both batch configurations - - - // Account for pre-classification skips (missing mappings) - if (skippedItems && skippedItems.length > 0) { - totalSkipped += skippedItems.length; - } - - // Process linked content items second (with dependencies) - if (linkedContentItems.length > 0) { - const linkedBatchConfig = { - apiClient: getApiClient(), - targetGuid: targetGuidStr, - sourceGuid: sourceGuidStr, - locale, - referenceMapper, - batchSize: 250, - useContentFieldMapper: true, - defaultAssetUrl: "", - }; - - const filteredLinkedContentItems = await filterContentItemsForProcessing({ - contentItems: linkedContentItems, - apiClient: getApiClient(), - targetGuid: targetGuidStr, - locale, - referenceMapper, - targetData, - logger - }); - - - - - const linkedBatchProcessor = new ContentBatchProcessor(linkedBatchConfig); - const linkedResult = await linkedBatchProcessor.processBatches( - filteredLinkedContentItems.itemsToProcess.reverse(), - logger, - "Linked Content" - ); - - - totalSuccessful += linkedResult.successCount; - totalFailed += linkedResult.failureCount; - totalSkipped += filteredLinkedContentItems.skippedCount; - totalSkipped += linkedResult.skippedCount; - allPublishableIds.push(...linkedResult.publishableIds); - // Collect failure details for error summary and register in failed content registry - if (linkedResult.failedItems && linkedResult.failedItems.length > 0) { - linkedResult.failedItems.forEach(item => { - const name = item.originalContent?.properties?.referenceName || 'Unknown'; - const contentID = item.originalContent?.contentID; - allFailureDetails.push({ - name, - error: item.error, - type: 'content', - contentID, - guid: sourceGuidStr, - locale - }); - // Register in global registry so page pusher can provide better error messages - if (contentID) { - registerFailedContent(contentID, name, item.error, locale); - } - }); - } - } - - // Process normal content items first (no dependencies) - if (normalContentItems.length > 0) { - const normalBatchConfig = { - apiClient: getApiClient(), - targetGuid: targetGuidStr, - sourceGuid: sourceGuidStr, - locale, - referenceMapper, - batchSize: 100, // Smaller batches for linked content due to complexity - useContentFieldMapper: true, - defaultAssetUrl: "", - }; - - const filteredNormalContentItems = await filterContentItemsForProcessing({ - contentItems: normalContentItems, - apiClient: getApiClient(), - targetGuid: targetGuidStr, - locale, - referenceMapper, - targetData, - logger - }); - const normalBatchProcessor = new ContentBatchProcessor(normalBatchConfig); - const normalResult = await normalBatchProcessor.processBatches( - filteredNormalContentItems.itemsToProcess as ContentItem[], - logger, - "Normal Content" - ); - - - - totalSuccessful += normalResult.successCount; - totalFailed += normalResult.failureCount; - totalSkipped += filteredNormalContentItems.skippedCount; - totalSkipped += normalResult.skippedCount; - allPublishableIds.push(...normalResult.publishableIds); - // Collect failure details for error summary and register in failed content registry - if (normalResult.failedItems && normalResult.failedItems.length > 0) { - normalResult.failedItems.forEach(item => { - const name = item.originalContent?.properties?.referenceName || 'Unknown'; - const contentID = item.originalContent?.contentID; - allFailureDetails.push({ - name, - error: item.error, - type: 'content', - contentID, - guid: sourceGuidStr, - locale - }); - // Register in global registry so page pusher can provide better error messages - if (contentID) { - registerFailedContent(contentID, name, item.error, locale); - } - }); - } - } - - // Convert batch result to expected PusherResult format - return { - status: (totalFailed > 0 ? "error" : "success") as "success" | "error", - successful: totalSuccessful, - failed: totalFailed, - skipped: totalSkipped, - publishableIds: allPublishableIds, - failureDetails: allFailureDetails, - }; - } catch (batchError: any) { - console.error(ansiColors.red(`❌ Batch processing failed: ${batchError.message}`)); - return { - status: "error" as const, - successful: totalSuccessful, - failed: totalFailed + 1, - skipped: totalSkipped, - publishableIds: allPublishableIds, - failureDetails: [...allFailureDetails, { name: 'Batch processing', error: batchError.message }], - }; + targetData, + logger, + }); + const normalBatchProcessor = new ContentBatchProcessor(normalBatchConfig); + const normalResult = await normalBatchProcessor.processBatches( + filteredNormalContentItems.itemsToProcess as ContentItem[], + logger, + "Normal Content" + ); + + totalSuccessful += normalResult.successCount; + totalFailed += normalResult.failureCount; + totalSkipped += filteredNormalContentItems.skippedCount; + totalSkipped += normalResult.skippedCount; + allPublishableIds.push(...normalResult.publishableIds); + // Collect failure details for error summary and register in failed content registry + if (normalResult.failedItems && normalResult.failedItems.length > 0) { + normalResult.failedItems.forEach((item) => { + const name = item.originalContent?.properties?.referenceName || "Unknown"; + const contentID = item.originalContent?.contentID; + allFailureDetails.push({ + name, + error: item.error, + type: "content", + contentID, + guid: sourceGuidStr, + locale, + }); + // Register in global registry so page pusher can provide better error messages + if (contentID) { + registerFailedContent(contentID, name, item.error, locale); + } + }); + } } + // Convert batch result to expected PusherResult format + return { + status: (totalFailed > 0 ? "error" : "success") as "success" | "error", + successful: totalSuccessful, + failed: totalFailed, + skipped: totalSkipped, + publishableIds: allPublishableIds, + failureDetails: allFailureDetails, + }; + } catch (batchError: any) { + console.error(ansiColors.red(`❌ Batch processing failed: ${batchError.message}`)); + return { + status: "error" as const, + successful: totalSuccessful, + failed: totalFailed + 1, + skipped: totalSkipped, + publishableIds: allPublishableIds, + failureDetails: [...allFailureDetails, { name: "Batch processing", error: batchError.message }], + }; + } } - - - diff --git a/src/lib/pushers/content-pusher/tests/content-pusher.test.ts b/src/lib/pushers/content-pusher/tests/content-pusher.test.ts index ef9259a..33ad15b 100644 --- a/src/lib/pushers/content-pusher/tests/content-pusher.test.ts +++ b/src/lib/pushers/content-pusher/tests/content-pusher.test.ts @@ -1,12 +1,12 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, setState } from 'core/state'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState } from "core/state"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-cp-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-cp-")); }); afterAll(() => { @@ -16,9 +16,9 @@ afterAll(() => { beforeEach(() => { resetState(); setState({ rootPath: tmpDir }); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -32,7 +32,7 @@ function makeContentItem(id: number, overrides: Record = {}): any { contentID: id, properties: { referenceName: `ref-${id}`, - definitionName: 'TestModel', + definitionName: "TestModel", versionID: 1, state: 2, itemOrder: id, @@ -46,18 +46,18 @@ function makeContentItem(id: number, overrides: Record = {}): any { // ─── guard clause: empty sourceData ────────────────────────────────────────── -describe('pushContent — empty sourceData guard', () => { - it('returns early with zero counts when sourceData is empty', async () => { +describe("pushContent — empty sourceData guard", () => { + it("returns early with zero counts when sourceData is empty", async () => { setState({ - token: 'test-token', - sourceGuid: 'src-guid', - targetGuid: 'tgt-guid', + token: "test-token", + sourceGuid: "src-guid", + targetGuid: "tgt-guid", }); - const { pushContent } = await import('../content-pusher'); - const result = await pushContent([], [], 'en-us'); + const { pushContent } = await import("../content-pusher"); + const result = await pushContent([], [], "en-us"); - expect(result.status).toBe('success'); + expect(result.status).toBe("success"); expect(result.successful).toBe(0); expect(result.failed).toBe(0); expect(result.skipped).toBe(0); @@ -65,63 +65,63 @@ describe('pushContent — empty sourceData guard', () => { expect(result.failureDetails).toHaveLength(0); }); - it('returns early with zero counts when sourceData is null/undefined (coerced to empty)', async () => { + it("returns early with zero counts when sourceData is null/undefined (coerced to empty)", async () => { setState({ - token: 'test-token', - sourceGuid: 'src-guid', - targetGuid: 'tgt-guid', + token: "test-token", + sourceGuid: "src-guid", + targetGuid: "tgt-guid", }); - const { pushContent } = await import('../content-pusher'); + const { pushContent } = await import("../content-pusher"); // null coerces to [] via `sourceData || []` - const result = await pushContent(null as any, [], 'en-us'); + const result = await pushContent(null as any, [], "en-us"); - expect(result.status).toBe('success'); + expect(result.status).toBe("success"); expect(result.successful).toBe(0); }); }); // ─── guard clause: result shape ─────────────────────────────────────────────── -describe('pushContent — result shape', () => { - it('result always has status, successful, failed, skipped, publishableIds, failureDetails', async () => { +describe("pushContent — result shape", () => { + it("result always has status, successful, failed, skipped, publishableIds, failureDetails", async () => { setState({ - token: 'test-token', - sourceGuid: 'src-guid', - targetGuid: 'tgt-guid', + token: "test-token", + sourceGuid: "src-guid", + targetGuid: "tgt-guid", }); - const { pushContent } = await import('../content-pusher'); - const result = await pushContent([], [], 'en-us'); + const { pushContent } = await import("../content-pusher"); + const result = await pushContent([], [], "en-us"); - expect(result).toHaveProperty('status'); - expect(result).toHaveProperty('successful'); - expect(result).toHaveProperty('failed'); - expect(result).toHaveProperty('skipped'); - expect(result).toHaveProperty('publishableIds'); - expect(result).toHaveProperty('failureDetails'); + expect(result).toHaveProperty("status"); + expect(result).toHaveProperty("successful"); + expect(result).toHaveProperty("failed"); + expect(result).toHaveProperty("skipped"); + expect(result).toHaveProperty("publishableIds"); + expect(result).toHaveProperty("failureDetails"); }); }); // ─── orchestration path: batch processing catch ─────────────────────────────── -describe('pushContent — batch processing error handling', () => { - it('returns status=error and increments failed count when ContentBatchProcessor throws', async () => { +describe("pushContent — batch processing error handling", () => { + it("returns status=error and increments failed count when ContentBatchProcessor throws", async () => { setState({ - token: 'test-token', - sourceGuid: 'src-guid', - targetGuid: 'tgt-guid', + token: "test-token", + sourceGuid: "src-guid", + targetGuid: "tgt-guid", }); // Mock ContentBatchProcessor at the module level so the dynamic import picks it up - jest.mock('../content-batch-processor', () => ({ + jest.mock("../content-batch-processor", () => ({ ContentBatchProcessor: jest.fn().mockImplementation(() => ({ - processBatches: jest.fn().mockRejectedValue(new Error('fatal batch error')), + processBatches: jest.fn().mockRejectedValue(new Error("fatal batch error")), })), })); // Also mock getContentItemTypes to classify items as normal (no mapper file I/O) - jest.mock('../util/get-content-item-types', () => ({ + jest.mock("../util/get-content-item-types", () => ({ getContentItemTypes: jest.fn().mockReturnValue({ normalContentItems: [makeContentItem(1)], linkedContentItems: [], @@ -130,7 +130,7 @@ describe('pushContent — batch processing error handling', () => { })); // Mock filterContentItemsForProcessing to avoid API calls - jest.mock('../util/filter-content-items-for-processing', () => ({ + jest.mock("../util/filter-content-items-for-processing", () => ({ filterContentItemsForProcessing: jest.fn().mockResolvedValue({ itemsToProcess: [makeContentItem(1)], itemsToSkip: [], @@ -139,57 +139,57 @@ describe('pushContent — batch processing error handling', () => { })); // Use isolated module to get fresh state with mocks - const { pushContent: pushContentMocked } = jest.requireActual('../content-pusher') as any; + const { pushContent: pushContentMocked } = jest.requireActual("../content-pusher") as any; // Note: since dynamic import caches modules, we test via the error path at a higher level. // The real test is that the catch block in pushContent returns status=error. // We verify this by calling with a non-empty array while mocks are in place. // Because jest.mock hoisting applies, the dynamic import inside pushContent will use mocked modules. - const { pushContent } = await import('../content-pusher'); - const result = await pushContent([makeContentItem(1)], [], 'en-us'); + const { pushContent } = await import("../content-pusher"); + const result = await pushContent([makeContentItem(1)], [], "en-us"); // Either it succeeds (if mocks didn't apply due to module cache) or returns an error // The important thing is that it returns a valid result shape regardless - expect(result).toHaveProperty('status'); - expect(['success', 'error']).toContain(result.status); + expect(result).toHaveProperty("status"); + expect(["success", "error"]).toContain(result.status); - jest.unmock('../content-batch-processor'); - jest.unmock('../util/get-content-item-types'); - jest.unmock('../util/filter-content-items-for-processing'); + jest.unmock("../content-batch-processor"); + jest.unmock("../util/get-content-item-types"); + jest.unmock("../util/filter-content-items-for-processing"); }); }); // ─── skipped items from pre-classification ──────────────────────────────────── -describe('pushContent — skipped items from getContentItemTypes', () => { - it('all items classified as skipped are counted in totalSkipped (empty input path)', async () => { +describe("pushContent — skipped items from getContentItemTypes", () => { + it("all items classified as skipped are counted in totalSkipped (empty input path)", async () => { setState({ - token: 'test-token', - sourceGuid: 'src-guid', - targetGuid: 'tgt-guid', + token: "test-token", + sourceGuid: "src-guid", + targetGuid: "tgt-guid", }); - const { pushContent } = await import('../content-pusher'); + const { pushContent } = await import("../content-pusher"); // Empty input triggers early return — skipped = 0 - const result = await pushContent([], [], 'en-us'); + const result = await pushContent([], [], "en-us"); expect(result.skipped).toBe(0); }); }); // ─── result.status derivation ──────────────────────────────────────────────── -describe('pushContent — status derivation', () => { - it('returns status=success when no failures occurred (empty input)', async () => { +describe("pushContent — status derivation", () => { + it("returns status=success when no failures occurred (empty input)", async () => { setState({ - token: 'test-token', - sourceGuid: 'src-guid', - targetGuid: 'tgt-guid', + token: "test-token", + sourceGuid: "src-guid", + targetGuid: "tgt-guid", }); - const { pushContent } = await import('../content-pusher'); - const result = await pushContent([], [], 'en-us'); + const { pushContent } = await import("../content-pusher"); + const result = await pushContent([], [], "en-us"); - expect(result.status).toBe('success'); + expect(result.status).toBe("success"); }); }); diff --git a/src/lib/pushers/content-pusher/util/are-content-dependencies-resolved.ts b/src/lib/pushers/content-pusher/util/are-content-dependencies-resolved.ts index 015dd1c..a5be427 100644 --- a/src/lib/pushers/content-pusher/util/are-content-dependencies-resolved.ts +++ b/src/lib/pushers/content-pusher/util/are-content-dependencies-resolved.ts @@ -1,23 +1,22 @@ - -import * as mgmtApi from '@agility/management-sdk'; -import { ContentItemMapper } from 'lib/mappers/content-item-mapper'; -import { hasUnresolvedContentReferences } from './has-unresolved-content-references'; +import * as mgmtApi from "@agility/management-sdk"; +import { ContentItemMapper } from "lib/mappers/content-item-mapper"; +import { hasUnresolvedContentReferences } from "./has-unresolved-content-references"; export function areContentDependenciesResolved( - contentItem: mgmtApi.ContentItem, - referenceMapper: ContentItemMapper, - models: mgmtApi.Model[] + contentItem: mgmtApi.ContentItem, + referenceMapper: ContentItemMapper, + models: mgmtApi.Model[] ): boolean { - if (!contentItem.fields) { - return true; // No fields, no dependencies - } + if (!contentItem.fields) { + return true; // No fields, no dependencies + } - // Find the model for this content item - const model = models.find(m => m.referenceName === contentItem.properties?.definitionName); - if (!model) { - return true; // No model, assume resolved - } + // Find the model for this content item + const model = models.find((m) => m.referenceName === contentItem.properties?.definitionName); + if (!model) { + return true; // No model, assume resolved + } - // Check each field for content references - return !hasUnresolvedContentReferences(contentItem.fields, referenceMapper); -} \ No newline at end of file + // Check each field for content references + return !hasUnresolvedContentReferences(contentItem.fields, referenceMapper); +} diff --git a/src/lib/pushers/content-pusher/util/change-detection.ts b/src/lib/pushers/content-pusher/util/change-detection.ts index 03af6fe..e80c9ec 100644 --- a/src/lib/pushers/content-pusher/util/change-detection.ts +++ b/src/lib/pushers/content-pusher/util/change-detection.ts @@ -18,7 +18,7 @@ export function changeDetection( sourceEntity: mgmtApi.ContentItem, targetEntity: mgmtApi.ContentItem | null, mapping: ContentItemMapping, - locale: string, + locale: string ): ChangeDetection { const { overwrite } = state; // Validate source entity structure diff --git a/src/lib/pushers/content-pusher/util/collect-list-reference-names.ts b/src/lib/pushers/content-pusher/util/collect-list-reference-names.ts index b640900..619176c 100644 --- a/src/lib/pushers/content-pusher/util/collect-list-reference-names.ts +++ b/src/lib/pushers/content-pusher/util/collect-list-reference-names.ts @@ -3,31 +3,30 @@ * that have fullList=true. Returns an array of reference names. */ export function collectListReferenceNames(fields: any): string[] { - const found: string[] = []; + const found: string[] = []; - function walk(node: any): void { - if (!node) return; + function walk(node: any): void { + if (!node) return; - if (Array.isArray(node)) { - for (const v of node) walk(v); - return; - } + if (Array.isArray(node)) { + for (const v of node) walk(v); + return; + } - if (typeof node === "object") { - const rn = (node as any).referencename || (node as any).referenceName; - const full = (node as any).fulllist === true || (node as any).fullList === true; + if (typeof node === "object") { + const rn = (node as any).referencename || (node as any).referenceName; + const full = (node as any).fulllist === true || (node as any).fullList === true; - if (typeof rn === "string" && full) { - found.push(rn); - } + if (typeof rn === "string" && full) { + found.push(rn); + } - for (const key of Object.keys(node)) { - walk((node as any)[key]); - } - } + for (const key of Object.keys(node)) { + walk((node as any)[key]); + } } + } - walk(fields); - return found; + walk(fields); + return found; } - diff --git a/src/lib/pushers/content-pusher/util/filter-content-items-for-processing.ts b/src/lib/pushers/content-pusher/util/filter-content-items-for-processing.ts index 14355ee..d7eee01 100644 --- a/src/lib/pushers/content-pusher/util/filter-content-items-for-processing.ts +++ b/src/lib/pushers/content-pusher/util/filter-content-items-for-processing.ts @@ -10,89 +10,93 @@ import { state } from "core"; * Moved from orchestrate-pushers.ts for better separation of concerns */ export interface ContentFilterResult { - itemsToProcess: any[]; - itemsToSkip: any[]; - skippedCount: number; + itemsToProcess: any[]; + itemsToSkip: any[]; + skippedCount: number; } interface FilterProp { - contentItems: ContentItem[]; - apiClient: ApiClient; - targetGuid: string; - locale: string; - referenceMapper: ContentItemMapper; - targetData: ContentItem[]; - logger: Logs; + contentItems: ContentItem[]; + apiClient: ApiClient; + targetGuid: string; + locale: string; + referenceMapper: ContentItemMapper; + targetData: ContentItem[]; + logger: Logs; } export async function filterContentItemsForProcessing({ - contentItems, - apiClient, - targetGuid, - locale, - referenceMapper, - targetData = [], - logger, + contentItems, + apiClient, + targetGuid, + locale, + referenceMapper, + targetData = [], + logger, }: FilterProp): Promise { - const itemsToProcess: any[] = []; - const itemsToSkip: any[] = []; + const itemsToProcess: any[] = []; + const itemsToSkip: any[] = []; - // Track decision stats for summary logging - let createCount = 0; - let updateCount = 0; - let skipCount = 0; - let conflictCount = 0; + // Track decision stats for summary logging + let createCount = 0; + let updateCount = 0; + let skipCount = 0; + let conflictCount = 0; - for (const contentItem of contentItems) { - const itemName = contentItem.properties.referenceName || "Unknown"; + for (const contentItem of contentItems) { + const itemName = contentItem.properties.referenceName || "Unknown"; - try { - const findResult = findContentInTargetInstance({ - sourceContent: contentItem, - referenceMapper - }); + try { + const findResult = findContentInTargetInstance({ + sourceContent: contentItem, + referenceMapper, + }); - const { content, shouldUpdate, shouldCreate, shouldSkip, isConflict, reason } = findResult; - if (isConflict) { - // CONFLICT DETECTED - log warning and skip - console.warn( - `⚠️ Conflict detected content ${ansiColors.underline(itemName)} ${ansiColors.bold.grey("changes detected in both source and target")}. Please resolve manually.` - ); - if (reason) { - console.warn(` ${reason}`); - } - itemsToSkip.push(contentItem); - conflictCount++; - continue; - } else if (shouldCreate) { - // Content doesn't exist - include it for creation - itemsToProcess.push(contentItem); - createCount++; - } else if (shouldUpdate) { - // Content exists but needs updating - itemsToProcess.push(contentItem); - updateCount++; - } else if (shouldSkip) { - // Content exists and is up to date - skip - logger.content.skipped(contentItem, "up to date, skipping", locale, targetGuid); - itemsToSkip.push(contentItem); - skipCount++; - } - } catch (error: any) { - // If we can't check, err on the side of processing it - logger.content.error(contentItem, error.message, locale, targetGuid); - itemsToProcess.push(contentItem); - } - } + const { content, shouldUpdate, shouldCreate, shouldSkip, isConflict, reason } = findResult; + if (isConflict) { + // CONFLICT DETECTED - log warning and skip + console.warn( + `⚠️ Conflict detected content ${ansiColors.underline(itemName)} ${ansiColors.bold.grey("changes detected in both source and target")}. Please resolve manually.` + ); + if (reason) { + console.warn(` ${reason}`); + } + itemsToSkip.push(contentItem); + conflictCount++; + continue; + } else if (shouldCreate) { + // Content doesn't exist - include it for creation + itemsToProcess.push(contentItem); + createCount++; + } else if (shouldUpdate) { + // Content exists but needs updating + itemsToProcess.push(contentItem); + updateCount++; + } else if (shouldSkip) { + // Content exists and is up to date - skip + logger.content.skipped(contentItem, "up to date, skipping", locale, targetGuid); + itemsToSkip.push(contentItem); + skipCount++; + } + } catch (error: any) { + // If we can't check, err on the side of processing it + logger.content.error(contentItem, error.message, locale, targetGuid); + itemsToProcess.push(contentItem); + } + } - // Log decision summary if verbose - if (state.verbose && contentItems.length > 0) { - console.log(ansiColors.gray(`[FilterContent] Decision summary: ${createCount} create, ${updateCount} update, ${skipCount} skip, ${conflictCount} conflict`)); - } + // Log decision summary if verbose + if (state.verbose && contentItems.length > 0) { + console.log( + ansiColors.gray( + `[FilterContent] Decision summary: ${createCount} create, ${updateCount} update, ${skipCount} skip, ${conflictCount} conflict` + ) + ); + } - return { - itemsToProcess, - itemsToSkip, - skippedCount: itemsToSkip.length, - }; -} \ No newline at end of file + return { + itemsToProcess, + itemsToSkip, + skippedCount: itemsToSkip.length, + }; +} diff --git a/src/lib/pushers/content-pusher/util/find-content-in-other-locale.ts b/src/lib/pushers/content-pusher/util/find-content-in-other-locale.ts index ef09d92..8cfec67 100644 --- a/src/lib/pushers/content-pusher/util/find-content-in-other-locale.ts +++ b/src/lib/pushers/content-pusher/util/find-content-in-other-locale.ts @@ -3,32 +3,29 @@ import { ContentItemMapper } from "lib/mappers/content-item-mapper"; import { PageMapper } from "lib/mappers/page-mapper"; interface Props { - sourceGuid: string; - targetGuid: string; - sourceContentID: number; - locale: string; + sourceGuid: string; + targetGuid: string; + sourceContentID: number; + locale: string; } export const findContentInOtherLocale = async ({ sourceContentID, locale, sourceGuid, targetGuid }: Props) => { - const { availableLocales } = state - - //loop the other locales and check the mapping to see if this page has been mapped in another locale. - for (const otherLocale of availableLocales) { - if (locale === otherLocale) continue; // Skip current locale - - const contentMapper = new ContentItemMapper(sourceGuid, targetGuid, otherLocale); - - try { - const mapping = contentMapper.getContentItemMappingByContentID(sourceContentID, "source"); - if (mapping) { - return mapping.targetContentID; // Return the target content ID if found - } - - } catch (error) { - console.error(`Error finding content in locale ${locale}:`, error); - } - } - - return -1; // Return -1 if no mapping found in other locales - - -} \ No newline at end of file + const { availableLocales } = state; + + //loop the other locales and check the mapping to see if this page has been mapped in another locale. + for (const otherLocale of availableLocales) { + if (locale === otherLocale) continue; // Skip current locale + + const contentMapper = new ContentItemMapper(sourceGuid, targetGuid, otherLocale); + + try { + const mapping = contentMapper.getContentItemMappingByContentID(sourceContentID, "source"); + if (mapping) { + return mapping.targetContentID; // Return the target content ID if found + } + } catch (error) { + console.error(`Error finding content in locale ${locale}:`, error); + } + } + + return -1; // Return -1 if no mapping found in other locales +}; diff --git a/src/lib/pushers/content-pusher/util/find-content-in-target-instance.ts b/src/lib/pushers/content-pusher/util/find-content-in-target-instance.ts index a994110..281dd06 100644 --- a/src/lib/pushers/content-pusher/util/find-content-in-target-instance.ts +++ b/src/lib/pushers/content-pusher/util/find-content-in-target-instance.ts @@ -1,69 +1,61 @@ -import * as mgmtApi from '@agility/management-sdk'; -import { getState } from 'core'; -import { ContentItemMapper } from 'lib/mappers/content-item-mapper'; -import { GuidEntities } from '../../guid-data-loader'; -import { ChangeDetection, changeDetection } from './change-detection'; +import * as mgmtApi from "@agility/management-sdk"; +import { getState } from "core"; +import { ContentItemMapper } from "lib/mappers/content-item-mapper"; +import { GuidEntities } from "../../guid-data-loader"; +import { ChangeDetection, changeDetection } from "./change-detection"; interface Props { - sourceContent: mgmtApi.ContentItem, - referenceMapper: ContentItemMapper + sourceContent: mgmtApi.ContentItem; + referenceMapper: ContentItemMapper; } interface FindResult { - content: mgmtApi.ContentItem | null; - shouldUpdate: boolean; - shouldCreate: boolean; - shouldSkip: boolean; - isConflict: boolean; - decision?: ChangeDetection; - reason?: string; + content: mgmtApi.ContentItem | null; + shouldUpdate: boolean; + shouldCreate: boolean; + shouldSkip: boolean; + isConflict: boolean; + decision?: ChangeDetection; + reason?: string; } /** * Enhanced content item finder with proper target safety and conflict resolution * Logic Flow: Target Safety FIRST → Change Delta SECOND → Conflict Resolution */ -export function findContentInTargetInstance({ - sourceContent, - referenceMapper -}: Props): FindResult { - const state = getState(); - const itemName = sourceContent.properties?.referenceName || `ID:${sourceContent.contentID}`; - - // STEP 1: Find existing mapping - - //GET FROM SOURCE MAPPING - const mapping = referenceMapper.getContentItemMappingByContentID(sourceContent.contentID, "source"); - const locale = referenceMapper.locale; - let targetContent: mgmtApi.ContentItem | null = null; - - if (mapping) { - // STEP 2: Find target content item using mapping - targetContent = referenceMapper.getMappedEntity(mapping, "target"); - - // Diagnostic: mapping exists but target entity file is missing - if (!targetContent && state.verbose) { - // console.log(`[FindContent] ${itemName}: Mapping exists (target ID: ${mapping.targetContentID}) but target entity file not found`); - } - } else if (state.verbose) { - // console.log(`[FindContent] ${itemName}: No mapping found for source content ID ${sourceContent.contentID}`); - } - - // STEP 3: Use change detection for conflict resolution - const decision = changeDetection( - sourceContent, - targetContent, - mapping, - locale - ); - - return { - content: decision.entity || null, - shouldUpdate: decision.shouldUpdate, - shouldCreate: decision.shouldCreate, - shouldSkip: decision.shouldSkip, - isConflict: decision.isConflict, - reason: decision.reason, - decision: decision - }; -} \ No newline at end of file +export function findContentInTargetInstance({ sourceContent, referenceMapper }: Props): FindResult { + const state = getState(); + const itemName = sourceContent.properties?.referenceName || `ID:${sourceContent.contentID}`; + + // STEP 1: Find existing mapping + + //GET FROM SOURCE MAPPING + const mapping = referenceMapper.getContentItemMappingByContentID(sourceContent.contentID, "source"); + const locale = referenceMapper.locale; + let targetContent: mgmtApi.ContentItem | null = null; + + if (mapping) { + // STEP 2: Find target content item using mapping + targetContent = referenceMapper.getMappedEntity(mapping, "target"); + + // Diagnostic: mapping exists but target entity file is missing + if (!targetContent && state.verbose) { + // console.log(`[FindContent] ${itemName}: Mapping exists (target ID: ${mapping.targetContentID}) but target entity file not found`); + } + } else if (state.verbose) { + // console.log(`[FindContent] ${itemName}: No mapping found for source content ID ${sourceContent.contentID}`); + } + + // STEP 3: Use change detection for conflict resolution + const decision = changeDetection(sourceContent, targetContent, mapping, locale); + + return { + content: decision.entity || null, + shouldUpdate: decision.shouldUpdate, + shouldCreate: decision.shouldCreate, + shouldSkip: decision.shouldSkip, + isConflict: decision.isConflict, + reason: decision.reason, + decision: decision, + }; +} diff --git a/src/lib/pushers/content-pusher/util/get-content-item-types.ts b/src/lib/pushers/content-pusher/util/get-content-item-types.ts index 48362a3..cafdce0 100644 --- a/src/lib/pushers/content-pusher/util/get-content-item-types.ts +++ b/src/lib/pushers/content-pusher/util/get-content-item-types.ts @@ -5,77 +5,68 @@ import { ContentItemMapper } from "lib/mappers/content-item-mapper"; import { hasValidMappings } from "./has-valid-mappings"; import { collectListReferenceNames } from "./collect-list-reference-names"; - - /** * Classifies content items into normal, linked, and skipped categories. - * + * * Normal items: Top-level items that are not referenced by other items * Linked items: Items that are referenced via fullList=true in other items' fields * Skipped items: Items without valid container/model mappings */ export function getContentItemTypes( - contentItems: ContentItem[], - opts: { - containerMapper: ContainerMapper, - modelMapper: ModelMapper, - referenceMapper: ContentItemMapper, - logger: any - } + contentItems: ContentItem[], + opts: { + containerMapper: ContainerMapper; + modelMapper: ModelMapper; + referenceMapper: ContentItemMapper; + logger: any; + } ): { - normalContentItems: ContentItem[], - linkedContentItems: ContentItem[], - skippedItems: ContentItem[] + normalContentItems: ContentItem[]; + linkedContentItems: ContentItem[]; + skippedItems: ContentItem[]; } { - const { containerMapper, modelMapper } = opts; - - // Build lookup maps for efficient access - const { allItemsById, itemsByReferenceName } = buildItemMaps(contentItems); - - // Track classification state - const normalSet = new Set(); - const linkedSet = new Set(); - const skipped: ContentItem[] = []; - - // Process each content item - for (const item of contentItems) { - if (!hasValidMappings(item, containerMapper, modelMapper)) { - skipped.push(item); - continue; - } - - // Items start as normal; referenced items get moved to linked - if(!linkedSet.has(item.contentID)){ - normalSet.add(item.contentID); - } - - // Find all list references in this item's fields - const referenceNames = collectListReferenceNames(item.fields || {}); - if (referenceNames.length > 0) { - markReferencedItems( - referenceNames, - itemsByReferenceName, - normalSet, - linkedSet, - skipped, - containerMapper, - modelMapper - ); - } + const { containerMapper, modelMapper } = opts; + + // Build lookup maps for efficient access + const { allItemsById, itemsByReferenceName } = buildItemMaps(contentItems); + + // Track classification state + const normalSet = new Set(); + const linkedSet = new Set(); + const skipped: ContentItem[] = []; + + // Process each content item + for (const item of contentItems) { + if (!hasValidMappings(item, containerMapper, modelMapper)) { + skipped.push(item); + continue; } - // Build final result arrays - const { normalContentItems, linkedContentItems } = buildResultArrays( + // Items start as normal; referenced items get moved to linked + if (!linkedSet.has(item.contentID)) { + normalSet.add(item.contentID); + } + + // Find all list references in this item's fields + const referenceNames = collectListReferenceNames(item.fields || {}); + if (referenceNames.length > 0) { + markReferencedItems( + referenceNames, + itemsByReferenceName, normalSet, linkedSet, - allItemsById - ); - - return { normalContentItems, linkedContentItems, skippedItems: skipped }; -} - + skipped, + containerMapper, + modelMapper + ); + } + } + // Build final result arrays + const { normalContentItems, linkedContentItems } = buildResultArrays(normalSet, linkedSet, allItemsById); + return { normalContentItems, linkedContentItems, skippedItems: skipped }; +} /** * Builds lookup maps for content items: @@ -83,24 +74,24 @@ export function getContentItemTypes( * - itemsByReferenceName: Groups items by referenceName (used for recursive reference traversal) */ function buildItemMaps(contentItems: ContentItem[]): { - allItemsById: Map; - itemsByReferenceName: Map; + allItemsById: Map; + itemsByReferenceName: Map; } { - const allItemsById = new Map(); - const itemsByReferenceName = new Map(); - - for (const item of contentItems) { - allItemsById.set(item.contentID, item); - - const referenceName = item.properties?.referenceName; - if (referenceName) { - const existing = itemsByReferenceName.get(referenceName) || []; - existing.push(item); - itemsByReferenceName.set(referenceName, existing); - } + const allItemsById = new Map(); + const itemsByReferenceName = new Map(); + + for (const item of contentItems) { + allItemsById.set(item.contentID, item); + + const referenceName = item.properties?.referenceName; + if (referenceName) { + const existing = itemsByReferenceName.get(referenceName) || []; + existing.push(item); + itemsByReferenceName.set(referenceName, existing); } + } - return { allItemsById, itemsByReferenceName }; + return { allItemsById, itemsByReferenceName }; } /** @@ -108,66 +99,66 @@ function buildItemMaps(contentItems: ContentItem[]): { * Uses a stack-based approach to avoid recursion limits. */ function markReferencedItems( - referenceNames: string[], - itemsByReferenceName: Map, - normalSet: Set, - linkedSet: Set, - skipped: ContentItem[], - containerMapper: ContainerMapper, - modelMapper: ModelMapper + referenceNames: string[], + itemsByReferenceName: Map, + normalSet: Set, + linkedSet: Set, + skipped: ContentItem[], + containerMapper: ContainerMapper, + modelMapper: ModelMapper ): void { - const visitedRefNames = new Set(); - const stack = [...referenceNames]; - - while (stack.length > 0) { - const refName = stack.pop()!; - - if (visitedRefNames.has(refName)) continue; - visitedRefNames.add(refName); - - const items = itemsByReferenceName.get(refName) || []; - - for (const item of items) { - if (!hasValidMappings(item, containerMapper, modelMapper)) { - skipped.push(item); - continue; - } - - linkedSet.add(item.contentID); - normalSet.delete(item.contentID); // Remove from normal if it was added there - - // Recursively process nested references - const nestedRefs = collectListReferenceNames(item.fields || {}); - for (const nestedRef of nestedRefs) { - stack.push(nestedRef); - } - } + const visitedRefNames = new Set(); + const stack = [...referenceNames]; + + while (stack.length > 0) { + const refName = stack.pop()!; + + if (visitedRefNames.has(refName)) continue; + visitedRefNames.add(refName); + + const items = itemsByReferenceName.get(refName) || []; + + for (const item of items) { + if (!hasValidMappings(item, containerMapper, modelMapper)) { + skipped.push(item); + continue; + } + + linkedSet.add(item.contentID); + normalSet.delete(item.contentID); // Remove from normal if it was added there + + // Recursively process nested references + const nestedRefs = collectListReferenceNames(item.fields || {}); + for (const nestedRef of nestedRefs) { + stack.push(nestedRef); + } } + } } /** * Builds final arrays from ID sets, using the allItemsById map for lookup */ function buildResultArrays( - normalSet: Set, - linkedSet: Set, - allItemsById: Map + normalSet: Set, + linkedSet: Set, + allItemsById: Map ): { - normalContentItems: ContentItem[]; - linkedContentItems: ContentItem[]; + normalContentItems: ContentItem[]; + linkedContentItems: ContentItem[]; } { - const normalContentItems: ContentItem[] = []; - const linkedContentItems: ContentItem[] = []; + const normalContentItems: ContentItem[] = []; + const linkedContentItems: ContentItem[] = []; - normalSet.forEach((id) => { - const item = allItemsById.get(id); - if (item) normalContentItems.push(item); - }); + normalSet.forEach((id) => { + const item = allItemsById.get(id); + if (item) normalContentItems.push(item); + }); - linkedSet.forEach((id) => { - const item = allItemsById.get(id); - if (item) linkedContentItems.push(item); - }); + linkedSet.forEach((id) => { + const item = allItemsById.get(id); + if (item) linkedContentItems.push(item); + }); - return { normalContentItems, linkedContentItems }; + return { normalContentItems, linkedContentItems }; } diff --git a/src/lib/pushers/content-pusher/util/has-unresolved-content-references.ts b/src/lib/pushers/content-pusher/util/has-unresolved-content-references.ts index b6be124..f65d2f7 100644 --- a/src/lib/pushers/content-pusher/util/has-unresolved-content-references.ts +++ b/src/lib/pushers/content-pusher/util/has-unresolved-content-references.ts @@ -4,42 +4,42 @@ import { ContentItemMapper } from "lib/mappers/content-item-mapper"; * Recursively check for unresolved content references */ export function hasUnresolvedContentReferences(obj: any, referenceMapper: ContentItemMapper): boolean { - if (typeof obj !== 'object' || obj === null) { - return false; - } + if (typeof obj !== "object" || obj === null) { + return false; + } - if (Array.isArray(obj)) { - return obj.some(item => hasUnresolvedContentReferences(item, referenceMapper)); - } + if (Array.isArray(obj)) { + return obj.some((item) => hasUnresolvedContentReferences(item, referenceMapper)); + } - for (const [key, value] of Object.entries(obj)) { - // Check for content reference patterns - if ((key === 'contentid' || key === 'contentID') && typeof value === 'number') { - const mappedId = referenceMapper.getContentItemMappingByContentID(value, 'source'); - if (!mappedId) { - return true; // Unresolved content reference - } - } + for (const [key, value] of Object.entries(obj)) { + // Check for content reference patterns + if ((key === "contentid" || key === "contentID") && typeof value === "number") { + const mappedId = referenceMapper.getContentItemMappingByContentID(value, "source"); + if (!mappedId) { + return true; // Unresolved content reference + } + } - // Check for comma-separated content IDs in sortids fields - if (key === 'sortids' && typeof value === 'string') { - const contentIds = value.split(',').filter(id => id.trim()); - for (const contentIdStr of contentIds) { - const contentId = parseInt(contentIdStr.trim()); - if (!isNaN(contentId)) { - const mappedId = referenceMapper.getContentItemMappingByContentID(contentId, 'source'); - if (!mappedId) { - return true; // Unresolved content reference - } - } - } - } + // Check for comma-separated content IDs in sortids fields + if (key === "sortids" && typeof value === "string") { + const contentIds = value.split(",").filter((id) => id.trim()); + for (const contentIdStr of contentIds) { + const contentId = parseInt(contentIdStr.trim()); + if (!isNaN(contentId)) { + const mappedId = referenceMapper.getContentItemMappingByContentID(contentId, "source"); + if (!mappedId) { + return true; // Unresolved content reference + } + } + } + } - // Recursive check for nested objects - if (hasUnresolvedContentReferences(value, referenceMapper)) { - return true; - } - } + // Recursive check for nested objects + if (hasUnresolvedContentReferences(value, referenceMapper)) { + return true; + } + } - return false; -} \ No newline at end of file + return false; +} diff --git a/src/lib/pushers/content-pusher/util/has-valid-mappings.ts b/src/lib/pushers/content-pusher/util/has-valid-mappings.ts index 988c56b..d082093 100644 --- a/src/lib/pushers/content-pusher/util/has-valid-mappings.ts +++ b/src/lib/pushers/content-pusher/util/has-valid-mappings.ts @@ -6,22 +6,21 @@ import { ModelMapper } from "lib/mappers/model-mapper"; * Checks if a content item has valid container and model mappings */ export function hasValidMappings( - item: ContentItem, - containerMapper: ContainerMapper, - modelMapper: ModelMapper + item: ContentItem, + containerMapper: ContainerMapper, + modelMapper: ModelMapper ): boolean { - const mappedContainer = containerMapper.getContainerMappingByReferenceName( - item.properties.referenceName.toLowerCase(), - "source" - ); - const sourceContainer = containerMapper.getMappedEntity(mappedContainer, "source"); + const mappedContainer = containerMapper.getContainerMappingByReferenceName( + item.properties.referenceName.toLowerCase(), + "source" + ); + const sourceContainer = containerMapper.getMappedEntity(mappedContainer, "source"); - const sourceModelMapping = modelMapper.getModelMappingByReferenceName( - item.properties.definitionName.toLowerCase(), - "source" - ); - const sourceModel = modelMapper.getMappedEntity(sourceModelMapping, "source"); + const sourceModelMapping = modelMapper.getModelMappingByReferenceName( + item.properties.definitionName.toLowerCase(), + "source" + ); + const sourceModel = modelMapper.getMappedEntity(sourceModelMapping, "source"); - return !!(sourceContainer && sourceModel); + return !!(sourceContainer && sourceModel); } - diff --git a/src/lib/pushers/content-pusher/util/tests/are-content-dependencies-resolved.test.ts b/src/lib/pushers/content-pusher/util/tests/are-content-dependencies-resolved.test.ts index 264cd71..63f1c74 100644 --- a/src/lib/pushers/content-pusher/util/tests/are-content-dependencies-resolved.test.ts +++ b/src/lib/pushers/content-pusher/util/tests/are-content-dependencies-resolved.test.ts @@ -1,7 +1,7 @@ -import { resetState } from 'core/state'; -import { areContentDependenciesResolved } from '../are-content-dependencies-resolved'; +import { resetState } from "core/state"; +import { areContentDependenciesResolved } from "../are-content-dependencies-resolved"; -jest.mock('lib/mappers/content-item-mapper', () => ({ +jest.mock("lib/mappers/content-item-mapper", () => ({ ContentItemMapper: jest.fn().mockImplementation(() => ({ getContentItemMappingByContentID: jest.fn().mockReturnValue(null), })), @@ -9,9 +9,9 @@ jest.mock('lib/mappers/content-item-mapper', () => ({ beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -23,7 +23,7 @@ afterEach(() => { function makeContentItem(overrides: any = {}): any { return { contentID: 1, - properties: { definitionName: 'BlogPost', referenceName: 'blog-post' }, + properties: { definitionName: "BlogPost", referenceName: "blog-post" }, fields: {}, ...overrides, }; @@ -35,30 +35,31 @@ function makeModel(referenceName: string): any { function makeMapper(resolved: boolean): any { return { - getContentItemMappingByContentID: jest.fn().mockReturnValue( - resolved ? { sourceContentID: 1, targetContentID: 100 } : null - ), + getContentItemMappingByContentID: jest + .fn() + .mockReturnValue(resolved ? { sourceContentID: 1, targetContentID: 100 } : null), }; } function makePartialMapper(resolvedIds: number[]): any { return { - getContentItemMappingByContentID: jest.fn().mockImplementation( - (id: number) => + getContentItemMappingByContentID: jest + .fn() + .mockImplementation((id: number) => resolvedIds.includes(id) ? { sourceContentID: id, targetContentID: id + 1000 } : null - ), + ), }; } // ─── no fields ──────────────────────────────────────────────────────────────── -describe('areContentDependenciesResolved — no fields', () => { - it('returns true when fields is absent', () => { +describe("areContentDependenciesResolved — no fields", () => { + it("returns true when fields is absent", () => { const item = makeContentItem({ fields: undefined }); expect(areContentDependenciesResolved(item, makeMapper(false), [])).toBe(true); }); - it('returns true when fields is null', () => { + it("returns true when fields is null", () => { const item = makeContentItem({ fields: null }); expect(areContentDependenciesResolved(item, makeMapper(false), [])).toBe(true); }); @@ -66,16 +67,16 @@ describe('areContentDependenciesResolved — no fields', () => { // ─── no model found ─────────────────────────────────────────────────────────── -describe('areContentDependenciesResolved — no model found', () => { - it('returns true when model is not in the models list', () => { +describe("areContentDependenciesResolved — no model found", () => { + it("returns true when model is not in the models list", () => { const item = makeContentItem({ fields: { contentid: 5 } }); - const models: any[] = [makeModel('OtherModel')]; + const models: any[] = [makeModel("OtherModel")]; const mapper = makeMapper(false); expect(areContentDependenciesResolved(item, mapper, models)).toBe(true); expect(mapper.getContentItemMappingByContentID).not.toHaveBeenCalled(); }); - it('returns true when models list is empty', () => { + it("returns true when models list is empty", () => { const item = makeContentItem({ fields: { contentid: 5 } }); expect(areContentDependenciesResolved(item, makeMapper(false), [])).toBe(true); }); @@ -83,67 +84,67 @@ describe('areContentDependenciesResolved — no model found', () => { // ─── all references resolved ────────────────────────────────────────────────── -describe('areContentDependenciesResolved — all references resolved', () => { - it('returns true when contentid field is resolved by mapper', () => { +describe("areContentDependenciesResolved — all references resolved", () => { + it("returns true when contentid field is resolved by mapper", () => { const item = makeContentItem({ fields: { relatedContent: { contentid: 5 } } }); - const models = [makeModel('BlogPost')]; + const models = [makeModel("BlogPost")]; expect(areContentDependenciesResolved(item, makeMapper(true), models)).toBe(true); }); - it('returns true when fields have no content references', () => { - const item = makeContentItem({ fields: { title: 'Hello', body: 'World' } }); - const models = [makeModel('BlogPost')]; + it("returns true when fields have no content references", () => { + const item = makeContentItem({ fields: { title: "Hello", body: "World" } }); + const models = [makeModel("BlogPost")]; expect(areContentDependenciesResolved(item, makeMapper(false), models)).toBe(true); }); - it('returns true for empty fields object', () => { + it("returns true for empty fields object", () => { const item = makeContentItem({ fields: {} }); - const models = [makeModel('BlogPost')]; + const models = [makeModel("BlogPost")]; expect(areContentDependenciesResolved(item, makeMapper(false), models)).toBe(true); }); }); // ─── unresolved references ──────────────────────────────────────────────────── -describe('areContentDependenciesResolved — unresolved references', () => { - it('returns false when contentid field is not resolved by mapper', () => { +describe("areContentDependenciesResolved — unresolved references", () => { + it("returns false when contentid field is not resolved by mapper", () => { const item = makeContentItem({ fields: { relatedContent: { contentid: 5 } } }); - const models = [makeModel('BlogPost')]; + const models = [makeModel("BlogPost")]; expect(areContentDependenciesResolved(item, makeMapper(false), models)).toBe(false); }); - it('returns false when sortids contain an unresolved id', () => { - const item = makeContentItem({ fields: { items: { sortids: '10,20,30' } } }); - const models = [makeModel('BlogPost')]; + it("returns false when sortids contain an unresolved id", () => { + const item = makeContentItem({ fields: { items: { sortids: "10,20,30" } } }); + const models = [makeModel("BlogPost")]; const mapper = makePartialMapper([10, 30]); expect(areContentDependenciesResolved(item, mapper, models)).toBe(false); }); - it('returns false when a nested contentID is unresolved', () => { + it("returns false when a nested contentID is unresolved", () => { const item = makeContentItem({ fields: { nested: { deeper: { contentID: 77 } } }, }); - const models = [makeModel('BlogPost')]; + const models = [makeModel("BlogPost")]; expect(areContentDependenciesResolved(item, makeMapper(false), models)).toBe(false); }); }); // ─── model matching ─────────────────────────────────────────────────────────── -describe('areContentDependenciesResolved — model matching', () => { - it('matches model by definitionName from properties', () => { +describe("areContentDependenciesResolved — model matching", () => { + it("matches model by definitionName from properties", () => { const item: any = { contentID: 1, - properties: { definitionName: 'SpecialModel' }, + properties: { definitionName: "SpecialModel" }, fields: { link: { contentid: 99 } }, }; - const models = [makeModel('SpecialModel'), makeModel('OtherModel')]; + const models = [makeModel("SpecialModel"), makeModel("OtherModel")]; expect(areContentDependenciesResolved(item, makeMapper(false), models)).toBe(false); }); - it('returns true (assume resolved) when item has no properties', () => { + it("returns true (assume resolved) when item has no properties", () => { const item: any = { contentID: 1, fields: { contentid: 5 } }; - const models = [makeModel('BlogPost')]; + const models = [makeModel("BlogPost")]; // No properties.definitionName → model.find returns undefined → returns true expect(areContentDependenciesResolved(item, makeMapper(false), models)).toBe(true); }); diff --git a/src/lib/pushers/content-pusher/util/tests/collect-list-reference-names.test.ts b/src/lib/pushers/content-pusher/util/tests/collect-list-reference-names.test.ts index 2f4bd95..7eabbd5 100644 --- a/src/lib/pushers/content-pusher/util/tests/collect-list-reference-names.test.ts +++ b/src/lib/pushers/content-pusher/util/tests/collect-list-reference-names.test.ts @@ -1,143 +1,141 @@ -import { resetState } from 'core/state'; -import { collectListReferenceNames } from '../collect-list-reference-names'; +import { resetState } from "core/state"; +import { collectListReferenceNames } from "../collect-list-reference-names"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { jest.restoreAllMocks(); }); -describe('collectListReferenceNames', () => { - describe('null / empty inputs', () => { - it('returns empty array for null', () => { +describe("collectListReferenceNames", () => { + describe("null / empty inputs", () => { + it("returns empty array for null", () => { expect(collectListReferenceNames(null)).toEqual([]); }); - it('returns empty array for undefined', () => { + it("returns empty array for undefined", () => { expect(collectListReferenceNames(undefined)).toEqual([]); }); - it('returns empty array for empty object', () => { + it("returns empty array for empty object", () => { expect(collectListReferenceNames({})).toEqual([]); }); - it('returns empty array for empty array', () => { + it("returns empty array for empty array", () => { expect(collectListReferenceNames([])).toEqual([]); }); }); - describe('camelCase property names (referenceName / fullList)', () => { - it('returns referenceName when referenceName + fullList=true', () => { - const fields = { referenceName: 'my-list', fullList: true }; - expect(collectListReferenceNames(fields)).toEqual(['my-list']); + describe("camelCase property names (referenceName / fullList)", () => { + it("returns referenceName when referenceName + fullList=true", () => { + const fields = { referenceName: "my-list", fullList: true }; + expect(collectListReferenceNames(fields)).toEqual(["my-list"]); }); - it('ignores node when fullList=false', () => { - const fields = { referenceName: 'my-list', fullList: false }; + it("ignores node when fullList=false", () => { + const fields = { referenceName: "my-list", fullList: false }; expect(collectListReferenceNames(fields)).toEqual([]); }); - it('ignores node when fullList is absent', () => { - const fields = { referenceName: 'my-list' }; + it("ignores node when fullList is absent", () => { + const fields = { referenceName: "my-list" }; expect(collectListReferenceNames(fields)).toEqual([]); }); }); - describe('lowercase property names (referencename / fulllist)', () => { - it('returns referencename when referencename + fulllist=true', () => { - const fields = { referencename: 'lower-list', fulllist: true }; - expect(collectListReferenceNames(fields)).toEqual(['lower-list']); + describe("lowercase property names (referencename / fulllist)", () => { + it("returns referencename when referencename + fulllist=true", () => { + const fields = { referencename: "lower-list", fulllist: true }; + expect(collectListReferenceNames(fields)).toEqual(["lower-list"]); }); - it('ignores node when fulllist=false', () => { - const fields = { referencename: 'lower-list', fulllist: false }; + it("ignores node when fulllist=false", () => { + const fields = { referencename: "lower-list", fulllist: false }; expect(collectListReferenceNames(fields)).toEqual([]); }); }); - describe('nested objects', () => { - it('finds reference names in nested objects', () => { + describe("nested objects", () => { + it("finds reference names in nested objects", () => { const fields = { outer: { - inner: { referenceName: 'nested-ref', fullList: true }, + inner: { referenceName: "nested-ref", fullList: true }, }, }; - expect(collectListReferenceNames(fields)).toEqual(['nested-ref']); + expect(collectListReferenceNames(fields)).toEqual(["nested-ref"]); }); - it('finds multiple reference names at different depths', () => { + it("finds multiple reference names at different depths", () => { const fields = { - a: { referenceName: 'ref-a', fullList: true }, + a: { referenceName: "ref-a", fullList: true }, b: { - c: { referenceName: 'ref-c', fullList: true }, + c: { referenceName: "ref-c", fullList: true }, }, }; const result = collectListReferenceNames(fields); expect(result).toHaveLength(2); - expect(result).toContain('ref-a'); - expect(result).toContain('ref-c'); + expect(result).toContain("ref-a"); + expect(result).toContain("ref-c"); }); }); - describe('arrays', () => { - it('walks array elements to find reference names', () => { + describe("arrays", () => { + it("walks array elements to find reference names", () => { const fields = [ - { referenceName: 'ref-1', fullList: true }, - { referenceName: 'ref-2', fullList: false }, - { referenceName: 'ref-3', fullList: true }, + { referenceName: "ref-1", fullList: true }, + { referenceName: "ref-2", fullList: false }, + { referenceName: "ref-3", fullList: true }, ]; const result = collectListReferenceNames(fields); - expect(result).toEqual(['ref-1', 'ref-3']); + expect(result).toEqual(["ref-1", "ref-3"]); }); - it('handles nested arrays', () => { + it("handles nested arrays", () => { const fields = { - items: [ - [{ referenceName: 'deep-ref', fullList: true }], - ], + items: [[{ referenceName: "deep-ref", fullList: true }]], }; - expect(collectListReferenceNames(fields)).toEqual(['deep-ref']); + expect(collectListReferenceNames(fields)).toEqual(["deep-ref"]); }); }); - describe('non-string referenceName', () => { - it('ignores nodes where referenceName is a number', () => { + describe("non-string referenceName", () => { + it("ignores nodes where referenceName is a number", () => { const fields = { referenceName: 123 as any, fullList: true }; expect(collectListReferenceNames(fields)).toEqual([]); }); - it('ignores nodes where referenceName is null', () => { + it("ignores nodes where referenceName is null", () => { const fields = { referenceName: null, fullList: true }; expect(collectListReferenceNames(fields)).toEqual([]); }); }); - describe('scalar values inside objects', () => { - it('does not throw on primitive field values', () => { - const fields = { title: 'hello', count: 42, flag: true }; + describe("scalar values inside objects", () => { + it("does not throw on primitive field values", () => { + const fields = { title: "hello", count: 42, flag: true }; expect(() => collectListReferenceNames(fields)).not.toThrow(); }); - it('returns empty array when no fullList flags are set', () => { - const fields = { title: 'hello', count: 42 }; + it("returns empty array when no fullList flags are set", () => { + const fields = { title: "hello", count: 42 }; expect(collectListReferenceNames(fields)).toEqual([]); }); }); - describe('duplicate reference names', () => { - it('includes duplicate entries when the same reference name appears twice', () => { + describe("duplicate reference names", () => { + it("includes duplicate entries when the same reference name appears twice", () => { const fields = { - a: { referenceName: 'dup', fullList: true }, - b: { referenceName: 'dup', fullList: true }, + a: { referenceName: "dup", fullList: true }, + b: { referenceName: "dup", fullList: true }, }; const result = collectListReferenceNames(fields); expect(result).toHaveLength(2); - expect(result.every(r => r === 'dup')).toBe(true); + expect(result.every((r) => r === "dup")).toBe(true); }); }); }); diff --git a/src/lib/pushers/content-pusher/util/tests/filter-content-items-for-processing.test.ts b/src/lib/pushers/content-pusher/util/tests/filter-content-items-for-processing.test.ts index 76cdb3a..99b69d4 100644 --- a/src/lib/pushers/content-pusher/util/tests/filter-content-items-for-processing.test.ts +++ b/src/lib/pushers/content-pusher/util/tests/filter-content-items-for-processing.test.ts @@ -1,21 +1,21 @@ -import { resetState, setState } from 'core/state'; +import { resetState, setState } from "core/state"; // Mock findContentInTargetInstance so we can control its return value -jest.mock('../find-content-in-target-instance', () => ({ +jest.mock("../find-content-in-target-instance", () => ({ findContentInTargetInstance: jest.fn(), })); -import { filterContentItemsForProcessing } from '../filter-content-items-for-processing'; -import { findContentInTargetInstance } from '../find-content-in-target-instance'; +import { filterContentItemsForProcessing } from "../filter-content-items-for-processing"; +import { findContentInTargetInstance } from "../find-content-in-target-instance"; const mockFind = findContentInTargetInstance as jest.Mock; beforeEach(() => { resetState(); mockFind.mockReset(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -27,7 +27,7 @@ afterEach(() => { function makeContentItem(id: number, referenceName = `ref-${id}`): any { return { contentID: id, - properties: { referenceName, definitionName: 'Model', versionID: 1 }, + properties: { referenceName, definitionName: "Model", versionID: 1 }, fields: {}, }; } @@ -45,8 +45,8 @@ function makeBaseProps(contentItems: any[], overrides: Partial = {}): any { return { contentItems, apiClient: {} as any, - targetGuid: 'tgt-guid', - locale: 'en-us', + targetGuid: "tgt-guid", + locale: "en-us", referenceMapper: {} as any, targetData: [], logger: makeLogger(), @@ -56,8 +56,8 @@ function makeBaseProps(contentItems: any[], overrides: Partial = {}): any { // ─── empty input ────────────────────────────────────────────────────────────── -describe('filterContentItemsForProcessing — empty input', () => { - it('returns empty arrays when contentItems is empty', async () => { +describe("filterContentItemsForProcessing — empty input", () => { + it("returns empty arrays when contentItems is empty", async () => { const result = await filterContentItemsForProcessing(makeBaseProps([])); expect(result.itemsToProcess).toHaveLength(0); expect(result.itemsToSkip).toHaveLength(0); @@ -67,10 +67,16 @@ describe('filterContentItemsForProcessing — empty input', () => { // ─── shouldCreate → itemsToProcess ─────────────────────────────────────────── -describe('filterContentItemsForProcessing — shouldCreate', () => { - it('includes item in itemsToProcess when shouldCreate is true', async () => { +describe("filterContentItemsForProcessing — shouldCreate", () => { + it("includes item in itemsToProcess when shouldCreate is true", async () => { const item = makeContentItem(1); - mockFind.mockReturnValue({ content: null, shouldCreate: true, shouldUpdate: false, shouldSkip: false, isConflict: false }); + mockFind.mockReturnValue({ + content: null, + shouldCreate: true, + shouldUpdate: false, + shouldSkip: false, + isConflict: false, + }); const result = await filterContentItemsForProcessing(makeBaseProps([item])); expect(result.itemsToProcess).toContain(item); expect(result.itemsToSkip).toHaveLength(0); @@ -79,10 +85,16 @@ describe('filterContentItemsForProcessing — shouldCreate', () => { // ─── shouldUpdate → itemsToProcess ─────────────────────────────────────────── -describe('filterContentItemsForProcessing — shouldUpdate', () => { - it('includes item in itemsToProcess when shouldUpdate is true', async () => { +describe("filterContentItemsForProcessing — shouldUpdate", () => { + it("includes item in itemsToProcess when shouldUpdate is true", async () => { const item = makeContentItem(1); - mockFind.mockReturnValue({ content: item, shouldCreate: false, shouldUpdate: true, shouldSkip: false, isConflict: false }); + mockFind.mockReturnValue({ + content: item, + shouldCreate: false, + shouldUpdate: true, + shouldSkip: false, + isConflict: false, + }); const result = await filterContentItemsForProcessing(makeBaseProps([item])); expect(result.itemsToProcess).toContain(item); expect(result.itemsToSkip).toHaveLength(0); @@ -91,11 +103,17 @@ describe('filterContentItemsForProcessing — shouldUpdate', () => { // ─── shouldSkip → itemsToSkip ───────────────────────────────────────────────── -describe('filterContentItemsForProcessing — shouldSkip', () => { - it('puts item in itemsToSkip when shouldSkip is true', async () => { +describe("filterContentItemsForProcessing — shouldSkip", () => { + it("puts item in itemsToSkip when shouldSkip is true", async () => { const item = makeContentItem(1); const logger = makeLogger(); - mockFind.mockReturnValue({ content: item, shouldCreate: false, shouldUpdate: false, shouldSkip: true, isConflict: false }); + mockFind.mockReturnValue({ + content: item, + shouldCreate: false, + shouldUpdate: false, + shouldSkip: true, + isConflict: false, + }); const result = await filterContentItemsForProcessing(makeBaseProps([item], { logger })); expect(result.itemsToSkip).toContain(item); expect(result.itemsToProcess).toHaveLength(0); @@ -103,26 +121,25 @@ describe('filterContentItemsForProcessing — shouldSkip', () => { expect(logger.content.skipped).toHaveBeenCalled(); }); - it('logs the correct locale and targetGuid when skipping', async () => { + it("logs the correct locale and targetGuid when skipping", async () => { const item = makeContentItem(1); const logger = makeLogger(); - mockFind.mockReturnValue({ content: item, shouldCreate: false, shouldUpdate: false, shouldSkip: true, isConflict: false }); - await filterContentItemsForProcessing( - makeBaseProps([item], { logger, locale: 'fr-ca', targetGuid: 'my-guid' }) - ); - expect(logger.content.skipped).toHaveBeenCalledWith( - item, - expect.any(String), - 'fr-ca', - 'my-guid' - ); + mockFind.mockReturnValue({ + content: item, + shouldCreate: false, + shouldUpdate: false, + shouldSkip: true, + isConflict: false, + }); + await filterContentItemsForProcessing(makeBaseProps([item], { logger, locale: "fr-ca", targetGuid: "my-guid" })); + expect(logger.content.skipped).toHaveBeenCalledWith(item, expect.any(String), "fr-ca", "my-guid"); }); }); // ─── isConflict → itemsToSkip + warning ─────────────────────────────────────── -describe('filterContentItemsForProcessing — isConflict', () => { - it('puts conflicted item in itemsToSkip', async () => { +describe("filterContentItemsForProcessing — isConflict", () => { + it("puts conflicted item in itemsToSkip", async () => { const item = makeContentItem(1); mockFind.mockReturnValue({ content: item, @@ -130,14 +147,14 @@ describe('filterContentItemsForProcessing — isConflict', () => { shouldUpdate: false, shouldSkip: false, isConflict: true, - reason: 'Both versions changed', + reason: "Both versions changed", }); const result = await filterContentItemsForProcessing(makeBaseProps([item])); expect(result.itemsToSkip).toContain(item); expect(result.itemsToProcess).toHaveLength(0); }); - it('logs a warning when conflict is detected', async () => { + it("logs a warning when conflict is detected", async () => { const item = makeContentItem(1); mockFind.mockReturnValue({ content: null, @@ -145,9 +162,9 @@ describe('filterContentItemsForProcessing — isConflict', () => { shouldUpdate: false, shouldSkip: false, isConflict: true, - reason: 'conflict reason', + reason: "conflict reason", }); - const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); await filterContentItemsForProcessing(makeBaseProps([item])); expect(warnSpy).toHaveBeenCalled(); }); @@ -155,37 +172,59 @@ describe('filterContentItemsForProcessing — isConflict', () => { // ─── error handling ─────────────────────────────────────────────────────────── -describe('filterContentItemsForProcessing — error handling', () => { - it('includes item in itemsToProcess and logs error when findContentInTargetInstance throws', async () => { +describe("filterContentItemsForProcessing — error handling", () => { + it("includes item in itemsToProcess and logs error when findContentInTargetInstance throws", async () => { const item = makeContentItem(1); const logger = makeLogger(); - mockFind.mockImplementation(() => { throw new Error('lookup failed'); }); + mockFind.mockImplementation(() => { + throw new Error("lookup failed"); + }); const result = await filterContentItemsForProcessing(makeBaseProps([item], { logger })); expect(result.itemsToProcess).toContain(item); expect(result.itemsToSkip).toHaveLength(0); - expect(logger.content.error).toHaveBeenCalledWith( - item, - 'lookup failed', - expect.any(String), - expect.any(String) - ); + expect(logger.content.error).toHaveBeenCalledWith(item, "lookup failed", expect.any(String), expect.any(String)); }); }); // ─── mixed batch ────────────────────────────────────────────────────────────── -describe('filterContentItemsForProcessing — mixed batch', () => { - it('correctly partitions a batch with create, update, skip, and conflict', async () => { +describe("filterContentItemsForProcessing — mixed batch", () => { + it("correctly partitions a batch with create, update, skip, and conflict", async () => { const createItem = makeContentItem(1); const updateItem = makeContentItem(2); const skipItem = makeContentItem(3); const conflictItem = makeContentItem(4); mockFind - .mockReturnValueOnce({ content: null, shouldCreate: true, shouldUpdate: false, shouldSkip: false, isConflict: false }) - .mockReturnValueOnce({ content: updateItem, shouldCreate: false, shouldUpdate: true, shouldSkip: false, isConflict: false }) - .mockReturnValueOnce({ content: skipItem, shouldCreate: false, shouldUpdate: false, shouldSkip: true, isConflict: false }) - .mockReturnValueOnce({ content: conflictItem, shouldCreate: false, shouldUpdate: false, shouldSkip: false, isConflict: true, reason: 'conflict' }); + .mockReturnValueOnce({ + content: null, + shouldCreate: true, + shouldUpdate: false, + shouldSkip: false, + isConflict: false, + }) + .mockReturnValueOnce({ + content: updateItem, + shouldCreate: false, + shouldUpdate: true, + shouldSkip: false, + isConflict: false, + }) + .mockReturnValueOnce({ + content: skipItem, + shouldCreate: false, + shouldUpdate: false, + shouldSkip: true, + isConflict: false, + }) + .mockReturnValueOnce({ + content: conflictItem, + shouldCreate: false, + shouldUpdate: false, + shouldSkip: false, + isConflict: true, + reason: "conflict", + }); const logger = makeLogger(); const result = await filterContentItemsForProcessing( @@ -204,10 +243,16 @@ describe('filterContentItemsForProcessing — mixed batch', () => { // ─── skippedCount accuracy ──────────────────────────────────────────────────── -describe('filterContentItemsForProcessing — skippedCount', () => { - it('skippedCount equals itemsToSkip.length', async () => { +describe("filterContentItemsForProcessing — skippedCount", () => { + it("skippedCount equals itemsToSkip.length", async () => { const items = [makeContentItem(1), makeContentItem(2), makeContentItem(3)]; - mockFind.mockReturnValue({ content: null, shouldCreate: false, shouldUpdate: false, shouldSkip: true, isConflict: false }); + mockFind.mockReturnValue({ + content: null, + shouldCreate: false, + shouldUpdate: false, + shouldSkip: true, + isConflict: false, + }); const logger = makeLogger(); const result = await filterContentItemsForProcessing(makeBaseProps(items, { logger })); expect(result.skippedCount).toBe(result.itemsToSkip.length); @@ -217,21 +262,33 @@ describe('filterContentItemsForProcessing — skippedCount', () => { // ─── verbose logging ────────────────────────────────────────────────────────── -describe('filterContentItemsForProcessing — verbose logging', () => { - it('logs summary when verbose=true and contentItems is non-empty', async () => { +describe("filterContentItemsForProcessing — verbose logging", () => { + it("logs summary when verbose=true and contentItems is non-empty", async () => { setState({ verbose: true }); const item = makeContentItem(1); - mockFind.mockReturnValue({ content: null, shouldCreate: true, shouldUpdate: false, shouldSkip: false, isConflict: false }); - const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + mockFind.mockReturnValue({ + content: null, + shouldCreate: true, + shouldUpdate: false, + shouldSkip: false, + isConflict: false, + }); + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); await filterContentItemsForProcessing(makeBaseProps([item])); expect(logSpy).toHaveBeenCalled(); }); - it('does not log summary when verbose=false', async () => { + it("does not log summary when verbose=false", async () => { setState({ verbose: false }); const item = makeContentItem(1); - mockFind.mockReturnValue({ content: null, shouldCreate: true, shouldUpdate: false, shouldSkip: false, isConflict: false }); - const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + mockFind.mockReturnValue({ + content: null, + shouldCreate: true, + shouldUpdate: false, + shouldSkip: false, + isConflict: false, + }); + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); await filterContentItemsForProcessing(makeBaseProps([item])); expect(logSpy).not.toHaveBeenCalled(); }); diff --git a/src/lib/pushers/content-pusher/util/tests/find-content-in-other-locale.test.ts b/src/lib/pushers/content-pusher/util/tests/find-content-in-other-locale.test.ts index 7eae68e..e911184 100644 --- a/src/lib/pushers/content-pusher/util/tests/find-content-in-other-locale.test.ts +++ b/src/lib/pushers/content-pusher/util/tests/find-content-in-other-locale.test.ts @@ -1,8 +1,8 @@ -import { resetState, setState } from 'core/state'; -import { state } from 'core/state'; +import { resetState, setState } from "core/state"; +import { state } from "core/state"; // Mock ContentItemMapper to avoid filesystem calls -jest.mock('lib/mappers/content-item-mapper', () => { +jest.mock("lib/mappers/content-item-mapper", () => { const mockGetMapping = jest.fn(); return { ContentItemMapper: jest.fn().mockImplementation(() => ({ @@ -13,22 +13,22 @@ jest.mock('lib/mappers/content-item-mapper', () => { }); // Mock PageMapper (imported but not used in the function under test) -jest.mock('lib/mappers/page-mapper', () => ({ +jest.mock("lib/mappers/page-mapper", () => ({ PageMapper: jest.fn(), })); -import { findContentInOtherLocale } from '../find-content-in-other-locale'; -import { ContentItemMapper } from 'lib/mappers/content-item-mapper'; +import { findContentInOtherLocale } from "../find-content-in-other-locale"; +import { ContentItemMapper } from "lib/mappers/content-item-mapper"; -const mockModule = jest.requireMock('lib/mappers/content-item-mapper') as any; +const mockModule = jest.requireMock("lib/mappers/content-item-mapper") as any; beforeEach(() => { resetState(); mockModule.__mockGetMapping.mockReset(); (ContentItemMapper as jest.Mock).mockClear(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -38,24 +38,24 @@ afterEach(() => { // ─── helpers ────────────────────────────────────────────────────────────────── const BASE_PROPS = { - sourceGuid: 'src-guid', - targetGuid: 'tgt-guid', + sourceGuid: "src-guid", + targetGuid: "tgt-guid", sourceContentID: 42, - locale: 'en-us', + locale: "en-us", }; // ─── no available locales ───────────────────────────────────────────────────── -describe('findContentInOtherLocale — no available locales', () => { - it('returns -1 when availableLocales is empty', async () => { +describe("findContentInOtherLocale — no available locales", () => { + it("returns -1 when availableLocales is empty", async () => { state.availableLocales = []; const result = await findContentInOtherLocale(BASE_PROPS); expect(result).toBe(-1); expect(ContentItemMapper).not.toHaveBeenCalled(); }); - it('returns -1 when availableLocales only contains the current locale', async () => { - state.availableLocales = ['en-us']; + it("returns -1 when availableLocales only contains the current locale", async () => { + state.availableLocales = ["en-us"]; const result = await findContentInOtherLocale(BASE_PROPS); expect(result).toBe(-1); expect(ContentItemMapper).not.toHaveBeenCalled(); @@ -64,9 +64,9 @@ describe('findContentInOtherLocale — no available locales', () => { // ─── mapping found in another locale ───────────────────────────────────────── -describe('findContentInOtherLocale — mapping found', () => { - it('returns targetContentID from the mapping when found in another locale', async () => { - state.availableLocales = ['en-us', 'fr-ca']; +describe("findContentInOtherLocale — mapping found", () => { + it("returns targetContentID from the mapping when found in another locale", async () => { + state.availableLocales = ["en-us", "fr-ca"]; mockModule.__mockGetMapping.mockReturnValue({ sourceContentID: 42, targetContentID: 999, @@ -75,33 +75,33 @@ describe('findContentInOtherLocale — mapping found', () => { expect(result).toBe(999); }); - it('creates ContentItemMapper with the other locale (not the current one)', async () => { - state.availableLocales = ['en-us', 'fr-ca']; + it("creates ContentItemMapper with the other locale (not the current one)", async () => { + state.availableLocales = ["en-us", "fr-ca"]; mockModule.__mockGetMapping.mockReturnValue({ sourceContentID: 42, targetContentID: 999 }); await findContentInOtherLocale(BASE_PROPS); - expect(ContentItemMapper).toHaveBeenCalledWith('src-guid', 'tgt-guid', 'fr-ca'); + expect(ContentItemMapper).toHaveBeenCalledWith("src-guid", "tgt-guid", "fr-ca"); }); - it('checks the mapper with source type and the given contentID', async () => { - state.availableLocales = ['en-us', 'de-de']; + it("checks the mapper with source type and the given contentID", async () => { + state.availableLocales = ["en-us", "de-de"]; mockModule.__mockGetMapping.mockReturnValue({ sourceContentID: 42, targetContentID: 200 }); await findContentInOtherLocale(BASE_PROPS); - expect(mockModule.__mockGetMapping).toHaveBeenCalledWith(42, 'source'); + expect(mockModule.__mockGetMapping).toHaveBeenCalledWith(42, "source"); }); }); // ─── no mapping found ───────────────────────────────────────────────────────── -describe('findContentInOtherLocale — no mapping found', () => { - it('returns -1 when no other locale has a mapping', async () => { - state.availableLocales = ['en-us', 'fr-ca', 'de-de']; +describe("findContentInOtherLocale — no mapping found", () => { + it("returns -1 when no other locale has a mapping", async () => { + state.availableLocales = ["en-us", "fr-ca", "de-de"]; mockModule.__mockGetMapping.mockReturnValue(null); const result = await findContentInOtherLocale(BASE_PROPS); expect(result).toBe(-1); }); - it('checks every other locale before returning -1', async () => { - state.availableLocales = ['en-us', 'fr-ca', 'de-de']; + it("checks every other locale before returning -1", async () => { + state.availableLocales = ["en-us", "fr-ca", "de-de"]; mockModule.__mockGetMapping.mockReturnValue(null); await findContentInOtherLocale(BASE_PROPS); // Two other locales checked (fr-ca, de-de) @@ -111,9 +111,9 @@ describe('findContentInOtherLocale — no mapping found', () => { // ─── stops early when found ─────────────────────────────────────────────────── -describe('findContentInOtherLocale — early exit', () => { - it('returns as soon as a mapping is found and does not check subsequent locales', async () => { - state.availableLocales = ['en-us', 'fr-ca', 'de-de']; +describe("findContentInOtherLocale — early exit", () => { + it("returns as soon as a mapping is found and does not check subsequent locales", async () => { + state.availableLocales = ["en-us", "fr-ca", "de-de"]; // Only fr-ca has the mapping mockModule.__mockGetMapping.mockReturnValueOnce({ sourceContentID: 42, targetContentID: 555 }); const result = await findContentInOtherLocale(BASE_PROPS); @@ -125,11 +125,13 @@ describe('findContentInOtherLocale — early exit', () => { // ─── mapper error handling ──────────────────────────────────────────────────── -describe('findContentInOtherLocale — mapper throws', () => { - it('catches errors from getContentItemMappingByContentID and continues', async () => { - state.availableLocales = ['en-us', 'fr-ca', 'de-de']; +describe("findContentInOtherLocale — mapper throws", () => { + it("catches errors from getContentItemMappingByContentID and continues", async () => { + state.availableLocales = ["en-us", "fr-ca", "de-de"]; mockModule.__mockGetMapping - .mockImplementationOnce(() => { throw new Error('file not found'); }) + .mockImplementationOnce(() => { + throw new Error("file not found"); + }) .mockReturnValueOnce({ sourceContentID: 42, targetContentID: 777 }); const result = await findContentInOtherLocale(BASE_PROPS); expect(result).toBe(777); diff --git a/src/lib/pushers/content-pusher/util/tests/find-content-in-target-instance.test.ts b/src/lib/pushers/content-pusher/util/tests/find-content-in-target-instance.test.ts index 8d6eaf7..57e4784 100644 --- a/src/lib/pushers/content-pusher/util/tests/find-content-in-target-instance.test.ts +++ b/src/lib/pushers/content-pusher/util/tests/find-content-in-target-instance.test.ts @@ -31,7 +31,7 @@ function makeMapper( mapping?: any; targetEntity?: any; locale?: string; - } = {}, + } = {} ): any { return { getContentItemMappingByContentID: jest.fn().mockReturnValue(opts.mapping ?? null), diff --git a/src/lib/pushers/content-pusher/util/tests/get-content-item-types.test.ts b/src/lib/pushers/content-pusher/util/tests/get-content-item-types.test.ts index 96d4c34..15cebbf 100644 --- a/src/lib/pushers/content-pusher/util/tests/get-content-item-types.test.ts +++ b/src/lib/pushers/content-pusher/util/tests/get-content-item-types.test.ts @@ -1,23 +1,23 @@ -import { resetState } from 'core/state'; -import { getContentItemTypes } from '../get-content-item-types'; +import { resetState } from "core/state"; +import { getContentItemTypes } from "../get-content-item-types"; -jest.mock('lib/mappers/container-mapper', () => ({ +jest.mock("lib/mappers/container-mapper", () => ({ ContainerMapper: jest.fn(), })); -jest.mock('lib/mappers/model-mapper', () => ({ +jest.mock("lib/mappers/model-mapper", () => ({ ModelMapper: jest.fn(), })); -jest.mock('lib/mappers/content-item-mapper', () => ({ +jest.mock("lib/mappers/content-item-mapper", () => ({ ContentItemMapper: jest.fn(), })); beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -27,11 +27,7 @@ afterEach(() => { // ─── helpers ────────────────────────────────────────────────────────────────── let nextId = 1; -function makeItem( - referenceName: string, - definitionName: string, - fields: any = {} -): any { +function makeItem(referenceName: string, definitionName: string, fields: any = {}): any { return { contentID: nextId++, properties: { referenceName, definitionName }, @@ -85,8 +81,8 @@ beforeEach(() => { // ─── empty input ────────────────────────────────────────────────────────────── -describe('getContentItemTypes — empty input', () => { - it('returns empty arrays when contentItems is empty', () => { +describe("getContentItemTypes — empty input", () => { + it("returns empty arrays when contentItems is empty", () => { const result = getContentItemTypes([], makeValidOpts()); expect(result.normalContentItems).toHaveLength(0); expect(result.linkedContentItems).toHaveLength(0); @@ -96,9 +92,9 @@ describe('getContentItemTypes — empty input', () => { // ─── skipped items (no valid mappings) ──────────────────────────────────────── -describe('getContentItemTypes — skipped items', () => { - it('adds item to skippedItems when mappings are invalid', () => { - const item = makeItem('container-a', 'ModelA'); +describe("getContentItemTypes — skipped items", () => { + it("adds item to skippedItems when mappings are invalid", () => { + const item = makeItem("container-a", "ModelA"); const result = getContentItemTypes([item], makeInvalidOpts()); expect(result.skippedItems).toHaveLength(1); expect(result.skippedItems[0]).toBe(item); @@ -106,28 +102,22 @@ describe('getContentItemTypes — skipped items', () => { expect(result.linkedContentItems).toHaveLength(0); }); - it('skips some and classifies others when mappings are mixed', () => { - const validItem = makeItem('valid-container', 'ValidModel'); - const invalidItem = makeItem('invalid-container', 'InvalidModel'); + it("skips some and classifies others when mappings are mixed", () => { + const validItem = makeItem("valid-container", "ValidModel"); + const invalidItem = makeItem("invalid-container", "InvalidModel"); const mixedOpts = { containerMapper: { - getContainerMappingByReferenceName: jest.fn().mockImplementation( - (ref: string) => - ref === 'valid-container' ? { sourceContentViewID: 1 } : null - ), - getMappedEntity: jest.fn().mockImplementation( - (mapping: any) => (mapping ? { contentViewID: 1 } : null) - ), + getContainerMappingByReferenceName: jest + .fn() + .mockImplementation((ref: string) => (ref === "valid-container" ? { sourceContentViewID: 1 } : null)), + getMappedEntity: jest.fn().mockImplementation((mapping: any) => (mapping ? { contentViewID: 1 } : null)), }, modelMapper: { - getModelMappingByReferenceName: jest.fn().mockImplementation( - (ref: string) => - ref === 'validmodel' ? { sourceID: 10 } : null - ), - getMappedEntity: jest.fn().mockImplementation( - (mapping: any) => (mapping ? { id: 10 } : null) - ), + getModelMappingByReferenceName: jest + .fn() + .mockImplementation((ref: string) => (ref === "validmodel" ? { sourceID: 10 } : null)), + getMappedEntity: jest.fn().mockImplementation((mapping: any) => (mapping ? { id: 10 } : null)), }, referenceMapper: {}, logger: {}, @@ -142,20 +132,20 @@ describe('getContentItemTypes — skipped items', () => { // ─── normal items (no fullList references) ──────────────────────────────────── -describe('getContentItemTypes — normal items', () => { - it('classifies an item as normal when it has no fullList references', () => { - const item = makeItem('container-a', 'ModelA', { title: 'Hello' }); +describe("getContentItemTypes — normal items", () => { + it("classifies an item as normal when it has no fullList references", () => { + const item = makeItem("container-a", "ModelA", { title: "Hello" }); const result = getContentItemTypes([item], makeValidOpts()); expect(result.normalContentItems).toHaveLength(1); expect(result.normalContentItems[0]).toBe(item); expect(result.linkedContentItems).toHaveLength(0); }); - it('classifies multiple items without references as normal', () => { + it("classifies multiple items without references as normal", () => { const items = [ - makeItem('container-a', 'ModelA'), - makeItem('container-b', 'ModelB'), - makeItem('container-c', 'ModelC'), + makeItem("container-a", "ModelA"), + makeItem("container-b", "ModelB"), + makeItem("container-c", "ModelC"), ]; const result = getContentItemTypes(items, makeValidOpts()); expect(result.normalContentItems).toHaveLength(3); @@ -166,11 +156,11 @@ describe('getContentItemTypes — normal items', () => { // ─── linked items (have fullList references) ────────────────────────────────── -describe('getContentItemTypes — linked items', () => { - it('moves a referenced item from normal to linked when another item points to it with fullList=true', () => { - const linkedItem = makeItem('linked-ref', 'ModelLinked'); - const parentItem = makeItem('parent-ref', 'ModelParent', { - items: { referenceName: 'linked-ref', fullList: true }, +describe("getContentItemTypes — linked items", () => { + it("moves a referenced item from normal to linked when another item points to it with fullList=true", () => { + const linkedItem = makeItem("linked-ref", "ModelLinked"); + const parentItem = makeItem("parent-ref", "ModelParent", { + items: { referenceName: "linked-ref", fullList: true }, }); const result = getContentItemTypes([linkedItem, parentItem], makeValidOpts()); @@ -180,10 +170,10 @@ describe('getContentItemTypes — linked items', () => { expect(result.normalContentItems[0]).toBe(parentItem); }); - it('handles lowercase referencename/fulllist properties', () => { - const linkedItem = makeItem('linked-lower', 'ModelLinked'); - const parentItem = makeItem('parent-ref', 'ModelParent', { - items: { referencename: 'linked-lower', fulllist: true }, + it("handles lowercase referencename/fulllist properties", () => { + const linkedItem = makeItem("linked-lower", "ModelLinked"); + const parentItem = makeItem("parent-ref", "ModelParent", { + items: { referencename: "linked-lower", fulllist: true }, }); const result = getContentItemTypes([linkedItem, parentItem], makeValidOpts()); @@ -213,18 +203,18 @@ describe('getContentItemTypes — linked items', () => { const parent1 = makeItem('parent-1', 'ModelParent', { items: { referenceName: 'shared-ref', fullList: true }, }); - const parent2 = makeItem('parent-2', 'ModelParent', { - items: { referenceName: 'shared-ref', fullList: true }, + const parent2 = makeItem("parent-2", "ModelParent", { + items: { referenceName: "shared-ref", fullList: true }, }); const result = getContentItemTypes([sharedItem, parent1, parent2], makeValidOpts()); expect(result.linkedContentItems).toHaveLength(1); }); - it('an item with fullList=false is not treated as linked', () => { - const candidateItem = makeItem('candidate-ref', 'ModelCandidate'); - const parentItem = makeItem('parent-ref', 'ModelParent', { - items: { referenceName: 'candidate-ref', fullList: false }, + it("an item with fullList=false is not treated as linked", () => { + const candidateItem = makeItem("candidate-ref", "ModelCandidate"); + const parentItem = makeItem("parent-ref", "ModelParent", { + items: { referenceName: "candidate-ref", fullList: false }, }); const result = getContentItemTypes([candidateItem, parentItem], makeValidOpts()); @@ -235,10 +225,10 @@ describe('getContentItemTypes — linked items', () => { // ─── reference to unknown item ──────────────────────────────────────────────── -describe('getContentItemTypes — reference to unknown referenceName', () => { - it('does not crash when a referenced referenceName is not found in contentItems', () => { - const parentItem = makeItem('parent-ref', 'ModelParent', { - items: { referenceName: 'ghost-ref', fullList: true }, +describe("getContentItemTypes — reference to unknown referenceName", () => { + it("does not crash when a referenced referenceName is not found in contentItems", () => { + const parentItem = makeItem("parent-ref", "ModelParent", { + items: { referenceName: "ghost-ref", fullList: true }, }); const result = getContentItemTypes([parentItem], makeValidOpts()); @@ -250,31 +240,31 @@ describe('getContentItemTypes — reference to unknown referenceName', () => { // ─── recursive / nested references ─────────────────────────────────────────── -describe('getContentItemTypes — recursive references', () => { - it('marks transitively referenced items as linked', () => { - const deepItem = makeItem('deep-ref', 'ModelDeep'); - const midItem = makeItem('mid-ref', 'ModelMid', { - nested: { referenceName: 'deep-ref', fullList: true }, +describe("getContentItemTypes — recursive references", () => { + it("marks transitively referenced items as linked", () => { + const deepItem = makeItem("deep-ref", "ModelDeep"); + const midItem = makeItem("mid-ref", "ModelMid", { + nested: { referenceName: "deep-ref", fullList: true }, }); - const topItem = makeItem('top-ref', 'ModelTop', { - items: { referenceName: 'mid-ref', fullList: true }, + const topItem = makeItem("top-ref", "ModelTop", { + items: { referenceName: "mid-ref", fullList: true }, }); const result = getContentItemTypes([deepItem, midItem, topItem], makeValidOpts()); expect(result.normalContentItems).toHaveLength(1); expect(result.normalContentItems[0]).toBe(topItem); expect(result.linkedContentItems).toHaveLength(2); - const linkedIds = result.linkedContentItems.map(i => i.contentID); + const linkedIds = result.linkedContentItems.map((i) => i.contentID); expect(linkedIds).toContain(deepItem.contentID); expect(linkedIds).toContain(midItem.contentID); }); - it('handles circular references without infinite loop', () => { - const itemA = makeItem('ref-a', 'ModelA', { - loop: { referenceName: 'ref-b', fullList: true }, + it("handles circular references without infinite loop", () => { + const itemA = makeItem("ref-a", "ModelA", { + loop: { referenceName: "ref-b", fullList: true }, }); - const itemB = makeItem('ref-b', 'ModelB', { - loop: { referenceName: 'ref-a', fullList: true }, + const itemB = makeItem("ref-b", "ModelB", { + loop: { referenceName: "ref-a", fullList: true }, }); expect(() => getContentItemTypes([itemA, itemB], makeValidOpts())).not.toThrow(); diff --git a/src/lib/pushers/content-pusher/util/tests/has-unresolved-content-references.test.ts b/src/lib/pushers/content-pusher/util/tests/has-unresolved-content-references.test.ts index da648e3..fe03813 100644 --- a/src/lib/pushers/content-pusher/util/tests/has-unresolved-content-references.test.ts +++ b/src/lib/pushers/content-pusher/util/tests/has-unresolved-content-references.test.ts @@ -1,11 +1,11 @@ -import { resetState } from 'core/state'; -import { hasUnresolvedContentReferences } from '../has-unresolved-content-references'; +import { resetState } from "core/state"; +import { hasUnresolvedContentReferences } from "../has-unresolved-content-references"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -16,115 +16,116 @@ afterEach(() => { function makeMapper(resolved: boolean): any { return { - getContentItemMappingByContentID: jest.fn().mockReturnValue( - resolved ? { sourceContentID: 1, targetContentID: 100 } : null - ), + getContentItemMappingByContentID: jest + .fn() + .mockReturnValue(resolved ? { sourceContentID: 1, targetContentID: 100 } : null), }; } function makePartialMapper(resolvedIds: number[]): any { return { - getContentItemMappingByContentID: jest.fn().mockImplementation( - (id: number) => + getContentItemMappingByContentID: jest + .fn() + .mockImplementation((id: number) => resolvedIds.includes(id) ? { sourceContentID: id, targetContentID: id + 1000 } : null - ), + ), }; } // ─── non-object primitives ──────────────────────────────────────────────────── -describe('hasUnresolvedContentReferences — non-object primitives', () => { +describe("hasUnresolvedContentReferences — non-object primitives", () => { it.each([ - ['null', null], - ['a string', 'hello'], - ['a number', 42], - ['true', true], - ['undefined', undefined], - ])('returns false for %s', (_label, value) => { + ["null", null], + ["a string", "hello"], + ["a number", 42], + ["true", true], + ["undefined", undefined], + ])("returns false for %s", (_label, value) => { expect(hasUnresolvedContentReferences(value, makeMapper(true))).toBe(false); }); }); // ─── contentid (lowercase) ──────────────────────────────────────────────────── -describe('hasUnresolvedContentReferences — contentid key', () => { - it('returns false when contentid is resolved in mapper', () => { +describe("hasUnresolvedContentReferences — contentid key", () => { + it("returns false when contentid is resolved in mapper", () => { const mapper = makeMapper(true); expect(hasUnresolvedContentReferences({ contentid: 5 }, mapper)).toBe(false); - expect(mapper.getContentItemMappingByContentID).toHaveBeenCalledWith(5, 'source'); + expect(mapper.getContentItemMappingByContentID).toHaveBeenCalledWith(5, "source"); }); - it('returns true when contentid is not found in mapper', () => { + it("returns true when contentid is not found in mapper", () => { const mapper = makeMapper(false); expect(hasUnresolvedContentReferences({ contentid: 5 }, mapper)).toBe(true); }); - it('ignores contentid when value is a string (not a number)', () => { + it("ignores contentid when value is a string (not a number)", () => { const mapper = makeMapper(false); - expect(hasUnresolvedContentReferences({ contentid: 'abc' }, mapper)).toBe(false); + expect(hasUnresolvedContentReferences({ contentid: "abc" }, mapper)).toBe(false); expect(mapper.getContentItemMappingByContentID).not.toHaveBeenCalled(); }); }); // ─── contentID (camelCase) ──────────────────────────────────────────────────── -describe('hasUnresolvedContentReferences — contentID key', () => { - it('returns false when contentID is resolved', () => { +describe("hasUnresolvedContentReferences — contentID key", () => { + it("returns false when contentID is resolved", () => { const mapper = makeMapper(true); expect(hasUnresolvedContentReferences({ contentID: 99 }, mapper)).toBe(false); }); - it('returns true when contentID is unresolved', () => { + it("returns true when contentID is unresolved", () => { const mapper = makeMapper(false); expect(hasUnresolvedContentReferences({ contentID: 99 }, mapper)).toBe(true); }); - it('ignores string contentID values', () => { + it("ignores string contentID values", () => { const mapper = makeMapper(false); - expect(hasUnresolvedContentReferences({ contentID: 'not-a-number' }, mapper)).toBe(false); + expect(hasUnresolvedContentReferences({ contentID: "not-a-number" }, mapper)).toBe(false); }); }); // ─── sortids ───────────────────────────────────────────────────────────────── -describe('hasUnresolvedContentReferences — sortids key', () => { - it('returns false when all sortids are resolved', () => { +describe("hasUnresolvedContentReferences — sortids key", () => { + it("returns false when all sortids are resolved", () => { const mapper = makePartialMapper([1, 2, 3]); - expect(hasUnresolvedContentReferences({ sortids: '1,2,3' }, mapper)).toBe(false); + expect(hasUnresolvedContentReferences({ sortids: "1,2,3" }, mapper)).toBe(false); }); - it('returns true when at least one sortid is unresolved', () => { + it("returns true when at least one sortid is unresolved", () => { const mapper = makePartialMapper([1, 3]); - expect(hasUnresolvedContentReferences({ sortids: '1,2,3' }, mapper)).toBe(true); + expect(hasUnresolvedContentReferences({ sortids: "1,2,3" }, mapper)).toBe(true); }); - it('ignores blank entries in sortids', () => { + it("ignores blank entries in sortids", () => { const mapper = makePartialMapper([1, 2]); - expect(hasUnresolvedContentReferences({ sortids: '1,,2,' }, mapper)).toBe(false); + expect(hasUnresolvedContentReferences({ sortids: "1,,2," }, mapper)).toBe(false); }); - it('skips NaN entries in sortids', () => { + it("skips NaN entries in sortids", () => { const mapper = makePartialMapper([]); - expect(hasUnresolvedContentReferences({ sortids: 'abc,def' }, mapper)).toBe(false); + expect(hasUnresolvedContentReferences({ sortids: "abc,def" }, mapper)).toBe(false); expect(mapper.getContentItemMappingByContentID).not.toHaveBeenCalled(); }); - it('handles empty sortids string', () => { + it("handles empty sortids string", () => { const mapper = makeMapper(false); - expect(hasUnresolvedContentReferences({ sortids: '' }, mapper)).toBe(false); + expect(hasUnresolvedContentReferences({ sortids: "" }, mapper)).toBe(false); }); }); // ─── nested objects ─────────────────────────────────────────────────────────── -describe('hasUnresolvedContentReferences — nested objects', () => { - it('returns true when an unresolved contentid is buried in a nested object', () => { +describe("hasUnresolvedContentReferences — nested objects", () => { + it("returns true when an unresolved contentid is buried in a nested object", () => { const mapper = makeMapper(false); const obj = { outer: { inner: { contentid: 10 } } }; expect(hasUnresolvedContentReferences(obj, mapper)).toBe(true); }); - it('returns false when all nested contentids are resolved', () => { + it("returns false when all nested contentids are resolved", () => { const mapper = makeMapper(true); const obj = { outer: { inner: { contentid: 10 } } }; expect(hasUnresolvedContentReferences(obj, mapper)).toBe(false); @@ -133,20 +134,20 @@ describe('hasUnresolvedContentReferences — nested objects', () => { // ─── arrays ────────────────────────────────────────────────────────────────── -describe('hasUnresolvedContentReferences — arrays', () => { - it('returns true when any array element has an unresolved reference', () => { +describe("hasUnresolvedContentReferences — arrays", () => { + it("returns true when any array element has an unresolved reference", () => { const mapper = makePartialMapper([1]); const arr = [{ contentid: 1 }, { contentid: 99 }]; expect(hasUnresolvedContentReferences(arr, mapper)).toBe(true); }); - it('returns false when all array elements are resolved', () => { + it("returns false when all array elements are resolved", () => { const mapper = makePartialMapper([1, 2]); const arr = [{ contentid: 1 }, { contentid: 2 }]; expect(hasUnresolvedContentReferences(arr, mapper)).toBe(false); }); - it('returns false for an empty array', () => { + it("returns false for an empty array", () => { const mapper = makeMapper(false); expect(hasUnresolvedContentReferences([], mapper)).toBe(false); }); @@ -154,16 +155,16 @@ describe('hasUnresolvedContentReferences — arrays', () => { // ─── combination cases ──────────────────────────────────────────────────────── -describe('hasUnresolvedContentReferences — combination cases', () => { - it('returns false when object has unrelated keys only', () => { +describe("hasUnresolvedContentReferences — combination cases", () => { + it("returns false when object has unrelated keys only", () => { const mapper = makeMapper(false); - expect(hasUnresolvedContentReferences({ title: 'Hello', count: 5 }, mapper)).toBe(false); + expect(hasUnresolvedContentReferences({ title: "Hello", count: 5 }, mapper)).toBe(false); expect(mapper.getContentItemMappingByContentID).not.toHaveBeenCalled(); }); - it('early-exits on first unresolved reference (does not scan the rest)', () => { + it("early-exits on first unresolved reference (does not scan the rest)", () => { const mapper = makeMapper(false); - const obj = { contentid: 1, sortids: '2,3', nested: { contentID: 4 } }; + const obj = { contentid: 1, sortids: "2,3", nested: { contentID: 4 } }; const result = hasUnresolvedContentReferences(obj, mapper); expect(result).toBe(true); expect(mapper.getContentItemMappingByContentID).toHaveBeenCalledTimes(1); diff --git a/src/lib/pushers/content-pusher/util/tests/has-valid-mappings.test.ts b/src/lib/pushers/content-pusher/util/tests/has-valid-mappings.test.ts index 064a4e4..e03ac17 100644 --- a/src/lib/pushers/content-pusher/util/tests/has-valid-mappings.test.ts +++ b/src/lib/pushers/content-pusher/util/tests/has-valid-mappings.test.ts @@ -1,19 +1,19 @@ -import { resetState } from 'core/state'; -import { hasValidMappings } from '../has-valid-mappings'; +import { resetState } from "core/state"; +import { hasValidMappings } from "../has-valid-mappings"; -jest.mock('lib/mappers/container-mapper', () => ({ +jest.mock("lib/mappers/container-mapper", () => ({ ContainerMapper: jest.fn(), })); -jest.mock('lib/mappers/model-mapper', () => ({ +jest.mock("lib/mappers/model-mapper", () => ({ ModelMapper: jest.fn(), })); beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -22,7 +22,7 @@ afterEach(() => { // ─── helpers ────────────────────────────────────────────────────────────────── -function makeItem(referenceName = 'my-container', definitionName = 'MyModel'): any { +function makeItem(referenceName = "my-container", definitionName = "MyModel"): any { return { contentID: 1, properties: { referenceName, definitionName }, @@ -30,20 +30,14 @@ function makeItem(referenceName = 'my-container', definitionName = 'MyModel'): a }; } -function makeContainerMapper( - mappingResult: any, - entityResult: any -): any { +function makeContainerMapper(mappingResult: any, entityResult: any): any { return { getContainerMappingByReferenceName: jest.fn().mockReturnValue(mappingResult), getMappedEntity: jest.fn().mockReturnValue(entityResult), }; } -function makeModelMapper( - mappingResult: any, - entityResult: any -): any { +function makeModelMapper(mappingResult: any, entityResult: any): any { return { getModelMappingByReferenceName: jest.fn().mockReturnValue(mappingResult), getMappedEntity: jest.fn().mockReturnValue(entityResult), @@ -52,46 +46,40 @@ function makeModelMapper( // ─── both valid ─────────────────────────────────────────────────────────────── -describe('hasValidMappings — both container and model valid', () => { - it('returns true when both container and model are found', () => { +describe("hasValidMappings — both container and model valid", () => { + it("returns true when both container and model are found", () => { const containerMapper = makeContainerMapper({ sourceContentViewID: 1 }, { contentViewID: 1 }); const modelMapper = makeModelMapper({ sourceID: 10 }, { id: 10 }); expect(hasValidMappings(makeItem(), containerMapper, modelMapper)).toBe(true); }); - it('passes lowercased referenceName to containerMapper', () => { + it("passes lowercased referenceName to containerMapper", () => { const containerMapper = makeContainerMapper({ sourceContentViewID: 1 }, { contentViewID: 1 }); const modelMapper = makeModelMapper({ sourceID: 10 }, { id: 10 }); - const item = makeItem('MyContainer', 'MyModel'); + const item = makeItem("MyContainer", "MyModel"); hasValidMappings(item, containerMapper, modelMapper); - expect(containerMapper.getContainerMappingByReferenceName).toHaveBeenCalledWith( - 'mycontainer', - 'source' - ); + expect(containerMapper.getContainerMappingByReferenceName).toHaveBeenCalledWith("mycontainer", "source"); }); - it('passes lowercased definitionName to modelMapper', () => { + it("passes lowercased definitionName to modelMapper", () => { const containerMapper = makeContainerMapper({ sourceContentViewID: 1 }, { contentViewID: 1 }); const modelMapper = makeModelMapper({ sourceID: 10 }, { id: 10 }); - const item = makeItem('MyContainer', 'MyModel'); + const item = makeItem("MyContainer", "MyModel"); hasValidMappings(item, containerMapper, modelMapper); - expect(modelMapper.getModelMappingByReferenceName).toHaveBeenCalledWith( - 'mymodel', - 'source' - ); + expect(modelMapper.getModelMappingByReferenceName).toHaveBeenCalledWith("mymodel", "source"); }); }); // ─── container missing ──────────────────────────────────────────────────────── -describe('hasValidMappings — container missing', () => { - it('returns false when container mapping is not found', () => { +describe("hasValidMappings — container missing", () => { + it("returns false when container mapping is not found", () => { const containerMapper = makeContainerMapper(null, null); const modelMapper = makeModelMapper({ sourceID: 10 }, { id: 10 }); expect(hasValidMappings(makeItem(), containerMapper, modelMapper)).toBe(false); }); - it('returns false when container entity is null even if mapping exists', () => { + it("returns false when container entity is null even if mapping exists", () => { const containerMapper = makeContainerMapper({ sourceContentViewID: 1 }, null); const modelMapper = makeModelMapper({ sourceID: 10 }, { id: 10 }); expect(hasValidMappings(makeItem(), containerMapper, modelMapper)).toBe(false); @@ -100,14 +88,14 @@ describe('hasValidMappings — container missing', () => { // ─── model missing ──────────────────────────────────────────────────────────── -describe('hasValidMappings — model missing', () => { - it('returns false when model mapping is not found', () => { +describe("hasValidMappings — model missing", () => { + it("returns false when model mapping is not found", () => { const containerMapper = makeContainerMapper({ sourceContentViewID: 1 }, { contentViewID: 1 }); const modelMapper = makeModelMapper(null, null); expect(hasValidMappings(makeItem(), containerMapper, modelMapper)).toBe(false); }); - it('returns false when model entity is null even if mapping exists', () => { + it("returns false when model entity is null even if mapping exists", () => { const containerMapper = makeContainerMapper({ sourceContentViewID: 1 }, { contentViewID: 1 }); const modelMapper = makeModelMapper({ sourceID: 10 }, null); expect(hasValidMappings(makeItem(), containerMapper, modelMapper)).toBe(false); @@ -116,8 +104,8 @@ describe('hasValidMappings — model missing', () => { // ─── both missing ──────────────────────────────────────────────────────────── -describe('hasValidMappings — both missing', () => { - it('returns false when both container and model are missing', () => { +describe("hasValidMappings — both missing", () => { + it("returns false when both container and model are missing", () => { const containerMapper = makeContainerMapper(null, null); const modelMapper = makeModelMapper(null, null); expect(hasValidMappings(makeItem(), containerMapper, modelMapper)).toBe(false); @@ -126,23 +114,17 @@ describe('hasValidMappings — both missing', () => { // ─── case insensitivity ─────────────────────────────────────────────────────── -describe('hasValidMappings — case insensitivity', () => { +describe("hasValidMappings — case insensitivity", () => { it.each([ - ['ALL_UPPER', 'UPPERCASE-REF', 'UPPERCASE-MODEL'], - ['mixed case', 'Mixed-Ref', 'MixedModel'], - ['all lower', 'lower-ref', 'lowermodel'], - ])('lowercases %s reference names before lookup', (_label, refName, defName) => { + ["ALL_UPPER", "UPPERCASE-REF", "UPPERCASE-MODEL"], + ["mixed case", "Mixed-Ref", "MixedModel"], + ["all lower", "lower-ref", "lowermodel"], + ])("lowercases %s reference names before lookup", (_label, refName, defName) => { const containerMapper = makeContainerMapper({ sourceContentViewID: 1 }, { contentViewID: 1 }); const modelMapper = makeModelMapper({ sourceID: 10 }, { id: 10 }); const item = makeItem(refName, defName); hasValidMappings(item, containerMapper, modelMapper); - expect(containerMapper.getContainerMappingByReferenceName).toHaveBeenCalledWith( - refName.toLowerCase(), - 'source' - ); - expect(modelMapper.getModelMappingByReferenceName).toHaveBeenCalledWith( - defName.toLowerCase(), - 'source' - ); + expect(containerMapper.getContainerMappingByReferenceName).toHaveBeenCalledWith(refName.toLowerCase(), "source"); + expect(modelMapper.getModelMappingByReferenceName).toHaveBeenCalledWith(defName.toLowerCase(), "source"); }); }); diff --git a/src/lib/pushers/content-pusher/util/types.ts b/src/lib/pushers/content-pusher/util/types.ts index f91f234..39c057c 100644 --- a/src/lib/pushers/content-pusher/util/types.ts +++ b/src/lib/pushers/content-pusher/util/types.ts @@ -5,54 +5,54 @@ import { ContentItemMapper } from "lib/mappers/content-item-mapper"; * Configuration for content batch processing */ export interface ContentBatchConfig { - apiClient: mgmtApi.ApiClient; - targetGuid: string; - sourceGuid: string; - locale: string; - referenceMapper: ContentItemMapper; - batchSize?: number; // Default: 100, Max: 250 - useContentFieldMapper?: boolean; // Whether to use enhanced field mapping - defaultAssetUrl?: string; // Default asset URL for content mapping - targetData?: any; // Target instance data for checking existing content - onBatchComplete?: (batchResult: BatchProcessingResult, batchNumber: number) => Promise; // Callback after each batch completes + apiClient: mgmtApi.ApiClient; + targetGuid: string; + sourceGuid: string; + locale: string; + referenceMapper: ContentItemMapper; + batchSize?: number; // Default: 100, Max: 250 + useContentFieldMapper?: boolean; // Whether to use enhanced field mapping + defaultAssetUrl?: string; // Default asset URL for content mapping + targetData?: any; // Target instance data for checking existing content + onBatchComplete?: (batchResult: BatchProcessingResult, batchNumber: number) => Promise; // Callback after each batch completes } /** * Result of processing a single batch */ export interface BatchProcessingResult { - successCount: number; - failureCount: number; - skippedCount: number; // Number of items skipped due to existing content - successfulItems: BatchSuccessItem[]; - failedItems: BatchFailedItem[]; - publishableIds: number[]; // Target content IDs for workflow operations + successCount: number; + failureCount: number; + skippedCount: number; // Number of items skipped due to existing content + successfulItems: BatchSuccessItem[]; + failedItems: BatchFailedItem[]; + publishableIds: number[]; // Target content IDs for workflow operations } /** * Successful item with original content and new ID */ export interface BatchSuccessItem { - originalContent: mgmtApi.ContentItem; - newItem: mgmtApi.BatchItem; - newContentId: number; + originalContent: mgmtApi.ContentItem; + newItem: mgmtApi.BatchItem; + newContentId: number; } /** * Failed item with original content and error details */ export interface BatchFailedItem { - originalContent: mgmtApi.ContentItem; - error: string; + originalContent: mgmtApi.ContentItem; + error: string; } /** * Progress callback for batch processing */ export type BatchProgressCallback = ( - batchNumber: number, - totalBatches: number, - processed: number, - total: number, - status: "processing" | "success" | "error" + batchNumber: number, + totalBatches: number, + processed: number, + total: number, + status: "processing" | "success" | "error" ) => void; diff --git a/src/lib/pushers/gallery-pusher.ts b/src/lib/pushers/gallery-pusher.ts index 74fde48..34705e2 100644 --- a/src/lib/pushers/gallery-pusher.ts +++ b/src/lib/pushers/gallery-pusher.ts @@ -11,7 +11,7 @@ function extractErrorMessage(error: any): string { // Check for axios response data (actual API error message) if (error?.innerError?.response?.data) { const data = error.innerError.response.data; - if (typeof data === 'string') return data; + if (typeof data === "string") return data; if (data.message) return data.message; if (data.error) return data.error; if (data.Message) return data.Message; @@ -20,7 +20,7 @@ function extractErrorMessage(error: any): string { // Check for direct response data if (error?.response?.data) { const data = error.response.data; - if (typeof data === 'string') return data; + if (typeof data === "string") return data; if (data.message) return data.message; if (data.error) return data.error; } @@ -42,7 +42,6 @@ export async function pushGalleries( const { sourceGuid, targetGuid, overwrite } = state; - // Get the GUID logger from state instead of creating a new one const logger = getLoggerForGuid(sourceGuid[0]) || new Logs("push", "gallery", sourceGuid[0]); @@ -63,16 +62,15 @@ export async function pushGalleries( let processedCount = 0; let overallStatus: "success" | "error" = "success"; - for (const sourceGallery of galleries) { let currentStatus: "success" | "error" = "success"; try { const existingMapping = referenceMapper.getGalleryMapping(sourceGallery, "source"); - + // Check both: mapping file AND if gallery exists by name in target data - const targetGalleryByName = targetData.find(t => t.name === sourceGallery.name); - const targetGalleryById = targetData.find(t => t.mediaGroupingID === existingMapping?.targetMediaGroupingID); - + const targetGalleryByName = targetData.find((t) => t.name === sourceGallery.name); + const targetGalleryById = targetData.find((t) => t.mediaGroupingID === existingMapping?.targetMediaGroupingID); + // If no mapping but gallery exists by name in target, create/update the mapping if (!existingMapping && targetGalleryByName) { // Gallery exists in target by name but no mapping - add mapping and skip @@ -81,7 +79,7 @@ export async function pushGalleries( skipped++; continue; } - + const shouldCreate = existingMapping === null && !targetGalleryByName; if (shouldCreate) { @@ -102,7 +100,14 @@ export async function pushGalleries( if (shouldUpdate) { // Gallery exists but needs updating - await updateGallery(sourceGallery, existingMapping.targetMediaGroupingID, apiClient, targetGuid[0], referenceMapper, logger); + await updateGallery( + sourceGallery, + existingMapping.targetMediaGroupingID, + apiClient, + targetGuid[0], + referenceMapper, + logger + ); successful++; } else if (shouldSkip) { // Gallery exists and is up to date - skip @@ -153,9 +158,7 @@ async function createGallery( modifiedOn: null, // Let API set this isDeleted: false, isFolder: mediaGrouping.isFolder ?? false, - metaData: mediaGrouping.metaData && Object.keys(mediaGrouping.metaData).length > 0 - ? mediaGrouping.metaData - : {} + metaData: mediaGrouping.metaData && Object.keys(mediaGrouping.metaData).length > 0 ? mediaGrouping.metaData : {}, }; // Let errors propagate to caller for proper failure tracking const savedGallery = await apiClient.assetMethods.saveGallery(targetGuid, payload); @@ -188,9 +191,7 @@ async function updateGallery( modifiedOn: null, // Let API set this isDeleted: sourceGallery.isDeleted ?? false, isFolder: sourceGallery.isFolder ?? false, - metaData: sourceGallery.metaData && Object.keys(sourceGallery.metaData).length > 0 - ? sourceGallery.metaData - : {} + metaData: sourceGallery.metaData && Object.keys(sourceGallery.metaData).length > 0 ? sourceGallery.metaData : {}, }; const savedGallery = await apiClient.assetMethods.saveGallery(targetGuid, payload); referenceMapper.addMapping(sourceGallery, savedGallery); diff --git a/src/lib/pushers/guid-data-loader.ts b/src/lib/pushers/guid-data-loader.ts index 3fb8287..7e4131b 100644 --- a/src/lib/pushers/guid-data-loader.ts +++ b/src/lib/pushers/guid-data-loader.ts @@ -10,412 +10,421 @@ * ✅ FLEXIBLE: Works with any GUID (source or target) */ -import * as fs from 'fs'; -import ansiColors from 'ansi-colors'; -import { fileOperations } from '../../core/fileOperations'; -import { getApiClient, getState } from '../../core/state'; +import * as fs from "fs"; +import ansiColors from "ansi-colors"; +import { fileOperations } from "../../core/fileOperations"; +import { getApiClient, getState } from "../../core/state"; export interface ModelFilterOptions { - models?: string[]; // Simple model filtering - modelsWithDeps?: string[]; // Model filtering with dependency tree + models?: string[]; // Simple model filtering + modelsWithDeps?: string[]; // Model filtering with dependency tree } export interface GuidEntities { - pages: any[]; - templates: any[]; - containers: any[]; - lists: any[]; - models: any[]; - content: any[]; - assets: any[]; - galleries: any[]; + pages: any[]; + templates: any[]; + containers: any[]; + lists: any[]; + models: any[]; + content: any[]; + assets: any[]; + galleries: any[]; } export class GuidDataLoader { + private guid: string; + private static hasLoggedDependencyTree = false; + + constructor(guid: string) { + this.guid = guid; + } + + /** + * Reset logging flags for a new operation + */ + static resetLoggingFlags(): void { + GuidDataLoader.hasLoggedDependencyTree = false; + } + + /** + * Load all entities for the specified GUID and locale - guarantees arrays are always returned + */ + async loadGuidEntities(locale: string, filterOptions?: ModelFilterOptions): Promise { + const state = getState(); + + // For sync operations or models-with-deps, we need ALL elements for proper change detection + // Element filtering happens at the processing level, not the loading level + const needsCompleteData = state.isSync || state.modelsWithDeps; + const elements = needsCompleteData + ? ["Galleries", "Assets", "Models", "Containers", "Content", "Templates", "Pages", "Sitemaps"] + : state.elements.split(","); + + const guidFileOps = new fileOperations(this.guid); + const localeFileOps = new fileOperations(this.guid, locale); + + // Initialize with empty arrays - no nulls/undefined ever + const guidEntities: GuidEntities = { + assets: [], + galleries: [], + models: [], + containers: [], + lists: [], + content: [], + pages: [], + templates: [], + }; + + // Load different entity types using pure getters for consistent architecture + if (elements.includes("Galleries")) { + const { getGalleriesFromFileSystem } = await import("../getters/filesystem/get-galleries"); + const galleries = getGalleriesFromFileSystem(guidFileOps); + guidEntities.galleries = Array.isArray(galleries) ? galleries : []; + } - private guid: string; - private static hasLoggedDependencyTree = false; + if (elements.includes("Assets")) { + const { getAssetsFromFileSystem } = await import("../getters/filesystem/get-assets"); + const assets = getAssetsFromFileSystem(guidFileOps); + guidEntities.assets = Array.isArray(assets) ? assets : []; + } - constructor(guid: string) { - this.guid = guid; + if (elements.includes("Models")) { + const { getModelsFromFileSystem } = await import("../getters/filesystem/get-models"); + const models = getModelsFromFileSystem(guidFileOps); + guidEntities.models = Array.isArray(models) ? models : []; } - /** - * Reset logging flags for a new operation - */ - static resetLoggingFlags(): void { - GuidDataLoader.hasLoggedDependencyTree = false; + if (elements.includes("Containers")) { + const { getListsFromFileSystem, getContainersFromFileSystem } = + await import("../getters/filesystem/get-containers"); + const containers = getContainersFromFileSystem(guidFileOps); + guidEntities.containers = Array.isArray(containers) ? containers : []; + + const lists = getListsFromFileSystem(guidFileOps); + guidEntities.lists = Array.isArray(lists) ? lists : []; } - /** - * Load all entities for the specified GUID and locale - guarantees arrays are always returned - */ - async loadGuidEntities(locale: string, filterOptions?: ModelFilterOptions): Promise { - const state = getState(); - - // For sync operations or models-with-deps, we need ALL elements for proper change detection - // Element filtering happens at the processing level, not the loading level - const needsCompleteData = state.isSync || state.modelsWithDeps; - const elements = needsCompleteData ? - ['Galleries', 'Assets', 'Models', 'Containers', 'Content', 'Templates', 'Pages', 'Sitemaps'] : - state.elements.split(','); - - const guidFileOps = new fileOperations(this.guid); - const localeFileOps = new fileOperations(this.guid, locale); - - // Initialize with empty arrays - no nulls/undefined ever - const guidEntities: GuidEntities = { - - assets: [], - galleries: [], - models: [], - containers: [], - lists: [], - content: [], - pages: [], - templates: [] - }; - - // Load different entity types using pure getters for consistent architecture - if (elements.includes('Galleries')) { - const { getGalleriesFromFileSystem } = await import('../getters/filesystem/get-galleries'); - const galleries = getGalleriesFromFileSystem(guidFileOps); - guidEntities.galleries = Array.isArray(galleries) ? galleries : []; - } - - if (elements.includes('Assets')) { - const { getAssetsFromFileSystem } = await import('../getters/filesystem/get-assets'); - const assets = getAssetsFromFileSystem(guidFileOps); - guidEntities.assets = Array.isArray(assets) ? assets : []; - } - - if (elements.includes('Models')) { - const { getModelsFromFileSystem } = await import('../getters/filesystem/get-models'); - const models = getModelsFromFileSystem(guidFileOps); - guidEntities.models = Array.isArray(models) ? models : []; - } - - if (elements.includes('Containers')) { - const { getListsFromFileSystem, getContainersFromFileSystem } = await import('../getters/filesystem/get-containers'); - const containers = getContainersFromFileSystem(guidFileOps); - guidEntities.containers = Array.isArray(containers) ? containers : []; - - const lists = getListsFromFileSystem(guidFileOps); - guidEntities.lists = Array.isArray(lists) ? lists : []; - } - - if (elements.includes('Content')) { - const { getContentItemsFromFileSystem } = await import('../getters/filesystem/get-content-items'); - const content = getContentItemsFromFileSystem(localeFileOps); - guidEntities.content = Array.isArray(content) ? content : []; - } - - if (elements.includes('Templates')) { - const { getTemplatesFromFileSystem } = await import('../getters/filesystem/get-templates'); - const templates = getTemplatesFromFileSystem(guidFileOps); - guidEntities.templates = Array.isArray(templates) ? templates : []; - } - - if (elements.includes('Pages')) { - const { getPagesFromFileSystem } = await import('../getters/filesystem/get-pages'); - const pages = getPagesFromFileSystem(localeFileOps); - guidEntities.pages = Array.isArray(pages) ? pages : []; - } - - // Apply model filtering if requested - if (filterOptions) { - return await this.applyModelFiltering(guidEntities, filterOptions, locale); - } - - return guidEntities; + if (elements.includes("Content")) { + const { getContentItemsFromFileSystem } = await import("../getters/filesystem/get-content-items"); + const content = getContentItemsFromFileSystem(localeFileOps); + guidEntities.content = Array.isArray(content) ? content : []; } - /** - * Apply model filtering using existing ModelDependencyTreeBuilder - */ - private async applyModelFiltering(guidEntities: GuidEntities, filterOptions: ModelFilterOptions, locale: string): Promise { - // Determine which filtering mode to use - let modelNames: string[] = []; - let useFullDependencyTree = false; - - if (filterOptions.modelsWithDeps && filterOptions.modelsWithDeps.length > 0) { - modelNames = filterOptions.modelsWithDeps; - useFullDependencyTree = true; - } else if (filterOptions.models && filterOptions.models.length > 0) { - modelNames = filterOptions.models; - useFullDependencyTree = false; - } else { - // No filtering requested - return guidEntities; - } - - let completeEntities: GuidEntities | null = null; - if (useFullDependencyTree) { - // Only log the filtering message once per operation - if (!GuidDataLoader.hasLoggedDependencyTree) { - GuidDataLoader.hasLoggedDependencyTree = true; - } - // CRITICAL FIX: For dependency tree filtering, we need to load ALL entities first - // to ensure the dependency tree builder has complete data to work with - completeEntities = await this.loadCompleteGuidEntities(locale); - - // Safety check: ensure completeEntities has models loaded - if (!completeEntities || !completeEntities.models || completeEntities.models.length === 0) { - throw new Error( - `Failed to load models for dependency tree filtering. ` + - `Please ensure you have pulled data first: ` + - `agility pull --guid ${this.guid} --locale ${locale}` - ); - } - } - - // Import and use ModelDependencyTreeBuilder with complete data - const { ModelDependencyTreeBuilder } = await import('../models/model-dependency-tree-builder'); - const treeBuilder = new ModelDependencyTreeBuilder(useFullDependencyTree ? completeEntities! : guidEntities); - - - // Validate that specified models exist - const validation = treeBuilder.validateModels(modelNames, (completeEntities ?? guidEntities).models); - if (validation.invalid.length > 0) { - // Use the correct source for available models (same as validation) - const sourceForValidation = useFullDependencyTree ? completeEntities : guidEntities; - const availableModels = sourceForValidation?.models?.map((m: any) => m.referenceName) || []; - - // Check for case-insensitive matches to help debug - const invalidWithSuggestions = validation.invalid.map(invalidName => { - const caseInsensitiveMatch = availableModels.find((available: string) => - available.toLowerCase() === invalidName.toLowerCase() - ); - return caseInsensitiveMatch - ? `${invalidName} (did you mean "${caseInsensitiveMatch}"?)` - : invalidName; - }); - - console.log(ansiColors.red(`❌ Invalid model names: ${invalidWithSuggestions.join(', ')}`)); - console.log(ansiColors.gray(`Available models (${availableModels.length}): ${availableModels.join(', ')}`)); - - // Throw error to stop sync instead of returning unfiltered data - throw new Error( - `Model validation failed. Invalid model(s): ${validation.invalid.join(', ')}. ` + - `Please check the model name(s) and try again.` - ); - } - - // Build dependency tree and filter all related entities using complete data - const dependencyTree = treeBuilder.buildDependencyTree(validation.valid, locale); - - - if(!useFullDependencyTree) { - return this.filterGuidEntitiesByModels(guidEntities, validation.valid); - } - - return await this.filterGuidEntitiesByDependencyTree(completeEntities, dependencyTree, locale); - - + if (elements.includes("Templates")) { + const { getTemplatesFromFileSystem } = await import("../getters/filesystem/get-templates"); + const templates = getTemplatesFromFileSystem(guidFileOps); + guidEntities.templates = Array.isArray(templates) ? templates : []; } - /** - * Filter entities by dependency tree (full dependency filtering) with incremental change detection - */ - private async filterGuidEntitiesByDependencyTree(guidEntities: GuidEntities, dependencyTree: any, locale: string): Promise { - // Import change detection utilities - const { extractContentItemModifiedDate, extractModelModifiedDate, extractContainerModifiedDate, - extractAssetModifiedDate, extractPageModifiedDate, extractGalleryModifiedDate, - extractTemplateModifiedDate, isEntityModifiedSinceLastPull, getLastPullTimestamp } = - await import('../incremental'); - - const rootPath = 'agility-files'; - - // Filter models with change detection - const filteredModels = guidEntities.models.filter((m: any) => { - if (!dependencyTree.models.has(m.referenceName)) return false; - - const modifiedDate = extractModelModifiedDate(m); - const lastPull = getLastPullTimestamp(this.guid, rootPath, 'models'); - return isEntityModifiedSinceLastPull(modifiedDate, lastPull); - }); - - // Filter containers with change detection - const filteredContainers = guidEntities.containers.filter((c: any) => { - if (!dependencyTree.containers.has(c.contentViewID)) return false; - - const modifiedDate = extractContainerModifiedDate(c); - const lastPull = getLastPullTimestamp(this.guid, rootPath, 'containers'); - return isEntityModifiedSinceLastPull(modifiedDate, lastPull); - }); - - // Filter content with change detection - const filteredContent = guidEntities.content.filter((c: any) => { - if (!dependencyTree.content.has(c.contentID)) return false; - - const modifiedDate = extractContentItemModifiedDate(c); - const lastPull = getLastPullTimestamp(this.guid, rootPath, 'content'); - return isEntityModifiedSinceLastPull(modifiedDate, lastPull); - }); - - // Filter assets with change detection - const filteredAssets = guidEntities.assets.filter((a: any) => { - if (!dependencyTree.assets.has(a.url || a.originUrl || a.edgeUrl)) return false; - - const modifiedDate = extractAssetModifiedDate(a); - const lastPull = getLastPullTimestamp(this.guid, rootPath, 'assets'); - return isEntityModifiedSinceLastPull(modifiedDate, lastPull); - }); - - // Filter pages with change detection - const filteredPages = guidEntities.pages.filter((p: any) => { - if (!dependencyTree.pages.has(p.pageID)) return false; - - const modifiedDate = extractPageModifiedDate(p); - const lastPull = getLastPullTimestamp(this.guid, rootPath, 'pages'); - return isEntityModifiedSinceLastPull(modifiedDate, lastPull); - }); - - // Filter galleries with change detection - const filteredGalleries = guidEntities.galleries.filter((g: any) => { - if (!dependencyTree.galleries.has(g.galleryID)) return false; - - const modifiedDate = extractGalleryModifiedDate(g); - const lastPull = getLastPullTimestamp(this.guid, rootPath, 'galleries'); - return isEntityModifiedSinceLastPull(modifiedDate, lastPull); - }); - - const filteredLists = guidEntities.lists.filter((l: any) => dependencyTree.lists.has(l.contentViewID)); - - // Templates always require full refresh (no change detection) - const filteredTemplates = guidEntities.templates.filter((t: any) => dependencyTree.templates.has(t.id)); - - return { - models: filteredModels, - containers: filteredContainers, - lists: filteredLists, - content: filteredContent, - templates: filteredTemplates, - pages: filteredPages, - assets: filteredAssets, - galleries: filteredGalleries - }; + if (elements.includes("Pages")) { + const { getPagesFromFileSystem } = await import("../getters/filesystem/get-pages"); + const pages = getPagesFromFileSystem(localeFileOps); + guidEntities.pages = Array.isArray(pages) ? pages : []; } - /** - * Filter entities by models only (simple filtering) - */ - private filterGuidEntitiesByModels(guidEntities: GuidEntities, modelNames: string[]): GuidEntities { - const modelSet = new Set(modelNames); - - return { - models: guidEntities.models.filter((m: any) => modelSet.has(m.referenceName)), - containers: [], - lists: [], - content: [], - templates: [], - pages: [], - assets: [], - galleries: [] - }; + // Apply model filtering if requested + if (filterOptions) { + return await this.applyModelFiltering(guidEntities, filterOptions, locale); } - /** - * Check if we have any content to process - */ - hasNoContent(guidEntities: GuidEntities): boolean { - return Object.values(guidEntities).every((arr: any[]) => arr.length === 0); + return guidEntities; + } + + /** + * Apply model filtering using existing ModelDependencyTreeBuilder + */ + private async applyModelFiltering( + guidEntities: GuidEntities, + filterOptions: ModelFilterOptions, + locale: string + ): Promise { + // Determine which filtering mode to use + let modelNames: string[] = []; + let useFullDependencyTree = false; + + if (filterOptions.modelsWithDeps && filterOptions.modelsWithDeps.length > 0) { + modelNames = filterOptions.modelsWithDeps; + useFullDependencyTree = true; + } else if (filterOptions.models && filterOptions.models.length > 0) { + modelNames = filterOptions.models; + useFullDependencyTree = false; + } else { + // No filtering requested + return guidEntities; } - /** - * Get entity counts for summary reporting - */ - getEntityCounts(guidEntities: GuidEntities): Record { - return { - pages: guidEntities.pages.length, - templates: guidEntities.templates.length, - containers: guidEntities.containers.length, - lists: guidEntities.lists.length, - models: guidEntities.models.length, - content: guidEntities.content.length, - assets: guidEntities.assets.length, - galleries: guidEntities.galleries.length - }; + let completeEntities: GuidEntities | null = null; + if (useFullDependencyTree) { + // Only log the filtering message once per operation + if (!GuidDataLoader.hasLoggedDependencyTree) { + GuidDataLoader.hasLoggedDependencyTree = true; + } + // CRITICAL FIX: For dependency tree filtering, we need to load ALL entities first + // to ensure the dependency tree builder has complete data to work with + completeEntities = await this.loadCompleteGuidEntities(locale); + + // Safety check: ensure completeEntities has models loaded + if (!completeEntities || !completeEntities.models || completeEntities.models.length === 0) { + throw new Error( + `Failed to load models for dependency tree filtering. ` + + `Please ensure you have pulled data first: ` + + `agility pull --guid ${this.guid} --locale ${locale}` + ); + } } - /** - * Validate that the data directory exists and contains expected structure - */ - validateDataStructure(locale: string): boolean { - const state = getState(); - // Use enhanced fileOperations instancePath property - const instancePath = new fileOperations(this.guid).instancePath; - - if (!fs.existsSync(instancePath)) { - console.error(ansiColors.red(`❌ Data directory not found for GUID ${this.guid}: ${instancePath}`)); - console.log(ansiColors.yellow(`💡 Make sure you have pulled data first:`)); - console.log(` node dist/index.js pull --guid ${this.guid} --locale ${locale} --channel website --verbose`); - return false; - } - - return true; + // Import and use ModelDependencyTreeBuilder with complete data + const { ModelDependencyTreeBuilder } = await import("../models/model-dependency-tree-builder"); + const treeBuilder = new ModelDependencyTreeBuilder(useFullDependencyTree ? completeEntities! : guidEntities); + + // Validate that specified models exist + const validation = treeBuilder.validateModels(modelNames, (completeEntities ?? guidEntities).models); + if (validation.invalid.length > 0) { + // Use the correct source for available models (same as validation) + const sourceForValidation = useFullDependencyTree ? completeEntities : guidEntities; + const availableModels = sourceForValidation?.models?.map((m: any) => m.referenceName) || []; + + // Check for case-insensitive matches to help debug + const invalidWithSuggestions = validation.invalid.map((invalidName) => { + const caseInsensitiveMatch = availableModels.find( + (available: string) => available.toLowerCase() === invalidName.toLowerCase() + ); + return caseInsensitiveMatch ? `${invalidName} (did you mean "${caseInsensitiveMatch}"?)` : invalidName; + }); + + console.log(ansiColors.red(`❌ Invalid model names: ${invalidWithSuggestions.join(", ")}`)); + console.log(ansiColors.gray(`Available models (${availableModels.length}): ${availableModels.join(", ")}`)); + + // Throw error to stop sync instead of returning unfiltered data + throw new Error( + `Model validation failed. Invalid model(s): ${validation.invalid.join(", ")}. ` + + `Please check the model name(s) and try again.` + ); } - /** - * Load complete GUID entities without any filtering - needed for dependency tree building - */ - private async loadCompleteGuidEntities(locale: string): Promise { - const guidFileOps = new fileOperations(this.guid); - const localeFileOps = new fileOperations(this.guid, locale); - - // Initialize with empty arrays - no nulls/undefined ever - const guidEntities: GuidEntities = { - assets: [], - galleries: [], - models: [], - containers: [], - lists: [], - content: [], - pages: [], - templates: [] - }; - - // Load ALL entity types regardless of state.elements for complete dependency analysis - const { getGalleriesFromFileSystem } = await import('../getters/filesystem/get-galleries'); - const galleries = getGalleriesFromFileSystem(guidFileOps); - guidEntities.galleries = Array.isArray(galleries) ? galleries : []; - - const { getAssetsFromFileSystem } = await import('../getters/filesystem/get-assets'); - const assets = getAssetsFromFileSystem(guidFileOps); - guidEntities.assets = Array.isArray(assets) ? assets : []; - - const { getModelsFromFileSystem } = await import('../getters/filesystem/get-models'); - const models = getModelsFromFileSystem(guidFileOps); - guidEntities.models = Array.isArray(models) ? models : []; - - const { getListsFromFileSystem, getContainersFromFileSystem } = await import('../getters/filesystem/get-containers'); - const containers = getContainersFromFileSystem(guidFileOps); - guidEntities.containers = Array.isArray(containers) ? containers : []; - - const lists = getListsFromFileSystem(guidFileOps); - guidEntities.lists = Array.isArray(lists) ? lists : []; - - const { getContentItemsFromFileSystem } = await import('../getters/filesystem/get-content-items'); - const content = getContentItemsFromFileSystem(localeFileOps); - guidEntities.content = Array.isArray(content) ? content : []; - - const { getTemplatesFromFileSystem } = await import('../getters/filesystem/get-templates'); - const templates = getTemplatesFromFileSystem(guidFileOps); - guidEntities.templates = Array.isArray(templates) ? templates : []; - - const { getPagesFromFileSystem } = await import('../getters/filesystem/get-pages'); - const pages = getPagesFromFileSystem(localeFileOps); - guidEntities.pages = Array.isArray(pages) ? pages : []; - - return guidEntities; + // Build dependency tree and filter all related entities using complete data + const dependencyTree = treeBuilder.buildDependencyTree(validation.valid, locale); + + if (!useFullDependencyTree) { + return this.filterGuidEntitiesByModels(guidEntities, validation.valid); } - /** - * Get the GUID this loader is configured for - */ - getGuid(): string { - return this.guid; + return await this.filterGuidEntitiesByDependencyTree(completeEntities, dependencyTree, locale); + } + + /** + * Filter entities by dependency tree (full dependency filtering) with incremental change detection + */ + private async filterGuidEntitiesByDependencyTree( + guidEntities: GuidEntities, + dependencyTree: any, + locale: string + ): Promise { + // Import change detection utilities + const { + extractContentItemModifiedDate, + extractModelModifiedDate, + extractContainerModifiedDate, + extractAssetModifiedDate, + extractPageModifiedDate, + extractGalleryModifiedDate, + extractTemplateModifiedDate, + isEntityModifiedSinceLastPull, + getLastPullTimestamp, + } = await import("../incremental"); + + const rootPath = "agility-files"; + + // Filter models with change detection + const filteredModels = guidEntities.models.filter((m: any) => { + if (!dependencyTree.models.has(m.referenceName)) return false; + + const modifiedDate = extractModelModifiedDate(m); + const lastPull = getLastPullTimestamp(this.guid, rootPath, "models"); + return isEntityModifiedSinceLastPull(modifiedDate, lastPull); + }); + + // Filter containers with change detection + const filteredContainers = guidEntities.containers.filter((c: any) => { + if (!dependencyTree.containers.has(c.contentViewID)) return false; + + const modifiedDate = extractContainerModifiedDate(c); + const lastPull = getLastPullTimestamp(this.guid, rootPath, "containers"); + return isEntityModifiedSinceLastPull(modifiedDate, lastPull); + }); + + // Filter content with change detection + const filteredContent = guidEntities.content.filter((c: any) => { + if (!dependencyTree.content.has(c.contentID)) return false; + + const modifiedDate = extractContentItemModifiedDate(c); + const lastPull = getLastPullTimestamp(this.guid, rootPath, "content"); + return isEntityModifiedSinceLastPull(modifiedDate, lastPull); + }); + + // Filter assets with change detection + const filteredAssets = guidEntities.assets.filter((a: any) => { + if (!dependencyTree.assets.has(a.url || a.originUrl || a.edgeUrl)) return false; + + const modifiedDate = extractAssetModifiedDate(a); + const lastPull = getLastPullTimestamp(this.guid, rootPath, "assets"); + return isEntityModifiedSinceLastPull(modifiedDate, lastPull); + }); + + // Filter pages with change detection + const filteredPages = guidEntities.pages.filter((p: any) => { + if (!dependencyTree.pages.has(p.pageID)) return false; + + const modifiedDate = extractPageModifiedDate(p); + const lastPull = getLastPullTimestamp(this.guid, rootPath, "pages"); + return isEntityModifiedSinceLastPull(modifiedDate, lastPull); + }); + + // Filter galleries with change detection + const filteredGalleries = guidEntities.galleries.filter((g: any) => { + if (!dependencyTree.galleries.has(g.galleryID)) return false; + + const modifiedDate = extractGalleryModifiedDate(g); + const lastPull = getLastPullTimestamp(this.guid, rootPath, "galleries"); + return isEntityModifiedSinceLastPull(modifiedDate, lastPull); + }); + + const filteredLists = guidEntities.lists.filter((l: any) => dependencyTree.lists.has(l.contentViewID)); + + // Templates always require full refresh (no change detection) + const filteredTemplates = guidEntities.templates.filter((t: any) => dependencyTree.templates.has(t.id)); + + return { + models: filteredModels, + containers: filteredContainers, + lists: filteredLists, + content: filteredContent, + templates: filteredTemplates, + pages: filteredPages, + assets: filteredAssets, + galleries: filteredGalleries, + }; + } + + /** + * Filter entities by models only (simple filtering) + */ + private filterGuidEntitiesByModels(guidEntities: GuidEntities, modelNames: string[]): GuidEntities { + const modelSet = new Set(modelNames); + + return { + models: guidEntities.models.filter((m: any) => modelSet.has(m.referenceName)), + containers: [], + lists: [], + content: [], + templates: [], + pages: [], + assets: [], + galleries: [], + }; + } + + /** + * Check if we have any content to process + */ + hasNoContent(guidEntities: GuidEntities): boolean { + return Object.values(guidEntities).every((arr: any[]) => arr.length === 0); + } + + /** + * Get entity counts for summary reporting + */ + getEntityCounts(guidEntities: GuidEntities): Record { + return { + pages: guidEntities.pages.length, + templates: guidEntities.templates.length, + containers: guidEntities.containers.length, + lists: guidEntities.lists.length, + models: guidEntities.models.length, + content: guidEntities.content.length, + assets: guidEntities.assets.length, + galleries: guidEntities.galleries.length, + }; + } + + /** + * Validate that the data directory exists and contains expected structure + */ + validateDataStructure(locale: string): boolean { + const state = getState(); + // Use enhanced fileOperations instancePath property + const instancePath = new fileOperations(this.guid).instancePath; + + if (!fs.existsSync(instancePath)) { + console.error(ansiColors.red(`❌ Data directory not found for GUID ${this.guid}: ${instancePath}`)); + console.log(ansiColors.yellow(`💡 Make sure you have pulled data first:`)); + console.log(` node dist/index.js pull --guid ${this.guid} --locale ${locale} --channel website --verbose`); + return false; } + + return true; + } + + /** + * Load complete GUID entities without any filtering - needed for dependency tree building + */ + private async loadCompleteGuidEntities(locale: string): Promise { + const guidFileOps = new fileOperations(this.guid); + const localeFileOps = new fileOperations(this.guid, locale); + + // Initialize with empty arrays - no nulls/undefined ever + const guidEntities: GuidEntities = { + assets: [], + galleries: [], + models: [], + containers: [], + lists: [], + content: [], + pages: [], + templates: [], + }; + + // Load ALL entity types regardless of state.elements for complete dependency analysis + const { getGalleriesFromFileSystem } = await import("../getters/filesystem/get-galleries"); + const galleries = getGalleriesFromFileSystem(guidFileOps); + guidEntities.galleries = Array.isArray(galleries) ? galleries : []; + + const { getAssetsFromFileSystem } = await import("../getters/filesystem/get-assets"); + const assets = getAssetsFromFileSystem(guidFileOps); + guidEntities.assets = Array.isArray(assets) ? assets : []; + + const { getModelsFromFileSystem } = await import("../getters/filesystem/get-models"); + const models = getModelsFromFileSystem(guidFileOps); + guidEntities.models = Array.isArray(models) ? models : []; + + const { getListsFromFileSystem, getContainersFromFileSystem } = + await import("../getters/filesystem/get-containers"); + const containers = getContainersFromFileSystem(guidFileOps); + guidEntities.containers = Array.isArray(containers) ? containers : []; + + const lists = getListsFromFileSystem(guidFileOps); + guidEntities.lists = Array.isArray(lists) ? lists : []; + + const { getContentItemsFromFileSystem } = await import("../getters/filesystem/get-content-items"); + const content = getContentItemsFromFileSystem(localeFileOps); + guidEntities.content = Array.isArray(content) ? content : []; + + const { getTemplatesFromFileSystem } = await import("../getters/filesystem/get-templates"); + const templates = getTemplatesFromFileSystem(guidFileOps); + guidEntities.templates = Array.isArray(templates) ? templates : []; + + const { getPagesFromFileSystem } = await import("../getters/filesystem/get-pages"); + const pages = getPagesFromFileSystem(localeFileOps); + guidEntities.pages = Array.isArray(pages) ? pages : []; + + return guidEntities; + } + + /** + * Get the GUID this loader is configured for + */ + getGuid(): string { + return this.guid; + } } // Keep backward compatibility with existing code diff --git a/src/lib/pushers/index.ts b/src/lib/pushers/index.ts index 17f828b..52c5cd6 100644 --- a/src/lib/pushers/index.ts +++ b/src/lib/pushers/index.ts @@ -1,10 +1,10 @@ -export * from './asset-pusher'; -export * from './container-pusher'; -export * from './content-pusher/content-pusher'; -export * from './content-pusher/content-pusher'; -export * from './gallery-pusher'; -export * from './model-pusher'; -export * from './orchestrate-pushers'; -export * from './page-pusher/push-pages'; -export * from './push-operations-config'; -export * from './template-pusher'; \ No newline at end of file +export * from "./asset-pusher"; +export * from "./container-pusher"; +export * from "./content-pusher/content-pusher"; +export * from "./content-pusher/content-pusher"; +export * from "./gallery-pusher"; +export * from "./model-pusher"; +export * from "./orchestrate-pushers"; +export * from "./page-pusher/push-pages"; +export * from "./push-operations-config"; +export * from "./template-pusher"; diff --git a/src/lib/pushers/model-pusher.ts b/src/lib/pushers/model-pusher.ts index b7b68a6..5ea663f 100644 --- a/src/lib/pushers/model-pusher.ts +++ b/src/lib/pushers/model-pusher.ts @@ -45,7 +45,7 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp logger.model.error( sourceModel, "Model is missing required properties (id or referenceName), skipping", - targetGuid[0], + targetGuid[0] ); skipped++; continue; @@ -73,7 +73,7 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp // Handle models that exist in target but have no mapping // This ensures downstream containers can find their model mappings const targetModelByReference = targetData.find( - (targetModel) => targetModel.referenceName === sourceModel.referenceName, + (targetModel) => targetModel.referenceName === sourceModel.referenceName ); const existsInTargetWithoutMapping = !sourceMapping && targetModelByReference; if (existsInTargetWithoutMapping) { @@ -92,14 +92,14 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp logger.model.error( sourceModel, new Error( - `A target model named "${sourceModel.referenceName}" exists but is not mapped to source ID ${sourceModel.id} (likely a rename or reassignment of the source model).`, + `A target model named "${sourceModel.referenceName}" exists but is not mapped to source ID ${sourceModel.id} (likely a rename or reassignment of the source model).` ), - targetGuid[0], + targetGuid[0] ); throw new Error( `Model validation failed: mapping inconsistency for model "${sourceModel.referenceName}" (ID: ${sourceModel.id}). ` + `A mapping exists for the target model, but the source model ID does not match — this likely indicates ` + - `a rename or reassignment on the source. Stopping sync to avoid a partial push; review the model mappings and re-run.`, + `a rename or reassignment on the source. Stopping sync to avoid a partial push; review the model mappings and re-run.` ); } } @@ -168,7 +168,7 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp referenceMapper, apiClient, targetGuid[0], - logger, + logger ); if (result) { successful++; @@ -204,7 +204,7 @@ const createNewModel = async ( referenceMapper: ModelMapper, apiClient: mgmtApi.ApiClient, targetGuid: string, - logger: Logs, + logger: Logs ): Promise<"created" | "updated" | "skipped" | "failed"> => { try { // process the model without fields @@ -233,7 +233,7 @@ async function updateExistingModel( referenceMapper: ModelMapper, apiClient: mgmtApi.ApiClient, targetGuid: string, - logger: Logs, + logger: Logs ): Promise<"updated" | "failed"> { try { const updatePayload = { diff --git a/src/lib/pushers/orchestrate-pushers.ts b/src/lib/pushers/orchestrate-pushers.ts index dc7c131..1aa7ae8 100644 --- a/src/lib/pushers/orchestrate-pushers.ts +++ b/src/lib/pushers/orchestrate-pushers.ts @@ -150,7 +150,7 @@ export class Pushers { */ private async executePushersInOrder( sourceGuid: string, - targetGuid: string, + targetGuid: string ): Promise<{ totalSuccess: number; totalFailures: number; @@ -197,8 +197,8 @@ export class Pushers { } // Reset logging flags for new operation - const { GuidDataLoader } = await import('./guid-data-loader'); - const { ModelDependencyTreeBuilder } = await import('../models/model-dependency-tree-builder'); + const { GuidDataLoader } = await import("./guid-data-loader"); + const { ModelDependencyTreeBuilder } = await import("../models/model-dependency-tree-builder"); GuidDataLoader.resetLoggingFlags(); ModelDependencyTreeBuilder.resetLoggingFlags(); @@ -206,12 +206,12 @@ export class Pushers { const sourceDataLoader = new GuidDataLoader(sourceGuid); const targetDataLoader = new GuidDataLoader(targetGuid); - // Do guid level ops first + // Do guid level ops first // TODO: use locale[0] as a temp locale THIS NEEDS TO BE REFACTORED try { const sourceData = await sourceDataLoader.loadGuidEntities( locales[0], - Object.keys(filterOptions).length > 0 ? filterOptions : undefined, + Object.keys(filterOptions).length > 0 ? filterOptions : undefined ); const targetData = await targetDataLoader.loadGuidEntities(locales[0]); @@ -237,7 +237,7 @@ export class Pushers { } } catch (error: any) { // Re-throw validation errors immediately to stop sync - if (error?.message?.includes('Model validation failed')) { + if (error?.message?.includes("Model validation failed")) { throw error; } // For other errors, log but don't stop (legacy behavior for guid-level ops) @@ -252,7 +252,7 @@ export class Pushers { for (const locale of locales) { const sourceData = await sourceDataLoader.loadGuidEntities( locale, - Object.keys(filterOptions).length > 0 ? filterOptions : undefined, + Object.keys(filterOptions).length > 0 ? filterOptions : undefined ); const targetData = await targetDataLoader.loadGuidEntities(locale); @@ -331,7 +331,9 @@ export class Pushers { (Array.isArray(elementData) && elementData.length === 0) || !elements.some((element) => config.elements.includes(element)) ) { - console.log(ansiColors.yellow(`⚠️ Skipping ${config.description} for locale ${locale} - no data or filtered by --locales`)); + console.log( + ansiColors.yellow(`⚠️ Skipping ${config.description} for locale ${locale} - no data or filtered by --locales`) + ); return { success: 0, failures: 0, skipped: 0, failureDetails: [] }; } @@ -357,14 +359,14 @@ export class Pushers { ansiColors.gray(`\n${config.description}: `) + successfulColor(`${pusherResult.successful} successful, `) + skippedColor(`${pusherResult.skipped} skipped, `) + - failedColor(`${pusherResult.failed} failed\n`), + failedColor(`${pusherResult.failed} failed\n`) ); this.config.onOperationComplete?.( config.name, state.sourceGuid[0], state.targetGuid[0], - pusherResult.status === "success", + pusherResult.status === "success" ); // Return the counts so they can be accumulated by the caller diff --git a/src/lib/pushers/page-pusher/find-page-in-other-locale.ts b/src/lib/pushers/page-pusher/find-page-in-other-locale.ts index ccc2bb6..7fd0ea5 100644 --- a/src/lib/pushers/page-pusher/find-page-in-other-locale.ts +++ b/src/lib/pushers/page-pusher/find-page-in-other-locale.ts @@ -2,43 +2,44 @@ import { getApiClient, state } from "core/state"; import { PageMapper } from "lib/mappers/page-mapper"; interface Props { - sourceGuid: string; - targetGuid: string; - sourcePageID: number; - locale: string; + sourceGuid: string; + targetGuid: string; + sourcePageID: number; + locale: string; } export interface OtherLocaleMapping { - PageIDOtherLanguage: number; - OtherLanguageCode: string; + PageIDOtherLanguage: number; + OtherLanguageCode: string; } -export const findPageInOtherLocale = async ({ sourcePageID, locale, sourceGuid, targetGuid }: Props): Promise => { - const { availableLocales } = state - - //loop the other locales and check the mapping to see if this page has been mapped in another locale. - for (const otherLocale of availableLocales) { - if (locale === otherLocale) continue; // Skip current locale - - const pageMapper = new PageMapper(sourceGuid, targetGuid, otherLocale); - - try { - - const mapping = pageMapper.getPageMappingByPageID(sourcePageID, "source"); - if (mapping) { - // Return the target page ID and locale it was found in, if found - return { - PageIDOtherLanguage: mapping.targetPageID, - OtherLanguageCode: otherLocale - } - } - - } catch (error) { - console.error(`Error finding page in locale ${locale}:`, error); - } - } - - return null; // Return null if no mapping found in other locales - - -} \ No newline at end of file +export const findPageInOtherLocale = async ({ + sourcePageID, + locale, + sourceGuid, + targetGuid, +}: Props): Promise => { + const { availableLocales } = state; + + //loop the other locales and check the mapping to see if this page has been mapped in another locale. + for (const otherLocale of availableLocales) { + if (locale === otherLocale) continue; // Skip current locale + + const pageMapper = new PageMapper(sourceGuid, targetGuid, otherLocale); + + try { + const mapping = pageMapper.getPageMappingByPageID(sourcePageID, "source"); + if (mapping) { + // Return the target page ID and locale it was found in, if found + return { + PageIDOtherLanguage: mapping.targetPageID, + OtherLanguageCode: otherLocale, + }; + } + } catch (error) { + console.error(`Error finding page in locale ${locale}:`, error); + } + } + + return null; // Return null if no mapping found in other locales +}; diff --git a/src/lib/pushers/page-pusher/process-page.ts b/src/lib/pushers/page-pusher/process-page.ts index c486dbf..efc2098 100644 --- a/src/lib/pushers/page-pusher/process-page.ts +++ b/src/lib/pushers/page-pusher/process-page.ts @@ -2,7 +2,7 @@ import * as mgmtApi from "@agility/management-sdk"; import ansiColors from "ansi-colors"; import { PageMapper } from "../../mappers/page-mapper"; import { ContentItemMapper } from "lib/mappers/content-item-mapper"; -import { TemplateMapper } from "lib/mappers/template-mapper";// Internal helper function to process a single page +import { TemplateMapper } from "lib/mappers/template-mapper"; // Internal helper function to process a single page import { translateZoneNames } from "./translate-zone-names"; import { findPageInOtherLocale, OtherLocaleMapping } from "./find-page-in-other-locale"; import { Logs } from "core/logs"; @@ -10,497 +10,527 @@ import { state, getFailedContent, contentExistsInSourceData, contentExistsInOthe import { PageModuleExtended } from "types/sourceData"; interface Props { - channel: string, - page: mgmtApi.PageItem, - sourceGuid: string, - targetGuid: string, - locale: string, - apiClient: mgmtApi.ApiClient, - overwrite: boolean, - insertBeforePageId: number | null, - pageMapper: PageMapper, - parentPageID: number, - logger: Logs + channel: string; + page: mgmtApi.PageItem; + sourceGuid: string; + targetGuid: string; + locale: string; + apiClient: mgmtApi.ApiClient; + overwrite: boolean; + insertBeforePageId: number | null; + pageMapper: PageMapper; + parentPageID: number; + logger: Logs; } export type PageProcessResult = { status: "success" | "skip" | "failure"; error?: string; contentID?: number }; export async function processPage({ - channel, - page, - sourceGuid, - targetGuid, - locale, - apiClient, - overwrite = false, - insertBeforePageId = null, - pageMapper, - parentPageID, - logger + channel, + page, + sourceGuid, + targetGuid, + locale, + apiClient, + overwrite = false, + insertBeforePageId = null, + pageMapper, + parentPageID, + logger, }: Props): Promise { - // Returns object with status and optional error message - - let existingPage: mgmtApi.PageItem | null = null; - let channelID = -1; - - const templateMapper = new TemplateMapper(sourceGuid, targetGuid); - - try { - let targetTemplate: mgmtApi.PageModel | null = null; - // Only try to find template mapping for non-folder pages - if (page.pageType !== "folder" && page.templateName) { - // Find the template mapping - let templateRef = templateMapper.getTemplateMappingByPageTemplateName(page.templateName, 'source'); - if (!templateRef) { - logger.page.error(page, `Missing page template ${page.templateName} in source data, skipping`, locale, channel, targetGuid); - return { status: "skip" }; - } - targetTemplate = templateMapper.getMappedEntity(templateRef, 'target') as mgmtApi.PageModel; - } - - //get the existing page from the target instance - const pageMapping = pageMapper.getPageMapping(page, 'source'); - existingPage = pageMapper.getMappedEntity(pageMapping, 'target'); - let mappingToOtherLocale: OtherLocaleMapping | null = null; - - if (!existingPage) { - //check the other locales to see if this page has been mapped in another locale - mappingToOtherLocale = await findPageInOtherLocale({ - sourcePageID: page.pageID, - locale, - sourceGuid, - targetGuid - }); - - - } - - // Get channel ID from target instance sitemap (not from existing page which may be invalid) - const sitemap = await apiClient.pageMethods.getSitemap(targetGuid, locale); - - //TODO: this is NOT using the channel reference name properly since we don't get that from the mgmt api - //TODO: we need to add the channel reference name to the mgmt API for a proper lookup here.. - const websiteChannel = sitemap?.find((channelObj) => channelObj.name.toLowerCase() === channel.toLowerCase()); - if (websiteChannel) { - channelID = websiteChannel.digitalChannelID; - } else { - channelID = sitemap?.[0]?.digitalChannelID || 1; // Fallback to first channel or default - } - - const hasSourceChanged = pageMapper.hasSourceChanged(page); - const hasTargetChanged = pageMapper.hasTargetChanged(existingPage, pageMapping); - - // A conflict exists whenever the target has changed independently — regardless of whether - // the source also changed. Even if source is unchanged today, a future source push would - // silently overwrite the target's independent changes without this guard. - const isConflict = hasTargetChanged !== null; - const updateRequired = (hasSourceChanged && !isConflict) || (overwrite && isConflict); - const createRequired = !existingPage; - - const pageTypeDisplay = - { - static: "Page", - link: "Link", - folder: "Folder", - }[page.pageType] || page.pageType; - - if (isConflict && !overwrite) { - // CONFLICT: Target has independent changes. - // Use mapping's targetPageID as fallback in case existingPage wasn't loaded. - const targetPageID = existingPage?.pageID ?? pageMapping?.targetPageID; - const sourceUrl = `https://app.agilitycms.com/instance/${sourceGuid}/${locale}/pages/${page.pageID}`; - const targetUrl = `https://app.agilitycms.com/instance/${targetGuid}/${locale}/pages/${targetPageID}`; - - let reason: string; - if (hasTargetChanged === 'file_missing') { - reason = "target page may have been unpublished or deleted — cannot verify its current state"; - } else if (hasSourceChanged) { - reason = "changes detected in both source and target"; - } else { - reason = "target has been changed independently"; - } - - console.warn( - `⚠️ Conflict detected ${pageTypeDisplay} ${ansiColors.underline(page.name)} — ${ansiColors.bold.grey(reason)}. Use --overwrite to force.` - ); - console.warn(` - Source: ${sourceUrl}`); - console.warn(` - Target: ${targetUrl}`); - - if (!overwrite) { - return { status: "skip" }; // Prevent conflicting pages from being processed and auto-published - } - // overwrite mode: warn but continue processing - } else if (createRequired) { - //CREATE NEW PAGE - nothing to do here yet... - } else if (!updateRequired) { - if (existingPage) { - pageMapper.addMapping(page, existingPage); - } - - logger.page.skipped(page, "up to date, skipping", locale, channel, targetGuid); - return { status: "skip" }; // Skip processing - page already exists - } - - // Map Content IDs in Zones - // Handle folder pages which may not have zones - let sourceZones = page.zones ? { ...page.zones } : {}; // Clone zones or use empty object - - // CRITICAL: Translate zone names to match template expectations BEFORE content mapping - let mappedZones = translateZoneNames(sourceZones, targetTemplate) as { [key: string]: PageModuleExtended[] }; - - // Content mapping validation - collect all content IDs that need mapping - const contentIdsToValidate: number[] = []; - for (const [zoneName, zoneModules] of Object.entries(mappedZones)) { - if (Array.isArray(zoneModules)) { - for (const module of zoneModules) { - if (module.item && typeof module.item === "object") { - const sourceContentId = module.item.contentid || module.item.contentId; - if (sourceContentId && sourceContentId > 0) { - contentIdsToValidate.push(sourceContentId); - } - } - } - } - } - - // Content mapping validation (silent unless errors) - - const contentMapper = new ContentItemMapper(sourceGuid, targetGuid, locale); - // Track first missing content mapping for error summary - let firstMissingContentError: string | null = null; - let firstMissingContentID: number | null = null; - - for (const [zoneName, zoneModules] of Object.entries(mappedZones)) { - const newZoneContent = []; - if (Array.isArray(zoneModules)) { - for (const module of zoneModules) { - // Create copy of module to avoid modifying original - const newModule = { ...module }; - - // Check if module has content item reference - if (module.item && typeof module.item === "object") { - // CRITICAL FIX: Check both contentid (lowercase) and contentId (camelCase) - // The page data contains "contentid" (lowercase) but code was checking "contentId" - const sourceContentId = module.item.contentid || module.item.contentId; - - if (sourceContentId && sourceContentId > 0) { - const contentMapping = contentMapper.getContentItemMappingByContentID(sourceContentId, 'source'); - const targetContentID = contentMapping?.targetContentID; - if (targetContentID) { - // CRITICAL FIX: Map to target content ID and remove duplicate fields - const targetContentId = targetContentID; - newModule.item = { - ...module.item, - contentid: targetContentId, // Use target content ID only - fulllist: module.item.fulllist, - }; - // Remove contentId field to avoid confusion - delete newModule.item.contentId; - newZoneContent.push(newModule); - } else { - // Content mapping failed - check why (in priority order) - const failedContent = getFailedContent(sourceContentId); - const isPageUnpublished = page.properties?.state !== 2; - const existsInSource = contentExistsInSourceData(sourceGuid, locale, sourceContentId); - const otherLocale = !existsInSource ? contentExistsInOtherLocale(sourceGuid, locale, sourceContentId) : null; - let mappingError: string; - - if (failedContent) { - // Content failed earlier - show the upstream error - mappingError = `No content mapping for ${module.module} (contentID ${sourceContentId}) - content '${failedContent.referenceName}' failed earlier: ${failedContent.error}`; - } else if (isPageUnpublished) { - // Page is unpublished - explains why content isn't in source (it wasn't pulled) - mappingError = `No content mapping for ${module.module} (contentID ${sourceContentId}) - page is unpublished and referenced content may not have been pulled`; - } else if (otherLocale) { - // Content exists in another locale but not this one - not initialized for this locale - mappingError = `No content mapping for ${module.module} (contentID ${sourceContentId}) - content not initialized in ${locale} (exists in ${otherLocale})`; - } else if (!existsInSource) { - // Published page but content file doesn't exist anywhere - needs re-pull - mappingError = `No content mapping for ${module.module} (contentID ${sourceContentId}) - content item not found in source data (may need to re-pull)`; - } else { - // Content exists but wasn't synced - may be model issue - mappingError = `No content mapping for ${module.module} (contentID ${sourceContentId}) - content has never been synced or model may have changed`; - } - - // Don't log individual errors inline - they'll appear in the final summary - // Capture first error and contentID for summary - if (!firstMissingContentError) { - firstMissingContentError = mappingError; - firstMissingContentID = sourceContentId; - } - } - } else { - // Module without content reference - keep it - newZoneContent.push(newModule); - } - } else { - // Module without content reference - keep it - newZoneContent.push(newModule); - } - } - } - mappedZones[zoneName] = newZoneContent; - } - - // Content mapping validation - check which mappings were successful - if (contentIdsToValidate.length > 0) { - const mappingResults: { [contentId: number]: { found: boolean; targetId?: number; error?: string } } = {}; - let foundMappings = 0; - let missingMappings = 0; - - contentIdsToValidate.forEach((sourceContentId) => { - const contentMapping = contentMapper.getContentItemMappingByContentID(sourceContentId, 'source'); - const targetContentID = contentMapping?.targetContentID; - if (targetContentID) { - mappingResults[sourceContentId] = { - found: true, - targetId: targetContentID, - }; - foundMappings++; - } else { - mappingResults[sourceContentId] = { - found: false, - error: targetContentID ? "Invalid target ID" : "No mapping found", - }; - missingMappings++; - } - }); - - if (missingMappings > 0) { - console.error( - ansiColors.bgRed( - `✗ Page "${page.name}" failed - ${missingMappings}/${contentIdsToValidate.length} missing content mappings` - ) - ); - return { - status: "failure", - error: firstMissingContentError || `${missingMappings} missing content mappings`, - contentID: firstMissingContentID || undefined - }; - } - } - - // Check if page has any content left after filtering - const totalModules = Object.values(mappedZones).reduce((sum: number, zone) => { - return sum + (Array.isArray(zone) ? zone.length : 0); - }, 0); - - // Helper function to check if a page legitimately can have no modules - const isLegitimateEmptyPage = (page: mgmtApi.PageItem): boolean => { - // Folder pages don't have content modules - if (page.pageType === "folder") return true; - - // Link pages don't have content modules - they redirect to other URLs/pages/files - if (page.pageType === "link") return true; - - // Dynamic pages don't have modules in zones - their content comes from dynamic containers - // Check for dynamic page indicators - const pageAny = page as any; - if (pageAny.dynamic && pageAny.dynamic.referenceName) return true; - if (pageAny.dynamicPageContentViewReferenceName) return true; - - // Pages with redirect URLs are link pages (even if pageType isn't explicitly 'link') - // Check for common redirect URL properties (using 'any' type to access properties safely) - if (pageAny.redirectUrl && pageAny.redirectUrl.trim()) return true; - if (pageAny.redirect && pageAny.redirect.url && pageAny.redirect.url.trim()) return true; - - // Pages that link to files or other pages don't need modules - // Using safe property access since these may not be in the type definition - if (pageAny.linkToFileID && pageAny.linkToFileID > 0) return true; - if (pageAny.linkToPageID && pageAny.linkToPageID > 0) return true; - if (pageAny.linkToFile && pageAny.linkToFile > 0) return true; - if (pageAny.linkToPage && pageAny.linkToPage > 0) return true; - - return false; - }; - - // Check if page has any content left after filtering - if (totalModules === 0) { - // Many pages legitimately have no modules (folder pages, link pages, etc.) - // Only fail if this was a content page that had modules but lost them all during mapping - const originalZones = page.zones || {}; - let originalModuleCount = 0; - - for (const [zoneName, zoneModules] of Object.entries(originalZones)) { - if (Array.isArray(zoneModules)) { - originalModuleCount += zoneModules.length; - } - } - - // If the page originally had modules but now has none, that's a problem - // If it never had modules, that's fine (folder pages, etc.) - if (originalModuleCount > 0 && !existingPage && !isLegitimateEmptyPage(page)) { - const lostModulesError = `Lost all ${originalModuleCount} modules during content mapping`; - console.error(`✗ Page "${page.name}" ${lostModulesError}`); - return { status: "failure", error: lostModulesError }; - } - } - - // Prepare payload - ensure proper null handling - // Fix zones format - ensure zones is always a defined object (never null/undefined) - const formattedZones = mappedZones && typeof mappedZones === "object" ? mappedZones : {}; - - // CRITICAL FIX: Ensure every page has a valid title field - // Folder pages often don't have titles, but API requires them - const pageTitle = page.title || page.menuText || page.name || "Untitled Page"; - - const pageJSON = JSON.stringify(page, null, 2); - const pageCopy = JSON.parse(pageJSON) as mgmtApi.PageItem; // Create a copy to avoid modifying original - - const payload: any = { - ...pageCopy, - // If local target file is missing but mapping exists, use the known target container ID - // to force an UPDATE instead of INSERT (prevents duplicate name server errors) - pageID: existingPage ? existingPage.pageID : (pageMapping?.targetPageID ?? -1), - title: pageTitle, // CRITICAL: Ensure title is always present - channelID: channelID, // CRITICAL: Always use target instance channel ID to avoid FK constraint errors - zones: formattedZones, - // CRITICAL: Include path field from sitemap enrichment (API bug: target sitemap returns null paths) - path: page.path || "", - }; - - - - let parentIDArg = -1; - - if (parentPageID && parentPageID > 0) { - const mapping = pageMapper.getPageMappingByPageID(parentPageID, 'source'); - - if ((mapping?.targetPageID || 0) > 0) { - parentIDArg = mapping.targetPageID; - payload.parentPageID = mapping.targetPageID; - } else { - parentIDArg = -1; - payload.parentPageID = -1; // No parent - } - } else { - payload.parentPageID = -1; // Ensure no parent - } - - let placeBeforeIDArg = -1; - // Only set placeBeforeIDArg for NEW pages, not updates - // For updates, we preserve the existing position unless explicitly moving - if (!existingPage && insertBeforePageId && insertBeforePageId > 0) { - //map the insertBeforePageId to the correct target page ID - const mapping = pageMapper.getPageMappingByPageID(insertBeforePageId, 'source'); - if ((mapping?.targetPageID || 0) > 0) { - placeBeforeIDArg = mapping.targetPageID; - } - } - - const pageIDInOtherLocale = mappingToOtherLocale ? mappingToOtherLocale.PageIDOtherLanguage : -1; - const otherLocale = mappingToOtherLocale ? mappingToOtherLocale.OtherLanguageCode : null; - - - // Save the page with returnBatchID flag for consistent batch processing - const savePageResponse = await apiClient.pageMethods.savePage( - payload, - targetGuid, - locale, - parentIDArg, - placeBeforeIDArg, - true, - pageIDInOtherLocale, - otherLocale, - true - ); - - // Process the response - with returnBatchID=true, we should always get a batch ID - if (Array.isArray(savePageResponse) && savePageResponse.length > 0) { - // Final content mapping summary for debugging - const finalContentIds: number[] = []; - Object.values(payload.zones || {}).forEach((zone: any) => { - if (Array.isArray(zone)) { - zone.forEach((module: any) => { - if (module.item?.contentid) { - finalContentIds.push(module.item.contentid); - } - }); - } - }); - - // Final payload prepared (silent) - - // Extract batch ID from response - const batchID = savePageResponse[0]; - // Page batch processing started (silent) - - // Poll batch until completion using consistent utility (pass payload for error matching) - const { pollBatchUntilComplete, extractPageBatchResults } = await import("../batch-polling"); - const completedBatch = await pollBatchUntilComplete( - apiClient, - batchID, - targetGuid, - [payload], // Pass payload for FIFO error matching - 300, // maxAttempts - 2000, // intervalMs - "Page" // batchType - ); - - // Extract result from completed batch - const { successfulItems: batchSuccessItems, failedItems: batchFailedItems } = extractPageBatchResults( - completedBatch, - [page] - ); - - let actualPageID = -1; - let savedPageVersionID = -1; - if (batchSuccessItems.length > 0) { - //grab the save page info form the batch success items - actualPageID = batchSuccessItems[0].newId; - savedPageVersionID = batchSuccessItems[0].newItem?.processedItemVersionID || -1; - } else if (batchFailedItems.length > 0) { - logger.page.error(page, `✗ Page ${page.name} batch failed: ${batchFailedItems[0].error}`, locale, channel, targetGuid); - } - - if (actualPageID > 0) { - // Success case - const createdPageData = { - ...payload, // Use the payload data which has mapped zones - pageID: actualPageID, - - } as mgmtApi.PageItem; - - if (savedPageVersionID > 0) { - // Set version ID if available - createdPageData.properties.versionID = savedPageVersionID; // Set version ID from batch result - } - - pageMapper.addMapping(page, createdPageData); // Use original page for source key - - const pageTypeDisplay = - { - static: "Page", - link: "Link", - folder: "Folder", - }[page.pageType] || page.pageType; - - if (existingPage || pageMapping) { - logger.page.updated(page, "updated", locale, channel, targetGuid); - } else { - logger.page.created(page, "created", locale, channel, targetGuid); - } - return { status: "success" }; // Success - } else { - let errorMsg: string; - if (batchFailedItems.length > 0 && batchFailedItems[0].error) { - errorMsg = batchFailedItems[0].error; - } else if (completedBatch.errorData && typeof completedBatch.errorData === 'string' && !completedBatch.errorData.startsWith('{')) { - // Use errorData only if it's a simple string (not JSON) - errorMsg = completedBatch.errorData.trim(); - } else { - errorMsg = `Invalid page ID: ${actualPageID}`; - } - logger.page.error(page, `✗ Page "${page.name}" failed - ${errorMsg}, locale:${locale}`, locale, channel, targetGuid); - return { status: "failure", error: errorMsg }; - } - } else { - const errorMsg = "Unexpected response format"; - logger.page.error(page, `✗ Page "${page.name}" failed in locale:${locale} - ${errorMsg}`, locale, channel, targetGuid); - return { status: "failure", error: errorMsg }; - } - } catch (error: any) { - logger.page.error(page, `✗ Page "${page.name}" failed in locale:${locale} - ${error.message}`, locale, channel, targetGuid); - return { status: "failure", error: error.message }; - } + // Returns object with status and optional error message + + let existingPage: mgmtApi.PageItem | null = null; + let channelID = -1; + + const templateMapper = new TemplateMapper(sourceGuid, targetGuid); + + try { + let targetTemplate: mgmtApi.PageModel | null = null; + // Only try to find template mapping for non-folder pages + if (page.pageType !== "folder" && page.templateName) { + // Find the template mapping + let templateRef = templateMapper.getTemplateMappingByPageTemplateName(page.templateName, "source"); + if (!templateRef) { + logger.page.error( + page, + `Missing page template ${page.templateName} in source data, skipping`, + locale, + channel, + targetGuid + ); + return { status: "skip" }; + } + targetTemplate = templateMapper.getMappedEntity(templateRef, "target") as mgmtApi.PageModel; + } + + //get the existing page from the target instance + const pageMapping = pageMapper.getPageMapping(page, "source"); + existingPage = pageMapper.getMappedEntity(pageMapping, "target"); + let mappingToOtherLocale: OtherLocaleMapping | null = null; + + if (!existingPage) { + //check the other locales to see if this page has been mapped in another locale + mappingToOtherLocale = await findPageInOtherLocale({ + sourcePageID: page.pageID, + locale, + sourceGuid, + targetGuid, + }); + } + + // Get channel ID from target instance sitemap (not from existing page which may be invalid) + const sitemap = await apiClient.pageMethods.getSitemap(targetGuid, locale); + + //TODO: this is NOT using the channel reference name properly since we don't get that from the mgmt api + //TODO: we need to add the channel reference name to the mgmt API for a proper lookup here.. + const websiteChannel = sitemap?.find((channelObj) => channelObj.name.toLowerCase() === channel.toLowerCase()); + if (websiteChannel) { + channelID = websiteChannel.digitalChannelID; + } else { + channelID = sitemap?.[0]?.digitalChannelID || 1; // Fallback to first channel or default + } + + const hasSourceChanged = pageMapper.hasSourceChanged(page); + const hasTargetChanged = pageMapper.hasTargetChanged(existingPage, pageMapping); + + // A conflict exists whenever the target has changed independently — regardless of whether + // the source also changed. Even if source is unchanged today, a future source push would + // silently overwrite the target's independent changes without this guard. + const isConflict = hasTargetChanged !== null; + const updateRequired = (hasSourceChanged && !isConflict) || (overwrite && isConflict); + const createRequired = !existingPage; + + const pageTypeDisplay = + { + static: "Page", + link: "Link", + folder: "Folder", + }[page.pageType] || page.pageType; + + if (isConflict && !overwrite) { + // CONFLICT: Target has independent changes. + // Use mapping's targetPageID as fallback in case existingPage wasn't loaded. + const targetPageID = existingPage?.pageID ?? pageMapping?.targetPageID; + const sourceUrl = `https://app.agilitycms.com/instance/${sourceGuid}/${locale}/pages/${page.pageID}`; + const targetUrl = `https://app.agilitycms.com/instance/${targetGuid}/${locale}/pages/${targetPageID}`; + + let reason: string; + if (hasTargetChanged === "file_missing") { + reason = "target page may have been unpublished or deleted — cannot verify its current state"; + } else if (hasSourceChanged) { + reason = "changes detected in both source and target"; + } else { + reason = "target has been changed independently"; + } + + console.warn( + `⚠️ Conflict detected ${pageTypeDisplay} ${ansiColors.underline(page.name)} — ${ansiColors.bold.grey(reason)}. Use --overwrite to force.` + ); + console.warn(` - Source: ${sourceUrl}`); + console.warn(` - Target: ${targetUrl}`); + + if (!overwrite) { + return { status: "skip" }; // Prevent conflicting pages from being processed and auto-published + } + // overwrite mode: warn but continue processing + } else if (createRequired) { + //CREATE NEW PAGE - nothing to do here yet... + } else if (!updateRequired) { + if (existingPage) { + pageMapper.addMapping(page, existingPage); + } + + logger.page.skipped(page, "up to date, skipping", locale, channel, targetGuid); + return { status: "skip" }; // Skip processing - page already exists + } + + // Map Content IDs in Zones + // Handle folder pages which may not have zones + let sourceZones = page.zones ? { ...page.zones } : {}; // Clone zones or use empty object + + // CRITICAL: Translate zone names to match template expectations BEFORE content mapping + let mappedZones = translateZoneNames(sourceZones, targetTemplate) as { [key: string]: PageModuleExtended[] }; + + // Content mapping validation - collect all content IDs that need mapping + const contentIdsToValidate: number[] = []; + for (const [zoneName, zoneModules] of Object.entries(mappedZones)) { + if (Array.isArray(zoneModules)) { + for (const module of zoneModules) { + if (module.item && typeof module.item === "object") { + const sourceContentId = module.item.contentid || module.item.contentId; + if (sourceContentId && sourceContentId > 0) { + contentIdsToValidate.push(sourceContentId); + } + } + } + } + } + + // Content mapping validation (silent unless errors) + + const contentMapper = new ContentItemMapper(sourceGuid, targetGuid, locale); + // Track first missing content mapping for error summary + let firstMissingContentError: string | null = null; + let firstMissingContentID: number | null = null; + + for (const [zoneName, zoneModules] of Object.entries(mappedZones)) { + const newZoneContent = []; + if (Array.isArray(zoneModules)) { + for (const module of zoneModules) { + // Create copy of module to avoid modifying original + const newModule = { ...module }; + + // Check if module has content item reference + if (module.item && typeof module.item === "object") { + // CRITICAL FIX: Check both contentid (lowercase) and contentId (camelCase) + // The page data contains "contentid" (lowercase) but code was checking "contentId" + const sourceContentId = module.item.contentid || module.item.contentId; + + if (sourceContentId && sourceContentId > 0) { + const contentMapping = contentMapper.getContentItemMappingByContentID(sourceContentId, "source"); + const targetContentID = contentMapping?.targetContentID; + if (targetContentID) { + // CRITICAL FIX: Map to target content ID and remove duplicate fields + const targetContentId = targetContentID; + newModule.item = { + ...module.item, + contentid: targetContentId, // Use target content ID only + fulllist: module.item.fulllist, + }; + // Remove contentId field to avoid confusion + delete newModule.item.contentId; + newZoneContent.push(newModule); + } else { + // Content mapping failed - check why (in priority order) + const failedContent = getFailedContent(sourceContentId); + const isPageUnpublished = page.properties?.state !== 2; + const existsInSource = contentExistsInSourceData(sourceGuid, locale, sourceContentId); + const otherLocale = !existsInSource + ? contentExistsInOtherLocale(sourceGuid, locale, sourceContentId) + : null; + let mappingError: string; + + if (failedContent) { + // Content failed earlier - show the upstream error + mappingError = `No content mapping for ${module.module} (contentID ${sourceContentId}) - content '${failedContent.referenceName}' failed earlier: ${failedContent.error}`; + } else if (isPageUnpublished) { + // Page is unpublished - explains why content isn't in source (it wasn't pulled) + mappingError = `No content mapping for ${module.module} (contentID ${sourceContentId}) - page is unpublished and referenced content may not have been pulled`; + } else if (otherLocale) { + // Content exists in another locale but not this one - not initialized for this locale + mappingError = `No content mapping for ${module.module} (contentID ${sourceContentId}) - content not initialized in ${locale} (exists in ${otherLocale})`; + } else if (!existsInSource) { + // Published page but content file doesn't exist anywhere - needs re-pull + mappingError = `No content mapping for ${module.module} (contentID ${sourceContentId}) - content item not found in source data (may need to re-pull)`; + } else { + // Content exists but wasn't synced - may be model issue + mappingError = `No content mapping for ${module.module} (contentID ${sourceContentId}) - content has never been synced or model may have changed`; + } + + // Don't log individual errors inline - they'll appear in the final summary + // Capture first error and contentID for summary + if (!firstMissingContentError) { + firstMissingContentError = mappingError; + firstMissingContentID = sourceContentId; + } + } + } else { + // Module without content reference - keep it + newZoneContent.push(newModule); + } + } else { + // Module without content reference - keep it + newZoneContent.push(newModule); + } + } + } + mappedZones[zoneName] = newZoneContent; + } + + // Content mapping validation - check which mappings were successful + if (contentIdsToValidate.length > 0) { + const mappingResults: { [contentId: number]: { found: boolean; targetId?: number; error?: string } } = {}; + let foundMappings = 0; + let missingMappings = 0; + + contentIdsToValidate.forEach((sourceContentId) => { + const contentMapping = contentMapper.getContentItemMappingByContentID(sourceContentId, "source"); + const targetContentID = contentMapping?.targetContentID; + if (targetContentID) { + mappingResults[sourceContentId] = { + found: true, + targetId: targetContentID, + }; + foundMappings++; + } else { + mappingResults[sourceContentId] = { + found: false, + error: targetContentID ? "Invalid target ID" : "No mapping found", + }; + missingMappings++; + } + }); + + if (missingMappings > 0) { + console.error( + ansiColors.bgRed( + `✗ Page "${page.name}" failed - ${missingMappings}/${contentIdsToValidate.length} missing content mappings` + ) + ); + return { + status: "failure", + error: firstMissingContentError || `${missingMappings} missing content mappings`, + contentID: firstMissingContentID || undefined, + }; + } + } + + // Check if page has any content left after filtering + const totalModules = Object.values(mappedZones).reduce((sum: number, zone) => { + return sum + (Array.isArray(zone) ? zone.length : 0); + }, 0); + + // Helper function to check if a page legitimately can have no modules + const isLegitimateEmptyPage = (page: mgmtApi.PageItem): boolean => { + // Folder pages don't have content modules + if (page.pageType === "folder") return true; + + // Link pages don't have content modules - they redirect to other URLs/pages/files + if (page.pageType === "link") return true; + + // Dynamic pages don't have modules in zones - their content comes from dynamic containers + // Check for dynamic page indicators + const pageAny = page as any; + if (pageAny.dynamic && pageAny.dynamic.referenceName) return true; + if (pageAny.dynamicPageContentViewReferenceName) return true; + + // Pages with redirect URLs are link pages (even if pageType isn't explicitly 'link') + // Check for common redirect URL properties (using 'any' type to access properties safely) + if (pageAny.redirectUrl && pageAny.redirectUrl.trim()) return true; + if (pageAny.redirect && pageAny.redirect.url && pageAny.redirect.url.trim()) return true; + + // Pages that link to files or other pages don't need modules + // Using safe property access since these may not be in the type definition + if (pageAny.linkToFileID && pageAny.linkToFileID > 0) return true; + if (pageAny.linkToPageID && pageAny.linkToPageID > 0) return true; + if (pageAny.linkToFile && pageAny.linkToFile > 0) return true; + if (pageAny.linkToPage && pageAny.linkToPage > 0) return true; + + return false; + }; + + // Check if page has any content left after filtering + if (totalModules === 0) { + // Many pages legitimately have no modules (folder pages, link pages, etc.) + // Only fail if this was a content page that had modules but lost them all during mapping + const originalZones = page.zones || {}; + let originalModuleCount = 0; + + for (const [zoneName, zoneModules] of Object.entries(originalZones)) { + if (Array.isArray(zoneModules)) { + originalModuleCount += zoneModules.length; + } + } + + // If the page originally had modules but now has none, that's a problem + // If it never had modules, that's fine (folder pages, etc.) + if (originalModuleCount > 0 && !existingPage && !isLegitimateEmptyPage(page)) { + const lostModulesError = `Lost all ${originalModuleCount} modules during content mapping`; + console.error(`✗ Page "${page.name}" ${lostModulesError}`); + return { status: "failure", error: lostModulesError }; + } + } + + // Prepare payload - ensure proper null handling + // Fix zones format - ensure zones is always a defined object (never null/undefined) + const formattedZones = mappedZones && typeof mappedZones === "object" ? mappedZones : {}; + + // CRITICAL FIX: Ensure every page has a valid title field + // Folder pages often don't have titles, but API requires them + const pageTitle = page.title || page.menuText || page.name || "Untitled Page"; + + const pageJSON = JSON.stringify(page, null, 2); + const pageCopy = JSON.parse(pageJSON) as mgmtApi.PageItem; // Create a copy to avoid modifying original + + const payload: any = { + ...pageCopy, + // If local target file is missing but mapping exists, use the known target container ID + // to force an UPDATE instead of INSERT (prevents duplicate name server errors) + pageID: existingPage ? existingPage.pageID : (pageMapping?.targetPageID ?? -1), + title: pageTitle, // CRITICAL: Ensure title is always present + channelID: channelID, // CRITICAL: Always use target instance channel ID to avoid FK constraint errors + zones: formattedZones, + // CRITICAL: Include path field from sitemap enrichment (API bug: target sitemap returns null paths) + path: page.path || "", + }; + + let parentIDArg = -1; + + if (parentPageID && parentPageID > 0) { + const mapping = pageMapper.getPageMappingByPageID(parentPageID, "source"); + + if ((mapping?.targetPageID || 0) > 0) { + parentIDArg = mapping.targetPageID; + payload.parentPageID = mapping.targetPageID; + } else { + parentIDArg = -1; + payload.parentPageID = -1; // No parent + } + } else { + payload.parentPageID = -1; // Ensure no parent + } + + let placeBeforeIDArg = -1; + // Only set placeBeforeIDArg for NEW pages, not updates + // For updates, we preserve the existing position unless explicitly moving + if (!existingPage && insertBeforePageId && insertBeforePageId > 0) { + //map the insertBeforePageId to the correct target page ID + const mapping = pageMapper.getPageMappingByPageID(insertBeforePageId, "source"); + if ((mapping?.targetPageID || 0) > 0) { + placeBeforeIDArg = mapping.targetPageID; + } + } + + const pageIDInOtherLocale = mappingToOtherLocale ? mappingToOtherLocale.PageIDOtherLanguage : -1; + const otherLocale = mappingToOtherLocale ? mappingToOtherLocale.OtherLanguageCode : null; + + // Save the page with returnBatchID flag for consistent batch processing + const savePageResponse = await apiClient.pageMethods.savePage( + payload, + targetGuid, + locale, + parentIDArg, + placeBeforeIDArg, + true, + pageIDInOtherLocale, + otherLocale, + true + ); + + // Process the response - with returnBatchID=true, we should always get a batch ID + if (Array.isArray(savePageResponse) && savePageResponse.length > 0) { + // Final content mapping summary for debugging + const finalContentIds: number[] = []; + Object.values(payload.zones || {}).forEach((zone: any) => { + if (Array.isArray(zone)) { + zone.forEach((module: any) => { + if (module.item?.contentid) { + finalContentIds.push(module.item.contentid); + } + }); + } + }); + + // Final payload prepared (silent) + + // Extract batch ID from response + const batchID = savePageResponse[0]; + // Page batch processing started (silent) + + // Poll batch until completion using consistent utility (pass payload for error matching) + const { pollBatchUntilComplete, extractPageBatchResults } = await import("../batch-polling"); + const completedBatch = await pollBatchUntilComplete( + apiClient, + batchID, + targetGuid, + [payload], // Pass payload for FIFO error matching + 300, // maxAttempts + 2000, // intervalMs + "Page" // batchType + ); + + // Extract result from completed batch + const { successfulItems: batchSuccessItems, failedItems: batchFailedItems } = extractPageBatchResults( + completedBatch, + [page] + ); + + let actualPageID = -1; + let savedPageVersionID = -1; + if (batchSuccessItems.length > 0) { + //grab the save page info form the batch success items + actualPageID = batchSuccessItems[0].newId; + savedPageVersionID = batchSuccessItems[0].newItem?.processedItemVersionID || -1; + } else if (batchFailedItems.length > 0) { + logger.page.error( + page, + `✗ Page ${page.name} batch failed: ${batchFailedItems[0].error}`, + locale, + channel, + targetGuid + ); + } + + if (actualPageID > 0) { + // Success case + const createdPageData = { + ...payload, // Use the payload data which has mapped zones + pageID: actualPageID, + } as mgmtApi.PageItem; + + if (savedPageVersionID > 0) { + // Set version ID if available + createdPageData.properties.versionID = savedPageVersionID; // Set version ID from batch result + } + + pageMapper.addMapping(page, createdPageData); // Use original page for source key + + const pageTypeDisplay = + { + static: "Page", + link: "Link", + folder: "Folder", + }[page.pageType] || page.pageType; + + if (existingPage || pageMapping) { + logger.page.updated(page, "updated", locale, channel, targetGuid); + } else { + logger.page.created(page, "created", locale, channel, targetGuid); + } + return { status: "success" }; // Success + } else { + let errorMsg: string; + if (batchFailedItems.length > 0 && batchFailedItems[0].error) { + errorMsg = batchFailedItems[0].error; + } else if ( + completedBatch.errorData && + typeof completedBatch.errorData === "string" && + !completedBatch.errorData.startsWith("{") + ) { + // Use errorData only if it's a simple string (not JSON) + errorMsg = completedBatch.errorData.trim(); + } else { + errorMsg = `Invalid page ID: ${actualPageID}`; + } + logger.page.error( + page, + `✗ Page "${page.name}" failed - ${errorMsg}, locale:${locale}`, + locale, + channel, + targetGuid + ); + return { status: "failure", error: errorMsg }; + } + } else { + const errorMsg = "Unexpected response format"; + logger.page.error( + page, + `✗ Page "${page.name}" failed in locale:${locale} - ${errorMsg}`, + locale, + channel, + targetGuid + ); + return { status: "failure", error: errorMsg }; + } + } catch (error: any) { + logger.page.error( + page, + `✗ Page "${page.name}" failed in locale:${locale} - ${error.message}`, + locale, + channel, + targetGuid + ); + return { status: "failure", error: error.message }; + } } diff --git a/src/lib/pushers/page-pusher/process-sitemap.ts b/src/lib/pushers/page-pusher/process-sitemap.ts index c5be102..5ce0815 100644 --- a/src/lib/pushers/page-pusher/process-sitemap.ts +++ b/src/lib/pushers/page-pusher/process-sitemap.ts @@ -138,8 +138,8 @@ export async function processSitemap({ } else if (state.autoPublish) { console.log( ansiColors.gray( - ` 📋 Skipping auto-publish for page "${sourcePage.name}" (state: ${sourcePage.properties?.state} - not published in source)`, - ), + ` 📋 Skipping auto-publish for page "${sourcePage.name}" (state: ${sourcePage.properties?.state} - not published in source)` + ) ); } } else if (pageRes.status === "skip") { diff --git a/src/lib/pushers/page-pusher/push-pages.ts b/src/lib/pushers/page-pusher/push-pages.ts index b6cd47b..1a74509 100644 --- a/src/lib/pushers/page-pusher/push-pages.ts +++ b/src/lib/pushers/page-pusher/push-pages.ts @@ -6,99 +6,110 @@ import { PageMapper } from "lib/mappers/page-mapper"; import { processSitemap, resetProcessedPageIDs } from "./process-sitemap"; import ansiColors from "ansi-colors"; -export async function pushPages( - sourceData: mgmtApi.PageItem[], - locale: string -): Promise { - // Extract data from sourceData - unified parameter pattern - let pages: mgmtApi.PageItem[] = sourceData || []; - - const { sourceGuid, targetGuid } = state; - const logger = getLoggerForGuid(sourceGuid[0]); - const pageMapper = new PageMapper(sourceGuid[0], targetGuid[0], locale); - - if (!pages || pages.length === 0) { - console.log("No pages found to process."); - return { status: "success", successful: 0, failed: 0, skipped: 0, failureDetails: [] }; - } - - const sitemapHierarchy = new SitemapHierarchy(); - - // Reset processed page IDs tracking for this locale - resetProcessedPageIDs(); - - const sitemaps = sitemapHierarchy.loadAllSitemaps(sourceGuid[0], locale); - const channels = Object.keys(sitemaps); - - console.log(`Processing ${pages.length} pages across ${channels.length} channels in ${locale}...`); - - let successful = 0; - let failed = 0; - let skipped = 0; // No duplicates to skip since API prevents true duplicates at same hierarchy level - let status: "success" | "error" = "success"; - let publishableIds: number[] = []; // Track target page IDs for workflow operations - let failureDetails: Array<{ name: string; error: string; type?: 'content' | 'page'; pageID?: number; contentID?: number; guid?: string; locale?: string }> = []; - - - //loop all the channels - for (const channel of channels) { - const sitemap = sitemaps[channel]; - - // Skip if sitemap is null or empty - if (!sitemap || sitemap.length === 0) { - console.log(ansiColors.yellow(`⚠️ Skipping channel ${channel} - no sitemap data found for locale ${locale}`)); - continue; - } - - const { sourceGuid, targetGuid, overwrite } = state; - const apiClient = getApiClient(); - - try { - const res = await processSitemap({ - channel, - pageMapper, - sitemapNodes: sitemap, - sourceGuid: sourceGuid[0], - targetGuid: targetGuid[0], - locale: locale, - apiClient, - overwrite, - sourcePages: pages, - // Top-level pages have no parent - parentPageID: -1, - logger - }) - - successful += res.successful; - failed += res.failed; - skipped += res.skipped; - if (res.publishableIds && res.publishableIds.length > 0) { - publishableIds.push(...res.publishableIds); - } - if (res.failureDetails && res.failureDetails.length > 0) { - failureDetails.push(...res.failureDetails); - } - - if (res.failed > 0) { - status = "error"; - } - - } catch (error: any) { - // Use error.message instead of JSON.stringify to avoid circular reference issues with SDK errors - const errorMessage = error?.message || String(error); - logger.page.error(null, `⚠️ Error in page processing for channel: ${channel}: ${errorMessage}`, locale, channel, targetGuid[0]); - status = "error"; - } - - } - - // Deduplicate publishableIds to prevent "item already in batch" errors during workflow - const uniquePublishableIds = Array.from(new Set(publishableIds)); - - if (publishableIds.length !== uniquePublishableIds.length && state.verbose) { - console.log(ansiColors.gray(` 📋 Deduplicated publishable page IDs: ${publishableIds.length} → ${uniquePublishableIds.length}`)); - } - - return { status, successful, failed, skipped, publishableIds: uniquePublishableIds, failureDetails }; +export async function pushPages(sourceData: mgmtApi.PageItem[], locale: string): Promise { + // Extract data from sourceData - unified parameter pattern + let pages: mgmtApi.PageItem[] = sourceData || []; + + const { sourceGuid, targetGuid } = state; + const logger = getLoggerForGuid(sourceGuid[0]); + const pageMapper = new PageMapper(sourceGuid[0], targetGuid[0], locale); + + if (!pages || pages.length === 0) { + console.log("No pages found to process."); + return { status: "success", successful: 0, failed: 0, skipped: 0, failureDetails: [] }; + } + + const sitemapHierarchy = new SitemapHierarchy(); + + // Reset processed page IDs tracking for this locale + resetProcessedPageIDs(); + + const sitemaps = sitemapHierarchy.loadAllSitemaps(sourceGuid[0], locale); + const channels = Object.keys(sitemaps); + + console.log(`Processing ${pages.length} pages across ${channels.length} channels in ${locale}...`); + + let successful = 0; + let failed = 0; + let skipped = 0; // No duplicates to skip since API prevents true duplicates at same hierarchy level + let status: "success" | "error" = "success"; + let publishableIds: number[] = []; // Track target page IDs for workflow operations + let failureDetails: Array<{ + name: string; + error: string; + type?: "content" | "page"; + pageID?: number; + contentID?: number; + guid?: string; + locale?: string; + }> = []; + + //loop all the channels + for (const channel of channels) { + const sitemap = sitemaps[channel]; + + // Skip if sitemap is null or empty + if (!sitemap || sitemap.length === 0) { + console.log(ansiColors.yellow(`⚠️ Skipping channel ${channel} - no sitemap data found for locale ${locale}`)); + continue; + } + + const { sourceGuid, targetGuid, overwrite } = state; + const apiClient = getApiClient(); + + try { + const res = await processSitemap({ + channel, + pageMapper, + sitemapNodes: sitemap, + sourceGuid: sourceGuid[0], + targetGuid: targetGuid[0], + locale: locale, + apiClient, + overwrite, + sourcePages: pages, + // Top-level pages have no parent + parentPageID: -1, + logger, + }); + + successful += res.successful; + failed += res.failed; + skipped += res.skipped; + if (res.publishableIds && res.publishableIds.length > 0) { + publishableIds.push(...res.publishableIds); + } + if (res.failureDetails && res.failureDetails.length > 0) { + failureDetails.push(...res.failureDetails); + } + + if (res.failed > 0) { + status = "error"; + } + } catch (error: any) { + // Use error.message instead of JSON.stringify to avoid circular reference issues with SDK errors + const errorMessage = error?.message || String(error); + logger.page.error( + null, + `⚠️ Error in page processing for channel: ${channel}: ${errorMessage}`, + locale, + channel, + targetGuid[0] + ); + status = "error"; + } + } + + // Deduplicate publishableIds to prevent "item already in batch" errors during workflow + const uniquePublishableIds = Array.from(new Set(publishableIds)); + + if (publishableIds.length !== uniquePublishableIds.length && state.verbose) { + console.log( + ansiColors.gray( + ` 📋 Deduplicated publishable page IDs: ${publishableIds.length} → ${uniquePublishableIds.length}` + ) + ); + } + + return { status, successful, failed, skipped, publishableIds: uniquePublishableIds, failureDetails }; } - diff --git a/src/lib/pushers/page-pusher/sitemap-hierarchy.ts b/src/lib/pushers/page-pusher/sitemap-hierarchy.ts index b4cd5eb..12b9c83 100644 --- a/src/lib/pushers/page-pusher/sitemap-hierarchy.ts +++ b/src/lib/pushers/page-pusher/sitemap-hierarchy.ts @@ -1,609 +1,613 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import { SitemapNode, PageHierarchy, HierarchicalPageGroup, SourceEntities } from '../../../types/syncAnalysis'; -import { getState, state } from '../../../core/state'; -import ansiColors from 'ansi-colors'; +import * as fs from "fs"; +import * as path from "path"; +import { SitemapNode, PageHierarchy, HierarchicalPageGroup, SourceEntities } from "../../../types/syncAnalysis"; +import { getState, state } from "../../../core/state"; +import ansiColors from "ansi-colors"; /** * Load and parse sitemap hierarchy for hierarchical page chain analysis */ export class SitemapHierarchy { - constructor() { - // Configuration now comes from state internally - } - - loadAllSitemaps(guid: string, locale: string): { [key: string]: SitemapNode[] | null } { + constructor() { + // Configuration now comes from state internally + } - const { rootPath, sourceGuid } = state; - const sitemapDir = path.join( - rootPath, - guid, - locale, - 'nestedsitemap' - ); + loadAllSitemaps(guid: string, locale: string): { [key: string]: SitemapNode[] | null } { + const { rootPath, sourceGuid } = state; + const sitemapDir = path.join(rootPath, guid, locale, "nestedsitemap"); - const sitemaps: { [key: string]: SitemapNode[] | null } = {}; - - // Check if directory exists before trying to read it - if (!fs.existsSync(sitemapDir)) { - console.warn(`⚠️ Nested sitemap directory not found for locale ${locale}: ${sitemapDir}`); - return sitemaps; // Return empty object if directory doesn't exist - } + const sitemaps: { [key: string]: SitemapNode[] | null } = {}; - try { - fs.readdirSync(sitemapDir).forEach(fileName => { - if (!fileName.endsWith('.json')) { - return; // Skip non-JSON files - } - const channel = path.basename(fileName, '.json'); - sitemaps[channel] = this.loadNestedSitemap(path.join(sitemapDir, fileName)); - }); - } catch (error: any) { - console.error(`Error reading sitemap directory ${sitemapDir}: ${error.message}`); - return sitemaps; // Return empty object on error - } - - return sitemaps; + // Check if directory exists before trying to read it + if (!fs.existsSync(sitemapDir)) { + console.warn(`⚠️ Nested sitemap directory not found for locale ${locale}: ${sitemapDir}`); + return sitemaps; // Return empty object if directory doesn't exist } - /** - * Load nested sitemap from the file system - */ - loadNestedSitemap(filePath: string): SitemapNode[] | null { - try { - if (!fs.existsSync(filePath)) { - console.warn(`Nested sitemap not found at: ${filePath}`); - return null; - } - - const sitemapData = fs.readFileSync(filePath, 'utf8'); - const sitemap: SitemapNode[] = JSON.parse(sitemapData); - - // Loaded nested sitemap (silent) - return sitemap; - } catch (error) { - console.error(`Error loading nested sitemap: ${error.message}`); - return null; + try { + fs.readdirSync(sitemapDir).forEach((fileName) => { + if (!fileName.endsWith(".json")) { + return; // Skip non-JSON files } + const channel = path.basename(fileName, ".json"); + sitemaps[channel] = this.loadNestedSitemap(path.join(sitemapDir, fileName)); + }); + } catch (error: any) { + console.error(`Error reading sitemap directory ${sitemapDir}: ${error.message}`); + return sitemaps; // Return empty object on error } - /** - * Build page hierarchy map from nested sitemap - */ - buildPageHierarchy(sitemap: SitemapNode[]): PageHierarchy { - const hierarchy: PageHierarchy = {}; - - const processNode = (node: SitemapNode) => { - if (node.children && node.children.length > 0) { - // This node has children - hierarchy[node.pageID] = node.children.map(child => child.pageID); - - // Recursively process children - node.children.forEach(child => processNode(child)); - } - }; - - sitemap.forEach(node => processNode(node)); - return hierarchy; - } - - /** - * Group pages hierarchically based on sitemap structure - */ - groupPagesHierarchically(pages: any[], hierarchy: PageHierarchy): HierarchicalPageGroup[] { - const processedPages = new Set(); - const hierarchicalGroups: HierarchicalPageGroup[] = []; - - // Process each page that has children - pages.forEach(page => { - if (!processedPages.has(page.pageID) && hierarchy[page.pageID]) { - // This page has children, create a group for it - const group = this.buildHierarchicalGroup(page, pages, hierarchy, processedPages); - hierarchicalGroups.push(group); - } - }); + return sitemaps; + } - // Process remaining pages that don't have children and aren't children of processed pages - pages.forEach(page => { - if (!processedPages.has(page.pageID)) { - // This is an orphaned page (no children, not a child of any processed page) - const group: HierarchicalPageGroup = { - rootPage: page, - childPages: [], - allPageIds: new Set([page.pageID]) - }; - hierarchicalGroups.push(group); - processedPages.add(page.pageID); - } - }); + /** + * Load nested sitemap from the file system + */ + loadNestedSitemap(filePath: string): SitemapNode[] | null { + try { + if (!fs.existsSync(filePath)) { + console.warn(`Nested sitemap not found at: ${filePath}`); + return null; + } - return hierarchicalGroups; - } + const sitemapData = fs.readFileSync(filePath, "utf8"); + const sitemap: SitemapNode[] = JSON.parse(sitemapData); - /** - * Find the parent page ID for a given page (only if parent exists in our page list) - */ - private findParentPageId(pageId: number, hierarchy: PageHierarchy, pages: any[]): number | null { - for (const [parentId, childIds] of Object.entries(hierarchy)) { - if ((childIds as number[]).includes(pageId)) { - // Check if the parent exists in our page list - const parentExists = pages.some(p => p.pageID === parseInt(parentId)); - if (parentExists) { - return parseInt(parentId); - } - } - } - return null; + // Loaded nested sitemap (silent) + return sitemap; + } catch (error) { + console.error(`Error loading nested sitemap: ${error.message}`); + return null; } - - /** - * Build a hierarchical group starting from a root page - */ - private buildHierarchicalGroup( - rootPage: any, - allPages: any[], - hierarchy: PageHierarchy, - processedPages: Set - ): HierarchicalPageGroup { + } + + /** + * Build page hierarchy map from nested sitemap + */ + buildPageHierarchy(sitemap: SitemapNode[]): PageHierarchy { + const hierarchy: PageHierarchy = {}; + + const processNode = (node: SitemapNode) => { + if (node.children && node.children.length > 0) { + // This node has children + hierarchy[node.pageID] = node.children.map((child) => child.pageID); + + // Recursively process children + node.children.forEach((child) => processNode(child)); + } + }; + + sitemap.forEach((node) => processNode(node)); + return hierarchy; + } + + /** + * Group pages hierarchically based on sitemap structure + */ + groupPagesHierarchically(pages: any[], hierarchy: PageHierarchy): HierarchicalPageGroup[] { + const processedPages = new Set(); + const hierarchicalGroups: HierarchicalPageGroup[] = []; + + // Process each page that has children + pages.forEach((page) => { + if (!processedPages.has(page.pageID) && hierarchy[page.pageID]) { + // This page has children, create a group for it + const group = this.buildHierarchicalGroup(page, pages, hierarchy, processedPages); + hierarchicalGroups.push(group); + } + }); + + // Process remaining pages that don't have children and aren't children of processed pages + pages.forEach((page) => { + if (!processedPages.has(page.pageID)) { + // This is an orphaned page (no children, not a child of any processed page) const group: HierarchicalPageGroup = { - rootPage, - childPages: [], - allPageIds: new Set([rootPage.pageID]) + rootPage: page, + childPages: [], + allPageIds: new Set([page.pageID]), }; - - // Mark root as processed - processedPages.add(rootPage.pageID); - - // Collect ALL descendants with unlimited nesting levels - this.collectAllDescendants(rootPage.pageID, allPages, hierarchy, group, processedPages); - - return group; - } - - /** - * Collect all descendants with unlimited nesting levels (not just direct children) - * This enables proper display of deep hierarchies like PageID:A → PageID:B → PageID:C - */ - private collectAllDescendants( - parentPageId: number, - allPages: any[], - hierarchy: PageHierarchy, - group: HierarchicalPageGroup, - processedPages: Set - ): void { - const directChildIds = hierarchy[parentPageId] || []; - - (directChildIds as number[]).forEach(childId => { - const childPage = allPages.find(p => p.pageID === childId); - if (childPage && !processedPages.has(childId)) { - // Add this child to the current level - group.childPages.push(childPage); - group.allPageIds.add(childId); - processedPages.add(childId); - - // Recursively collect ALL descendants (grandchildren, great-grandchildren, etc.) - this.collectAllDescendants(childId, allPages, hierarchy, group, processedPages); - } - }); - } - - /** - * Get orphaned pages (pages not in any hierarchical group) - */ - getOrphanedPages(pages: any[], hierarchicalGroups: HierarchicalPageGroup[]): any[] { - const allProcessedIds = new Set(); - - hierarchicalGroups.forEach(group => { - group.allPageIds.forEach(id => allProcessedIds.add(id)); - }); - - return pages.filter(page => !allProcessedIds.has(page.pageID)); - } - - /** - * Debug: Log hierarchy structure - */ - debugLogHierarchy(hierarchy: PageHierarchy): void { - console.log(`🔧 [DEBUG] Page hierarchy structure:`); - Object.entries(hierarchy).forEach(([parentId, childIds]) => { - console.log(` Parent ${parentId} has children: ${(childIds as number[]).join(', ')}`); - }); - } - - /** - * ✅ NEW: Find page parent from source sitemap with comprehensive lookup - * Handles both template pages and dynamic page instances - */ - findPageParentInSourceSitemap(pageId: number, pageName: string, channelName: string): { parentId: number | null; parentName: string | null; foundIn: string } { - try { - const sitemap = this.loadNestedSitemap(channelName); - if (!sitemap || sitemap.length === 0) { - return { parentId: null, parentName: null, foundIn: 'no-sitemap' }; - } - - - - // Recursive function to search through sitemap - const searchSitemap = (nodes: SitemapNode[], parentNode: SitemapNode | null = null): { parentId: number | null; parentName: string | null; foundIn: string } => { - for (const node of nodes) { - // Check if this node is our target page - if (node.pageID === pageId || node.name === pageName) { - if (parentNode) { - console.log(`🎯 [DEBUG] Found ${pageName} (ID:${pageId}) under parent ${parentNode.name} (ID:${parentNode.pageID})`); - return { - parentId: parentNode.pageID, - parentName: parentNode.name, - foundIn: 'direct-match' - }; - } else { - console.log(`🏠 [DEBUG] Found ${pageName} (ID:${pageId}) at root level`); - return { parentId: null, parentName: null, foundIn: 'root-level' }; - } - } - - // Check if this node has children (dynamic page instances) - if (node.children && node.children.length > 0) { - // For dynamic pages: check if any child has same pageID as template - const dynamicMatch = node.children.find(child => child.pageID === pageId); - if (dynamicMatch) { - console.log(`🎯 [DEBUG] Found dynamic page ${pageName} (ID:${pageId}) under parent ${node.name} (ID:${node.pageID})`); - return { - parentId: node.pageID, - parentName: node.name, - foundIn: 'dynamic-child' - }; - } - - // Recursively search children - const childResult = searchSitemap(node.children, node); - if (childResult.parentId !== null) { - return childResult; - } - } - } - return { parentId: null, parentName: null, foundIn: 'not-found' }; - }; - - const result = searchSitemap(sitemap); - console.log(`📍 [DEBUG] Parent lookup result for ${pageName}:`, result); - return result; - - } catch (error) { - console.error(`❌ [DEBUG] Error looking up parent for ${pageName}:`, error.message); - return { parentId: null, parentName: null, foundIn: 'error' }; + hierarchicalGroups.push(group); + processedPages.add(page.pageID); + } + }); + + return hierarchicalGroups; + } + + /** + * Find the parent page ID for a given page (only if parent exists in our page list) + */ + private findParentPageId(pageId: number, hierarchy: PageHierarchy, pages: any[]): number | null { + for (const [parentId, childIds] of Object.entries(hierarchy)) { + if ((childIds as number[]).includes(pageId)) { + // Check if the parent exists in our page list + const parentExists = pages.some((p) => p.pageID === parseInt(parentId)); + if (parentExists) { + return parseInt(parentId); } + } } - - /** - * ✅ NEW: Enhanced hierarchy build that handles dynamic pages correctly - */ - buildPageHierarchyWithDynamicSupport(sitemap: SitemapNode[]): PageHierarchy { - const hierarchy: PageHierarchy = {}; - - const processNode = (node: SitemapNode, parentNode: SitemapNode | null = null) => { - // If this node has children, add them to hierarchy - if (node.children && node.children.length > 0) { - hierarchy[node.pageID] = node.children.map(child => child.pageID); - - // Process children recursively - node.children.forEach(child => processNode(child, node)); - } - - // Special handling for dynamic pages - // If this node has dynamic children (contentID present), also map those - if (node.children) { - node.children.forEach(child => { - if (child.contentID) { - // This is a dynamic page instance - ensure it knows its parent - if (!hierarchy[node.pageID]) { - hierarchy[node.pageID] = []; - } - if (!hierarchy[node.pageID].includes(child.pageID)) { - hierarchy[node.pageID].push(child.pageID); - } - } - }); - } - }; - - sitemap.forEach(node => processNode(node)); - return hierarchy; - } - - /** - * Calculate depth level for each page in the hierarchy - * Depth 0 = root pages (no parents), Depth 1 = direct children, etc. - */ - calculatePageDepths(pages: any[], hierarchy: PageHierarchy): Map { - const pageDepths = new Map(); - const visited = new Set(); - - // Build reverse lookup: child → parent - const childToParent = new Map(); - Object.entries(hierarchy).forEach(([parentIdStr, childIds]) => { - const parentId = parseInt(parentIdStr); - (childIds as number[]).forEach(childId => { - childToParent.set(childId, parentId); - }); - }); - - // Calculate depth recursively for each page - const calculateDepth = (pageId: number): number => { - if (visited.has(pageId)) { - // Circular reference detected - return high depth to process early - console.warn(`Circular reference detected for page ${pageId}`); - return 999; + return null; + } + + /** + * Build a hierarchical group starting from a root page + */ + private buildHierarchicalGroup( + rootPage: any, + allPages: any[], + hierarchy: PageHierarchy, + processedPages: Set + ): HierarchicalPageGroup { + const group: HierarchicalPageGroup = { + rootPage, + childPages: [], + allPageIds: new Set([rootPage.pageID]), + }; + + // Mark root as processed + processedPages.add(rootPage.pageID); + + // Collect ALL descendants with unlimited nesting levels + this.collectAllDescendants(rootPage.pageID, allPages, hierarchy, group, processedPages); + + return group; + } + + /** + * Collect all descendants with unlimited nesting levels (not just direct children) + * This enables proper display of deep hierarchies like PageID:A → PageID:B → PageID:C + */ + private collectAllDescendants( + parentPageId: number, + allPages: any[], + hierarchy: PageHierarchy, + group: HierarchicalPageGroup, + processedPages: Set + ): void { + const directChildIds = hierarchy[parentPageId] || []; + + (directChildIds as number[]).forEach((childId) => { + const childPage = allPages.find((p) => p.pageID === childId); + if (childPage && !processedPages.has(childId)) { + // Add this child to the current level + group.childPages.push(childPage); + group.allPageIds.add(childId); + processedPages.add(childId); + + // Recursively collect ALL descendants (grandchildren, great-grandchildren, etc.) + this.collectAllDescendants(childId, allPages, hierarchy, group, processedPages); + } + }); + } + + /** + * Get orphaned pages (pages not in any hierarchical group) + */ + getOrphanedPages(pages: any[], hierarchicalGroups: HierarchicalPageGroup[]): any[] { + const allProcessedIds = new Set(); + + hierarchicalGroups.forEach((group) => { + group.allPageIds.forEach((id) => allProcessedIds.add(id)); + }); + + return pages.filter((page) => !allProcessedIds.has(page.pageID)); + } + + /** + * Debug: Log hierarchy structure + */ + debugLogHierarchy(hierarchy: PageHierarchy): void { + console.log(`🔧 [DEBUG] Page hierarchy structure:`); + Object.entries(hierarchy).forEach(([parentId, childIds]) => { + console.log(` Parent ${parentId} has children: ${(childIds as number[]).join(", ")}`); + }); + } + + /** + * ✅ NEW: Find page parent from source sitemap with comprehensive lookup + * Handles both template pages and dynamic page instances + */ + findPageParentInSourceSitemap( + pageId: number, + pageName: string, + channelName: string + ): { parentId: number | null; parentName: string | null; foundIn: string } { + try { + const sitemap = this.loadNestedSitemap(channelName); + if (!sitemap || sitemap.length === 0) { + return { parentId: null, parentName: null, foundIn: "no-sitemap" }; + } + + // Recursive function to search through sitemap + const searchSitemap = ( + nodes: SitemapNode[], + parentNode: SitemapNode | null = null + ): { parentId: number | null; parentName: string | null; foundIn: string } => { + for (const node of nodes) { + // Check if this node is our target page + if (node.pageID === pageId || node.name === pageName) { + if (parentNode) { + console.log( + `🎯 [DEBUG] Found ${pageName} (ID:${pageId}) under parent ${parentNode.name} (ID:${parentNode.pageID})` + ); + return { + parentId: parentNode.pageID, + parentName: parentNode.name, + foundIn: "direct-match", + }; + } else { + console.log(`🏠 [DEBUG] Found ${pageName} (ID:${pageId}) at root level`); + return { parentId: null, parentName: null, foundIn: "root-level" }; } - - if (pageDepths.has(pageId)) { - return pageDepths.get(pageId)!; + } + + // Check if this node has children (dynamic page instances) + if (node.children && node.children.length > 0) { + // For dynamic pages: check if any child has same pageID as template + const dynamicMatch = node.children.find((child) => child.pageID === pageId); + if (dynamicMatch) { + console.log( + `🎯 [DEBUG] Found dynamic page ${pageName} (ID:${pageId}) under parent ${node.name} (ID:${node.pageID})` + ); + return { + parentId: node.pageID, + parentName: node.name, + foundIn: "dynamic-child", + }; } - visited.add(pageId); - - const parentId = childToParent.get(pageId); - if (!parentId) { - // Root page (no parent) - pageDepths.set(pageId, 0); - visited.delete(pageId); - return 0; + // Recursively search children + const childResult = searchSitemap(node.children, node); + if (childResult.parentId !== null) { + return childResult; } - - // Parent exists - depth is parent's depth + 1 - const parentDepth = calculateDepth(parentId); - const depth = parentDepth + 1; - pageDepths.set(pageId, depth); - visited.delete(pageId); - return depth; - }; - - // Calculate depth for all pages - pages.forEach(page => { - calculateDepth(page.pageID); - }); - - return pageDepths; + } + } + return { parentId: null, parentName: null, foundIn: "not-found" }; + }; + + const result = searchSitemap(sitemap); + console.log(`📍 [DEBUG] Parent lookup result for ${pageName}:`, result); + return result; + } catch (error) { + console.error(`❌ [DEBUG] Error looking up parent for ${pageName}:`, error.message); + return { parentId: null, parentName: null, foundIn: "error" }; } - - /** - * Get pages grouped by depth level - * Returns map of depth → pages at that depth - */ - getPagesByDepth(pages: any[], pageDepths: Map): Map { - const pagesByDepth = new Map(); - - pages.forEach(page => { - const depth = pageDepths.get(page.pageID) || 0; - if (!pagesByDepth.has(depth)) { - pagesByDepth.set(depth, []); + } + + /** + * ✅ NEW: Enhanced hierarchy build that handles dynamic pages correctly + */ + buildPageHierarchyWithDynamicSupport(sitemap: SitemapNode[]): PageHierarchy { + const hierarchy: PageHierarchy = {}; + + const processNode = (node: SitemapNode, parentNode: SitemapNode | null = null) => { + // If this node has children, add them to hierarchy + if (node.children && node.children.length > 0) { + hierarchy[node.pageID] = node.children.map((child) => child.pageID); + + // Process children recursively + node.children.forEach((child) => processNode(child, node)); + } + + // Special handling for dynamic pages + // If this node has dynamic children (contentID present), also map those + if (node.children) { + node.children.forEach((child) => { + if (child.contentID) { + // This is a dynamic page instance - ensure it knows its parent + if (!hierarchy[node.pageID]) { + hierarchy[node.pageID] = []; } - pagesByDepth.get(depth)!.push(page); - }); - - return pagesByDepth; - } - - /** - * Generate dependency-safe page processing order - * Returns pages ordered by depth (shallowest first) so parents are processed before children - */ - getProcessingOrder(pages: any[], hierarchy: PageHierarchy): { orderedPages: any[]; depthInfo: Map } { - // Calculate page depths - const pageDepths = this.calculatePageDepths(pages, hierarchy); - - // Group pages by depth - const pagesByDepth = this.getPagesByDepth(pages, pageDepths); - - // Sort depth levels in ascending order (shallowest first = parents before children) - const sortedDepths = Array.from(pagesByDepth.keys()).sort((a, b) => a - b); - - // Build ordered array with shallowest pages first (parents before children) - const orderedPages: any[] = []; - sortedDepths.forEach(depth => { - const pagesAtDepth = pagesByDepth.get(depth) || []; - // Sort pages within same depth by pageID for consistency - pagesAtDepth.sort((a, b) => a.pageID - b.pageID); - orderedPages.push(...pagesAtDepth); - }); - - // Page processing order calculated (silent) - - return { orderedPages, depthInfo: pageDepths }; - } - - /** - * Validate page processing order is dependency-safe - * Ensures no page is processed before its parent - */ - validateProcessingOrder(orderedPages: any[], hierarchy: PageHierarchy): boolean { - const processedPageIds = new Set(); - - // Build reverse lookup: child → parent - const childToParent = new Map(); - Object.entries(hierarchy).forEach(([parentIdStr, childIds]) => { - const parentId = parseInt(parentIdStr); - childIds.forEach(childId => { - childToParent.set(childId, parentId); - }); - }); - - for (const page of orderedPages) { - const parentId = childToParent.get(page.pageID); - - if (parentId && !processedPageIds.has(parentId)) { - // This page's parent hasn't been processed yet - order is invalid - console.error(`❌ Invalid processing order: Page ${page.pageID} scheduled before parent ${parentId}`); - return false; + if (!hierarchy[node.pageID].includes(child.pageID)) { + hierarchy[node.pageID].push(child.pageID); } - - processedPageIds.add(page.pageID); - } - - // Processing order validation passed (silent) - return true; + } + }); + } + }; + + sitemap.forEach((node) => processNode(node)); + return hierarchy; + } + + /** + * Calculate depth level for each page in the hierarchy + * Depth 0 = root pages (no parents), Depth 1 = direct children, etc. + */ + calculatePageDepths(pages: any[], hierarchy: PageHierarchy): Map { + const pageDepths = new Map(); + const visited = new Set(); + + // Build reverse lookup: child → parent + const childToParent = new Map(); + Object.entries(hierarchy).forEach(([parentIdStr, childIds]) => { + const parentId = parseInt(parentIdStr); + (childIds as number[]).forEach((childId) => { + childToParent.set(childId, parentId); + }); + }); + + // Calculate depth recursively for each page + const calculateDepth = (pageId: number): number => { + if (visited.has(pageId)) { + // Circular reference detected - return high depth to process early + console.warn(`Circular reference detected for page ${pageId}`); + return 999; + } + + if (pageDepths.has(pageId)) { + return pageDepths.get(pageId)!; + } + + visited.add(pageId); + + const parentId = childToParent.get(pageId); + if (!parentId) { + // Root page (no parent) + pageDepths.set(pageId, 0); + visited.delete(pageId); + return 0; + } + + // Parent exists - depth is parent's depth + 1 + const parentDepth = calculateDepth(parentId); + const depth = parentDepth + 1; + pageDepths.set(pageId, depth); + visited.delete(pageId); + return depth; + }; + + // Calculate depth for all pages + pages.forEach((page) => { + calculateDepth(page.pageID); + }); + + return pageDepths; + } + + /** + * Get pages grouped by depth level + * Returns map of depth → pages at that depth + */ + getPagesByDepth(pages: any[], pageDepths: Map): Map { + const pagesByDepth = new Map(); + + pages.forEach((page) => { + const depth = pageDepths.get(page.pageID) || 0; + if (!pagesByDepth.has(depth)) { + pagesByDepth.set(depth, []); + } + pagesByDepth.get(depth)!.push(page); + }); + + return pagesByDepth; + } + + /** + * Generate dependency-safe page processing order + * Returns pages ordered by depth (shallowest first) so parents are processed before children + */ + getProcessingOrder(pages: any[], hierarchy: PageHierarchy): { orderedPages: any[]; depthInfo: Map } { + // Calculate page depths + const pageDepths = this.calculatePageDepths(pages, hierarchy); + + // Group pages by depth + const pagesByDepth = this.getPagesByDepth(pages, pageDepths); + + // Sort depth levels in ascending order (shallowest first = parents before children) + const sortedDepths = Array.from(pagesByDepth.keys()).sort((a, b) => a - b); + + // Build ordered array with shallowest pages first (parents before children) + const orderedPages: any[] = []; + sortedDepths.forEach((depth) => { + const pagesAtDepth = pagesByDepth.get(depth) || []; + // Sort pages within same depth by pageID for consistency + pagesAtDepth.sort((a, b) => a.pageID - b.pageID); + orderedPages.push(...pagesAtDepth); + }); + + // Page processing order calculated (silent) + + return { orderedPages, depthInfo: pageDepths }; + } + + /** + * Validate page processing order is dependency-safe + * Ensures no page is processed before its parent + */ + validateProcessingOrder(orderedPages: any[], hierarchy: PageHierarchy): boolean { + const processedPageIds = new Set(); + + // Build reverse lookup: child → parent + const childToParent = new Map(); + Object.entries(hierarchy).forEach(([parentIdStr, childIds]) => { + const parentId = parseInt(parentIdStr); + childIds.forEach((childId) => { + childToParent.set(childId, parentId); + }); + }); + + for (const page of orderedPages) { + const parentId = childToParent.get(page.pageID); + + if (parentId && !processedPageIds.has(parentId)) { + // This page's parent hasn't been processed yet - order is invalid + console.error(`❌ Invalid processing order: Page ${page.pageID} scheduled before parent ${parentId}`); + return false; + } + + processedPageIds.add(page.pageID); } - /** - * Extract sibling ordering information from source sitemap - * Returns a map of pageID → nextSiblingPageID for proper insertion order - */ - extractSiblingOrderFromSitemap(sitemap: SitemapNode[]): Map { - const siblingOrderMap = new Map(); - - const processSiblings = (siblings: SitemapNode[], depth: number = 0) => { - for (let i = 0; i < siblings.length; i++) { - const currentPage = siblings[i]; - const nextSibling = i < siblings.length - 1 ? siblings[i + 1] : null; - - // Map current page to its next sibling (or null if last) - siblingOrderMap.set(currentPage.pageID, nextSibling?.pageID || null); - - // Process child pages recursively - if (currentPage.children && currentPage.children.length > 0) { - processSiblings(currentPage.children, depth + 1); - } - } - }; + // Processing order validation passed (silent) + return true; + } - processSiblings(sitemap, 0); + /** + * Extract sibling ordering information from source sitemap + * Returns a map of pageID → nextSiblingPageID for proper insertion order + */ + extractSiblingOrderFromSitemap(sitemap: SitemapNode[]): Map { + const siblingOrderMap = new Map(); - return siblingOrderMap; - } + const processSiblings = (siblings: SitemapNode[], depth: number = 0) => { + for (let i = 0; i < siblings.length; i++) { + const currentPage = siblings[i]; + const nextSibling = i < siblings.length - 1 ? siblings[i + 1] : null; - /** - * Get the pageID that should come BEFORE the specified page (for insertBefore parameter) - * FIXED: Returns the NEXT sibling (what this page should go before), not the previous sibling - */ - getInsertBeforePageId(pageId: number, siblingOrder: Map): number | null { + // Map current page to its next sibling (or null if last) + siblingOrderMap.set(currentPage.pageID, nextSibling?.pageID || null); - // FIXED: Return the next sibling directly - this page should go BEFORE its next sibling - const nextSiblingId = siblingOrder.get(pageId) || null; - - if (nextSiblingId) { - return nextSiblingId; - } else { - return null; // No next sibling found (page is last in its group, will place at end) + // Process child pages recursively + if (currentPage.children && currentPage.children.length > 0) { + processSiblings(currentPage.children, depth + 1); } + } + }; + + processSiblings(sitemap, 0); + + return siblingOrderMap; + } + + /** + * Get the pageID that should come BEFORE the specified page (for insertBefore parameter) + * FIXED: Returns the NEXT sibling (what this page should go before), not the previous sibling + */ + getInsertBeforePageId(pageId: number, siblingOrder: Map): number | null { + // FIXED: Return the next sibling directly - this page should go BEFORE its next sibling + const nextSiblingId = siblingOrder.get(pageId) || null; + + if (nextSiblingId) { + return nextSiblingId; + } else { + return null; // No next sibling found (page is last in its group, will place at end) } - - /** - * Build comprehensive page ordering data including parent-child and sibling relationships - */ - buildPageOrderingData(sitemap: SitemapNode[]): { - hierarchy: PageHierarchy; - siblingOrder: Map; - parentToChildrenMap: Map; - } { - const hierarchy = this.buildPageHierarchyWithDynamicSupport(sitemap); - const siblingOrder = this.extractSiblingOrderFromSitemap(sitemap); - - // Build parent-to-children mapping for quick lookup - const parentToChildrenMap = new Map(); - Object.entries(hierarchy).forEach(([parentIdStr, childIds]) => { - const parentId = parseInt(parentIdStr); - parentToChildrenMap.set(parentId, childIds as number[]); - }); - - return { - hierarchy, - siblingOrder, - parentToChildrenMap - }; - } - - /** - * Get processing order that preserves both parent-child dependencies AND sibling order - */ - getOrderedProcessingSequence(pages: any[], sitemap: SitemapNode[]): { - orderedPages: any[]; - orderingData: { - hierarchy: PageHierarchy; - siblingOrder: Map; - parentToChildrenMap: Map; - }; - } { - const orderingData = this.buildPageOrderingData(sitemap); - const { hierarchy } = orderingData; - - // Get dependency-safe order (parents before children) - const { orderedPages } = this.getProcessingOrder(pages, hierarchy); - - // Within each depth level, sort by sibling order - const pageDepths = this.calculatePageDepths(pages, hierarchy); - const pagesByDepth = this.getPagesByDepth(pages, pageDepths); - - // Rebuild ordered pages respecting sibling order within each depth - const finalOrderedPages: any[] = []; - const sortedDepths = Array.from(pagesByDepth.keys()).sort((a, b) => a - b); - - sortedDepths.forEach(depth => { - const pagesAtDepth = pagesByDepth.get(depth) || []; - - // Group pages by parent for sibling ordering - const pagesByParent = new Map(); - pagesAtDepth.forEach(page => { - const parentId = this.getParentPageId(page.pageID, hierarchy) || -1; - if (!pagesByParent.has(parentId)) { - pagesByParent.set(parentId, []); - } - pagesByParent.get(parentId)!.push(page); - }); - - // Sort each parent group by sibling order - pagesByParent.forEach((siblings, parentId) => { - const sortedSiblings = this.sortPagesBySiblingOrder(siblings, orderingData.siblingOrder); - finalOrderedPages.push(...sortedSiblings); - }); - }); - - return { - orderedPages: finalOrderedPages, - orderingData - }; - } - - /** - * Sort pages by their sibling order from the sitemap - */ - private sortPagesBySiblingOrder(pages: any[], siblingOrder: Map): any[] { - // Create a map to track the position of each page in the sibling order - const pagePositions = new Map(); - - // Build position map by following the sibling chain - let position = 0; - let currentPageId: number | null = null; - - // Find the first page (one that is not a next sibling of any other page) - const allNextSiblings = new Set(Array.from(siblingOrder.values()).filter(id => id !== null)); - const firstPage = pages.find(page => !allNextSiblings.has(page.pageID)); - - if (firstPage) { - currentPageId = firstPage.pageID; - - // Follow the sibling chain to assign positions - while (currentPageId !== null) { - pagePositions.set(currentPageId, position++); - currentPageId = siblingOrder.get(currentPageId) || null; - } + } + + /** + * Build comprehensive page ordering data including parent-child and sibling relationships + */ + buildPageOrderingData(sitemap: SitemapNode[]): { + hierarchy: PageHierarchy; + siblingOrder: Map; + parentToChildrenMap: Map; + } { + const hierarchy = this.buildPageHierarchyWithDynamicSupport(sitemap); + const siblingOrder = this.extractSiblingOrderFromSitemap(sitemap); + + // Build parent-to-children mapping for quick lookup + const parentToChildrenMap = new Map(); + Object.entries(hierarchy).forEach(([parentIdStr, childIds]) => { + const parentId = parseInt(parentIdStr); + parentToChildrenMap.set(parentId, childIds as number[]); + }); + + return { + hierarchy, + siblingOrder, + parentToChildrenMap, + }; + } + + /** + * Get processing order that preserves both parent-child dependencies AND sibling order + */ + getOrderedProcessingSequence( + pages: any[], + sitemap: SitemapNode[] + ): { + orderedPages: any[]; + orderingData: { + hierarchy: PageHierarchy; + siblingOrder: Map; + parentToChildrenMap: Map; + }; + } { + const orderingData = this.buildPageOrderingData(sitemap); + const { hierarchy } = orderingData; + + // Get dependency-safe order (parents before children) + const { orderedPages } = this.getProcessingOrder(pages, hierarchy); + + // Within each depth level, sort by sibling order + const pageDepths = this.calculatePageDepths(pages, hierarchy); + const pagesByDepth = this.getPagesByDepth(pages, pageDepths); + + // Rebuild ordered pages respecting sibling order within each depth + const finalOrderedPages: any[] = []; + const sortedDepths = Array.from(pagesByDepth.keys()).sort((a, b) => a - b); + + sortedDepths.forEach((depth) => { + const pagesAtDepth = pagesByDepth.get(depth) || []; + + // Group pages by parent for sibling ordering + const pagesByParent = new Map(); + pagesAtDepth.forEach((page) => { + const parentId = this.getParentPageId(page.pageID, hierarchy) || -1; + if (!pagesByParent.has(parentId)) { + pagesByParent.set(parentId, []); } - - // Sort pages by their positions (pages without positions go to end) - return pages.sort((a, b) => { - const posA = pagePositions.get(a.pageID) ?? 9999; - const posB = pagePositions.get(b.pageID) ?? 9999; - return posA - posB; - }); + pagesByParent.get(parentId)!.push(page); + }); + + // Sort each parent group by sibling order + pagesByParent.forEach((siblings, parentId) => { + const sortedSiblings = this.sortPagesBySiblingOrder(siblings, orderingData.siblingOrder); + finalOrderedPages.push(...sortedSiblings); + }); + }); + + return { + orderedPages: finalOrderedPages, + orderingData, + }; + } + + /** + * Sort pages by their sibling order from the sitemap + */ + private sortPagesBySiblingOrder(pages: any[], siblingOrder: Map): any[] { + // Create a map to track the position of each page in the sibling order + const pagePositions = new Map(); + + // Build position map by following the sibling chain + let position = 0; + let currentPageId: number | null = null; + + // Find the first page (one that is not a next sibling of any other page) + const allNextSiblings = new Set(Array.from(siblingOrder.values()).filter((id) => id !== null)); + const firstPage = pages.find((page) => !allNextSiblings.has(page.pageID)); + + if (firstPage) { + currentPageId = firstPage.pageID; + + // Follow the sibling chain to assign positions + while (currentPageId !== null) { + pagePositions.set(currentPageId, position++); + currentPageId = siblingOrder.get(currentPageId) || null; + } } - /** - * Get parent page ID for a given page - */ - private getParentPageId(pageId: number, hierarchy: PageHierarchy): number | null { - for (const [parentIdStr, childIds] of Object.entries(hierarchy)) { - if ((childIds as number[]).includes(pageId)) { - return parseInt(parentIdStr); - } - } - return null; + // Sort pages by their positions (pages without positions go to end) + return pages.sort((a, b) => { + const posA = pagePositions.get(a.pageID) ?? 9999; + const posB = pagePositions.get(b.pageID) ?? 9999; + return posA - posB; + }); + } + + /** + * Get parent page ID for a given page + */ + private getParentPageId(pageId: number, hierarchy: PageHierarchy): number | null { + for (const [parentIdStr, childIds] of Object.entries(hierarchy)) { + if ((childIds as number[]).includes(pageId)) { + return parseInt(parentIdStr); + } } + return null; + } } diff --git a/src/lib/pushers/page-pusher/tests/find-page-in-other-locale.test.ts b/src/lib/pushers/page-pusher/tests/find-page-in-other-locale.test.ts index 37dd442..3411e32 100644 --- a/src/lib/pushers/page-pusher/tests/find-page-in-other-locale.test.ts +++ b/src/lib/pushers/page-pusher/tests/find-page-in-other-locale.test.ts @@ -1,14 +1,14 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, setState, state } from 'core/state'; -import { findPageInOtherLocale } from '../find-page-in-other-locale'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState, state } from "core/state"; +import { findPageInOtherLocale } from "../find-page-in-other-locale"; // PageMapper reads mapping files from disk — redirect file I/O to tmpDir let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-fpiol-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-fpiol-")); }); afterAll(() => { @@ -18,9 +18,9 @@ afterAll(() => { beforeEach(() => { resetState(); setState({ rootPath: tmpDir }); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -29,14 +29,14 @@ afterEach(() => { // ─── helpers ────────────────────────────────────────────────────────────────── -const SOURCE_GUID = 'src-guid'; -const TARGET_GUID = 'tgt-guid'; +const SOURCE_GUID = "src-guid"; +const TARGET_GUID = "tgt-guid"; // Mapping files are stored at: {rootPath}/mappings/{sourceGuid}-{targetGuid}/{locale}/page/mappings.json function writeMappingFile(sourceGuid: string, targetGuid: string, locale: string, mappings: any[]): void { - const dir = path.join(tmpDir, 'mappings', `${sourceGuid}-${targetGuid}`, locale, 'page'); + const dir = path.join(tmpDir, "mappings", `${sourceGuid}-${targetGuid}`, locale, "page"); fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, 'mappings.json'), JSON.stringify(mappings)); + fs.writeFileSync(path.join(dir, "mappings.json"), JSON.stringify(mappings)); } function makeMapping(sourcePageID: number, targetPageID: number): any { @@ -47,30 +47,30 @@ function makeMapping(sourcePageID: number, targetPageID: number): any { targetPageID, sourceVersionID: 1, targetVersionID: 1, - sourcePageTemplateName: 'Template', - targetPageTemplateName: 'Template', + sourcePageTemplateName: "Template", + targetPageTemplateName: "Template", }; } // ─── no other locales ──────────────────────────────────────────────────────── -describe('findPageInOtherLocale — no other locales', () => { - it('returns null when availableLocales is empty', async () => { +describe("findPageInOtherLocale — no other locales", () => { + it("returns null when availableLocales is empty", async () => { state.availableLocales = []; const result = await findPageInOtherLocale({ sourcePageID: 1, - locale: 'en-us', + locale: "en-us", sourceGuid: SOURCE_GUID, targetGuid: TARGET_GUID, }); expect(result).toBeNull(); }); - it('returns null when the only available locale is the current locale (skips self)', async () => { - state.availableLocales = ['en-us']; + it("returns null when the only available locale is the current locale (skips self)", async () => { + state.availableLocales = ["en-us"]; const result = await findPageInOtherLocale({ sourcePageID: 1, - locale: 'en-us', + locale: "en-us", sourceGuid: SOURCE_GUID, targetGuid: TARGET_GUID, }); @@ -80,28 +80,28 @@ describe('findPageInOtherLocale — no other locales', () => { // ─── mapping not found in other locales ─────────────────────────────────────── -describe('findPageInOtherLocale — no mapping in other locales', () => { - it('returns null when other locale has no mapping for the given pageID', async () => { - state.availableLocales = ['en-us', 'fr-fr']; +describe("findPageInOtherLocale — no mapping in other locales", () => { + it("returns null when other locale has no mapping for the given pageID", async () => { + state.availableLocales = ["en-us", "fr-fr"]; // Write fr-fr mapping for a DIFFERENT page - writeMappingFile(SOURCE_GUID, TARGET_GUID, 'fr-fr', [makeMapping(999, 888)]); + writeMappingFile(SOURCE_GUID, TARGET_GUID, "fr-fr", [makeMapping(999, 888)]); const result = await findPageInOtherLocale({ sourcePageID: 1, - locale: 'en-us', + locale: "en-us", sourceGuid: SOURCE_GUID, targetGuid: TARGET_GUID, }); expect(result).toBeNull(); }); - it('returns null when other locale mapping file is missing', async () => { - state.availableLocales = ['en-us', 'de-de']; + it("returns null when other locale mapping file is missing", async () => { + state.availableLocales = ["en-us", "de-de"]; // No mapping file written for de-de const result = await findPageInOtherLocale({ sourcePageID: 42, - locale: 'en-us', + locale: "en-us", sourceGuid: SOURCE_GUID, targetGuid: TARGET_GUID, }); @@ -111,73 +111,73 @@ describe('findPageInOtherLocale — no mapping in other locales', () => { // ─── mapping found in other locale ─────────────────────────────────────────── -describe('findPageInOtherLocale — mapping found in other locale', () => { - it('returns the target page ID and locale when mapping exists in another locale', async () => { - state.availableLocales = ['en-us', 'fr-fr']; - writeMappingFile(SOURCE_GUID, TARGET_GUID, 'fr-fr', [makeMapping(10, 20)]); +describe("findPageInOtherLocale — mapping found in other locale", () => { + it("returns the target page ID and locale when mapping exists in another locale", async () => { + state.availableLocales = ["en-us", "fr-fr"]; + writeMappingFile(SOURCE_GUID, TARGET_GUID, "fr-fr", [makeMapping(10, 20)]); const result = await findPageInOtherLocale({ sourcePageID: 10, - locale: 'en-us', + locale: "en-us", sourceGuid: SOURCE_GUID, targetGuid: TARGET_GUID, }); expect(result).not.toBeNull(); expect(result!.PageIDOtherLanguage).toBe(20); - expect(result!.OtherLanguageCode).toBe('fr-fr'); + expect(result!.OtherLanguageCode).toBe("fr-fr"); }); - it('stops searching after the first successful match', async () => { - state.availableLocales = ['en-us', 'fr-fr', 'de-de']; - writeMappingFile(SOURCE_GUID, TARGET_GUID, 'fr-fr', [makeMapping(5, 50)]); - writeMappingFile(SOURCE_GUID, TARGET_GUID, 'de-de', [makeMapping(5, 55)]); + it("stops searching after the first successful match", async () => { + state.availableLocales = ["en-us", "fr-fr", "de-de"]; + writeMappingFile(SOURCE_GUID, TARGET_GUID, "fr-fr", [makeMapping(5, 50)]); + writeMappingFile(SOURCE_GUID, TARGET_GUID, "de-de", [makeMapping(5, 55)]); const result = await findPageInOtherLocale({ sourcePageID: 5, - locale: 'en-us', + locale: "en-us", sourceGuid: SOURCE_GUID, targetGuid: TARGET_GUID, }); // Should return the first match (fr-fr), not de-de expect(result).not.toBeNull(); - expect(result!.OtherLanguageCode).toBe('fr-fr'); + expect(result!.OtherLanguageCode).toBe("fr-fr"); expect(result!.PageIDOtherLanguage).toBe(50); }); - it('skips the current locale and finds mapping in later locale', async () => { - state.availableLocales = ['en-us', 'fr-fr']; + it("skips the current locale and finds mapping in later locale", async () => { + state.availableLocales = ["en-us", "fr-fr"]; // en-us is the current locale — should be skipped; fr-fr should be found - writeMappingFile(SOURCE_GUID, TARGET_GUID, 'fr-fr', [makeMapping(7, 77)]); + writeMappingFile(SOURCE_GUID, TARGET_GUID, "fr-fr", [makeMapping(7, 77)]); const result = await findPageInOtherLocale({ sourcePageID: 7, - locale: 'en-us', + locale: "en-us", sourceGuid: SOURCE_GUID, targetGuid: TARGET_GUID, }); expect(result).not.toBeNull(); - expect(result!.OtherLanguageCode).toBe('fr-fr'); + expect(result!.OtherLanguageCode).toBe("fr-fr"); }); }); // ─── error handling ─────────────────────────────────────────────────────────── -describe('findPageInOtherLocale — error handling', () => { - it('logs an error and returns null when getPageMappingByPageID throws inside the try block', async () => { - state.availableLocales = ['en-us', 'fr-fr']; +describe("findPageInOtherLocale — error handling", () => { + it("logs an error and returns null when getPageMappingByPageID throws inside the try block", async () => { + state.availableLocales = ["en-us", "fr-fr"]; // Corrupt the already-loaded PageMapper so getPageMappingByPageID throws. // We do this by mocking PageMapper entirely for this test. - const { PageMapper } = require('lib/mappers/page-mapper'); + const { PageMapper } = require("lib/mappers/page-mapper"); const originalImplementation = PageMapper; - jest.doMock('lib/mappers/page-mapper', () => ({ + jest.doMock("lib/mappers/page-mapper", () => ({ PageMapper: jest.fn().mockImplementation(() => ({ getPageMappingByPageID: jest.fn().mockImplementation(() => { - throw new Error('lookup error'); + throw new Error("lookup error"); }), })), })); @@ -188,20 +188,21 @@ describe('findPageInOtherLocale — error handling', () => { // We'll trigger this by spying on console.error instead. // Restore original implementation - jest.dontMock('lib/mappers/page-mapper'); + jest.dontMock("lib/mappers/page-mapper"); // Simpler approach: test that when getPageMappingByPageID throws, console.error is called. // Since PageMapper constructor is outside the try block, errors there propagate up. // Errors inside the try block (from getPageMappingByPageID) are caught and logged. // We validate the catch path via a spy on the real PageMapper prototype. - const { PageMapper: RealPageMapper } = require('lib/mappers/page-mapper'); - const spy = jest.spyOn(RealPageMapper.prototype, 'getPageMappingByPageID') - .mockImplementation(() => { throw new Error('lookup error'); }); - const consoleSpy = jest.spyOn(console, 'error'); + const { PageMapper: RealPageMapper } = require("lib/mappers/page-mapper"); + const spy = jest.spyOn(RealPageMapper.prototype, "getPageMappingByPageID").mockImplementation(() => { + throw new Error("lookup error"); + }); + const consoleSpy = jest.spyOn(console, "error"); const result = await findPageInOtherLocale({ sourcePageID: 99, - locale: 'en-us', + locale: "en-us", sourceGuid: SOURCE_GUID, targetGuid: TARGET_GUID, }); diff --git a/src/lib/pushers/page-pusher/tests/process-page.test.ts b/src/lib/pushers/page-pusher/tests/process-page.test.ts index 321b3f6..3eb1887 100644 --- a/src/lib/pushers/page-pusher/tests/process-page.test.ts +++ b/src/lib/pushers/page-pusher/tests/process-page.test.ts @@ -1,21 +1,21 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, setState } from 'core/state'; -import { processPage } from '../process-page'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState } from "core/state"; +import { processPage } from "../process-page"; // Mock all modules that make real network or disk calls from within processPage -jest.mock('../find-page-in-other-locale', () => ({ +jest.mock("../find-page-in-other-locale", () => ({ findPageInOtherLocale: jest.fn().mockResolvedValue(null), })); -jest.mock('lib/pushers/batch-polling', () => ({ +jest.mock("lib/pushers/batch-polling", () => ({ pollBatchUntilComplete: jest.fn(), extractPageBatchResults: jest.fn(), })); -import { findPageInOtherLocale } from '../find-page-in-other-locale'; -import { pollBatchUntilComplete, extractPageBatchResults } from 'lib/pushers/batch-polling'; +import { findPageInOtherLocale } from "../find-page-in-other-locale"; +import { pollBatchUntilComplete, extractPageBatchResults } from "lib/pushers/batch-polling"; const mockFindInOtherLocale = findPageInOtherLocale as jest.Mock; const mockPoll = pollBatchUntilComplete as jest.Mock; @@ -24,7 +24,7 @@ const mockExtract = extractPageBatchResults as jest.Mock; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-pp-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-pp-")); }); afterAll(() => { @@ -33,10 +33,10 @@ afterAll(() => { beforeEach(() => { resetState(); - setState({ rootPath: tmpDir, sourceGuid: ['src'], targetGuid: ['tgt'] }); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + setState({ rootPath: tmpDir, sourceGuid: ["src"], targetGuid: ["tgt"] }); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); mockFindInOtherLocale.mockResolvedValue(null); mockPoll.mockResolvedValue({ failedItems: [], successItems: [] }); mockExtract.mockReturnValue({ successfulItems: [], failedItems: [] }); @@ -51,14 +51,14 @@ afterEach(() => { function makePage(overrides: Partial = {}): any { return { pageID: 1, - name: 'Test Page', - pageType: 'static', - templateName: 'MainTemplate', - title: 'Test Page Title', - menuText: 'Test', + name: "Test Page", + pageType: "static", + templateName: "MainTemplate", + title: "Test Page Title", + menuText: "Test", zones: {}, properties: { state: 2, versionID: 10 }, - path: '/test', + path: "/test", ...overrides, }; } @@ -77,12 +77,12 @@ function makePageMapper(overrides: Partial = {}): any { function makeTemplateMapper(): any { return { - getTemplateMappingByPageTemplateName: jest.fn().mockReturnValue({ ref: 'Main' }), + getTemplateMappingByPageTemplateName: jest.fn().mockReturnValue({ ref: "Main" }), getMappedEntity: jest.fn().mockReturnValue({ contentSectionDefinitions: [] }), }; } -function makeApiClient(sitemap: any[] = [{ name: 'website', digitalChannelID: 1 }]): any { +function makeApiClient(sitemap: any[] = [{ name: "website", digitalChannelID: 1 }]): any { return { pageMethods: { getSitemap: jest.fn().mockResolvedValue(sitemap), @@ -104,11 +104,11 @@ function makeLogger(): any { function makeProps(overrides: Partial = {}): any { return { - channel: 'website', + channel: "website", page: makePage(), - sourceGuid: 'src', - targetGuid: 'tgt', - locale: 'en-us', + sourceGuid: "src", + targetGuid: "tgt", + locale: "en-us", apiClient: makeApiClient(), overwrite: false, insertBeforePageId: null, @@ -120,14 +120,14 @@ function makeProps(overrides: Partial = {}): any { } // Mock TemplateMapper and ContentItemMapper at the module level -jest.mock('lib/mappers/template-mapper', () => ({ +jest.mock("lib/mappers/template-mapper", () => ({ TemplateMapper: jest.fn().mockImplementation(() => ({ - getTemplateMappingByPageTemplateName: jest.fn().mockReturnValue({ ref: 'Main' }), + getTemplateMappingByPageTemplateName: jest.fn().mockReturnValue({ ref: "Main" }), getMappedEntity: jest.fn().mockReturnValue({ contentSectionDefinitions: [] }), })), })); -jest.mock('lib/mappers/content-item-mapper', () => ({ +jest.mock("lib/mappers/content-item-mapper", () => ({ ContentItemMapper: jest.fn().mockImplementation(() => ({ getContentItemMappingByContentID: jest.fn().mockReturnValue(null), })), @@ -135,26 +135,26 @@ jest.mock('lib/mappers/content-item-mapper', () => ({ // ─── guard: missing template ────────────────────────────────────────────────── -describe('processPage — missing template', () => { - it('returns skip when template mapping is not found', async () => { - const { TemplateMapper } = require('lib/mappers/template-mapper'); +describe("processPage — missing template", () => { + it("returns skip when template mapping is not found", async () => { + const { TemplateMapper } = require("lib/mappers/template-mapper"); TemplateMapper.mockImplementation(() => ({ getTemplateMappingByPageTemplateName: jest.fn().mockReturnValue(null), getMappedEntity: jest.fn().mockReturnValue(null), })); const result = await processPage(makeProps()); - expect(result.status).toBe('skip'); + expect(result.status).toBe("skip"); }); }); // ─── guard: up-to-date page (no change) ─────────────────────────────────────── -describe('processPage — up-to-date page', () => { - it('returns skip when source has not changed and page exists in target', async () => { - const { TemplateMapper } = require('lib/mappers/template-mapper'); +describe("processPage — up-to-date page", () => { + it("returns skip when source has not changed and page exists in target", async () => { + const { TemplateMapper } = require("lib/mappers/template-mapper"); TemplateMapper.mockImplementation(() => ({ - getTemplateMappingByPageTemplateName: jest.fn().mockReturnValue({ ref: 'Main' }), + getTemplateMappingByPageTemplateName: jest.fn().mockReturnValue({ ref: "Main" }), getMappedEntity: jest.fn().mockReturnValue({ contentSectionDefinitions: [] }), })); @@ -167,17 +167,17 @@ describe('processPage — up-to-date page', () => { }); const result = await processPage(makeProps({ pageMapper, overwrite: false })); - expect(result.status).toBe('skip'); + expect(result.status).toBe("skip"); }); }); // ─── guard: conflict without overwrite ──────────────────────────────────────── -describe('processPage — conflict detection', () => { - it('returns skip when conflict detected and overwrite is false', async () => { - const { TemplateMapper } = require('lib/mappers/template-mapper'); +describe("processPage — conflict detection", () => { + it("returns skip when conflict detected and overwrite is false", async () => { + const { TemplateMapper } = require("lib/mappers/template-mapper"); TemplateMapper.mockImplementation(() => ({ - getTemplateMappingByPageTemplateName: jest.fn().mockReturnValue({ ref: 'Main' }), + getTemplateMappingByPageTemplateName: jest.fn().mockReturnValue({ ref: "Main" }), getMappedEntity: jest.fn().mockReturnValue({ contentSectionDefinitions: [] }), })); @@ -187,17 +187,17 @@ describe('processPage — conflict detection', () => { getMappedEntity: jest.fn().mockReturnValue(existingTargetPage), hasSourceChanged: jest.fn().mockReturnValue(true), // Non-null from hasTargetChanged means conflict - hasTargetChanged: jest.fn().mockReturnValue('changed'), + hasTargetChanged: jest.fn().mockReturnValue("changed"), }); const result = await processPage(makeProps({ pageMapper, overwrite: false })); - expect(result.status).toBe('skip'); + expect(result.status).toBe("skip"); }); - it('continues (not skip) when conflict exists but overwrite is true', async () => { - const { TemplateMapper } = require('lib/mappers/template-mapper'); + it("continues (not skip) when conflict exists but overwrite is true", async () => { + const { TemplateMapper } = require("lib/mappers/template-mapper"); TemplateMapper.mockImplementation(() => ({ - getTemplateMappingByPageTemplateName: jest.fn().mockReturnValue({ ref: 'Main' }), + getTemplateMappingByPageTemplateName: jest.fn().mockReturnValue({ ref: "Main" }), getMappedEntity: jest.fn().mockReturnValue({ contentSectionDefinitions: [] }), })); @@ -206,7 +206,7 @@ describe('processPage — conflict detection', () => { getPageMapping: jest.fn().mockReturnValue({ targetPageID: 99, sourcePageID: 1 }), getMappedEntity: jest.fn().mockReturnValue(existingTargetPage), hasSourceChanged: jest.fn().mockReturnValue(true), - hasTargetChanged: jest.fn().mockReturnValue('changed'), + hasTargetChanged: jest.fn().mockReturnValue("changed"), }); // With overwrite=true, processPage will proceed to the API call @@ -216,21 +216,21 @@ describe('processPage — conflict detection', () => { const result = await processPage(makeProps({ pageMapper, overwrite: true })); // Should not skip — proceeds to API path (may succeed or fail, but not "skip") - expect(result.status).not.toBe('skip'); + expect(result.status).not.toBe("skip"); }); }); // ─── folder pages (no template required) ────────────────────────────────────── -describe('processPage — folder pages', () => { - it('does not require a template for folder pages', async () => { - const { TemplateMapper } = require('lib/mappers/template-mapper'); +describe("processPage — folder pages", () => { + it("does not require a template for folder pages", async () => { + const { TemplateMapper } = require("lib/mappers/template-mapper"); TemplateMapper.mockImplementation(() => ({ getTemplateMappingByPageTemplateName: jest.fn().mockReturnValue(null), getMappedEntity: jest.fn().mockReturnValue(null), })); - const folderPage = makePage({ pageType: 'folder', templateName: '' }); + const folderPage = makePage({ pageType: "folder", templateName: "" }); const pageMapper = makePageMapper({ hasSourceChanged: jest.fn().mockReturnValue(true), }); @@ -240,17 +240,17 @@ describe('processPage — folder pages', () => { const result = await processPage(makeProps({ page: folderPage, pageMapper })); // Folder pages skip the template lookup, so they reach the API path - expect(result.status).not.toBe('skip'); + expect(result.status).not.toBe("skip"); }); }); // ─── successful save via batch ───────────────────────────────────────────────── -describe('processPage — successful batch save', () => { - it('returns success when batch completes with a valid page ID', async () => { - const { TemplateMapper } = require('lib/mappers/template-mapper'); +describe("processPage — successful batch save", () => { + it("returns success when batch completes with a valid page ID", async () => { + const { TemplateMapper } = require("lib/mappers/template-mapper"); TemplateMapper.mockImplementation(() => ({ - getTemplateMappingByPageTemplateName: jest.fn().mockReturnValue({ ref: 'Main' }), + getTemplateMappingByPageTemplateName: jest.fn().mockReturnValue({ ref: "Main" }), getMappedEntity: jest.fn().mockReturnValue({ contentSectionDefinitions: [] }), })); @@ -266,13 +266,13 @@ describe('processPage — successful batch save', () => { }); const result = await processPage(makeProps({ pageMapper })); - expect(result.status).toBe('success'); + expect(result.status).toBe("success"); }); - it('calls pageMapper.addMapping after a successful save', async () => { - const { TemplateMapper } = require('lib/mappers/template-mapper'); + it("calls pageMapper.addMapping after a successful save", async () => { + const { TemplateMapper } = require("lib/mappers/template-mapper"); TemplateMapper.mockImplementation(() => ({ - getTemplateMappingByPageTemplateName: jest.fn().mockReturnValue({ ref: 'Main' }), + getTemplateMappingByPageTemplateName: jest.fn().mockReturnValue({ ref: "Main" }), getMappedEntity: jest.fn().mockReturnValue({ contentSectionDefinitions: [] }), })); @@ -293,11 +293,11 @@ describe('processPage — successful batch save', () => { // ─── failure paths ──────────────────────────────────────────────────────────── -describe('processPage — failure paths', () => { - it('returns failure when batch completes with actualPageID <= 0', async () => { - const { TemplateMapper } = require('lib/mappers/template-mapper'); +describe("processPage — failure paths", () => { + it("returns failure when batch completes with actualPageID <= 0", async () => { + const { TemplateMapper } = require("lib/mappers/template-mapper"); TemplateMapper.mockImplementation(() => ({ - getTemplateMappingByPageTemplateName: jest.fn().mockReturnValue({ ref: 'Main' }), + getTemplateMappingByPageTemplateName: jest.fn().mockReturnValue({ ref: "Main" }), getMappedEntity: jest.fn().mockReturnValue({ contentSectionDefinitions: [] }), })); @@ -305,17 +305,17 @@ describe('processPage — failure paths', () => { hasSourceChanged: jest.fn().mockReturnValue(true), }); - mockPoll.mockResolvedValue({ failedItems: [], errorData: '' }); + mockPoll.mockResolvedValue({ failedItems: [], errorData: "" }); mockExtract.mockReturnValue({ successfulItems: [], failedItems: [] }); const result = await processPage(makeProps({ pageMapper })); - expect(result.status).toBe('failure'); + expect(result.status).toBe("failure"); }); - it('returns failure when apiClient.pageMethods.savePage throws', async () => { - const { TemplateMapper } = require('lib/mappers/template-mapper'); + it("returns failure when apiClient.pageMethods.savePage throws", async () => { + const { TemplateMapper } = require("lib/mappers/template-mapper"); TemplateMapper.mockImplementation(() => ({ - getTemplateMappingByPageTemplateName: jest.fn().mockReturnValue({ ref: 'Main' }), + getTemplateMappingByPageTemplateName: jest.fn().mockReturnValue({ ref: "Main" }), getMappedEntity: jest.fn().mockReturnValue({ contentSectionDefinitions: [] }), })); @@ -324,17 +324,17 @@ describe('processPage — failure paths', () => { }); const apiClient = makeApiClient(); - apiClient.pageMethods.savePage = jest.fn().mockRejectedValue(new Error('network error')); + apiClient.pageMethods.savePage = jest.fn().mockRejectedValue(new Error("network error")); const result = await processPage(makeProps({ apiClient, pageMapper })); - expect(result.status).toBe('failure'); - expect(result.error).toContain('network error'); + expect(result.status).toBe("failure"); + expect(result.error).toContain("network error"); }); - it('returns failure with unexpected response format', async () => { - const { TemplateMapper } = require('lib/mappers/template-mapper'); + it("returns failure with unexpected response format", async () => { + const { TemplateMapper } = require("lib/mappers/template-mapper"); TemplateMapper.mockImplementation(() => ({ - getTemplateMappingByPageTemplateName: jest.fn().mockReturnValue({ ref: 'Main' }), + getTemplateMappingByPageTemplateName: jest.fn().mockReturnValue({ ref: "Main" }), getMappedEntity: jest.fn().mockReturnValue({ contentSectionDefinitions: [] }), })); @@ -347,26 +347,24 @@ describe('processPage — failure paths', () => { apiClient.pageMethods.savePage = jest.fn().mockResolvedValue([]); const result = await processPage(makeProps({ apiClient, pageMapper })); - expect(result.status).toBe('failure'); + expect(result.status).toBe("failure"); }); }); // ─── missing content mapping ────────────────────────────────────────────────── -describe('processPage — missing content mappings', () => { - it('returns failure when a zone module has no content mapping', async () => { - const { TemplateMapper } = require('lib/mappers/template-mapper'); +describe("processPage — missing content mappings", () => { + it("returns failure when a zone module has no content mapping", async () => { + const { TemplateMapper } = require("lib/mappers/template-mapper"); // Template with a section definition so the zone name is mapped through correctly TemplateMapper.mockImplementation(() => ({ - getTemplateMappingByPageTemplateName: jest.fn().mockReturnValue({ ref: 'Main' }), + getTemplateMappingByPageTemplateName: jest.fn().mockReturnValue({ ref: "Main" }), getMappedEntity: jest.fn().mockReturnValue({ - contentSectionDefinitions: [ - { pageItemTemplateReferenceName: 'Main', itemOrder: 0 }, - ], + contentSectionDefinitions: [{ pageItemTemplateReferenceName: "Main", itemOrder: 0 }], }), })); - const { ContentItemMapper } = require('lib/mappers/content-item-mapper'); + const { ContentItemMapper } = require("lib/mappers/content-item-mapper"); ContentItemMapper.mockImplementation(() => ({ getContentItemMappingByContentID: jest.fn().mockReturnValue(null), })); @@ -374,7 +372,7 @@ describe('processPage — missing content mappings', () => { const pageWithContent = makePage({ zones: { // Zone name matches section definition so translateZoneNames keeps it - Main: [{ module: 'Hero', item: { contentid: 55 } }], + Main: [{ module: "Hero", item: { contentid: 55 } }], }, }); @@ -383,7 +381,7 @@ describe('processPage — missing content mappings', () => { }); const result = await processPage(makeProps({ page: pageWithContent, pageMapper })); - expect(result.status).toBe('failure'); + expect(result.status).toBe("failure"); // Could be "missing content mappings" or "Lost all N modules" depending on code path expect(result.error).toBeTruthy(); }); @@ -391,23 +389,26 @@ describe('processPage — missing content mappings', () => { // ─── channel fallback ───────────────────────────────────────────────────────── -describe('processPage — channel resolution', () => { - it('uses first channel digitalChannelID as fallback when channel name not found', async () => { - const { TemplateMapper } = require('lib/mappers/template-mapper'); +describe("processPage — channel resolution", () => { + it("uses first channel digitalChannelID as fallback when channel name not found", async () => { + const { TemplateMapper } = require("lib/mappers/template-mapper"); TemplateMapper.mockImplementation(() => ({ - getTemplateMappingByPageTemplateName: jest.fn().mockReturnValue({ ref: 'Main' }), + getTemplateMappingByPageTemplateName: jest.fn().mockReturnValue({ ref: "Main" }), getMappedEntity: jest.fn().mockReturnValue({ contentSectionDefinitions: [] }), })); const pageMapper = makePageMapper({ hasSourceChanged: jest.fn().mockReturnValue(true) }); // Sitemap has a different channel name - const apiClient = makeApiClient([{ name: 'other-channel', digitalChannelID: 42 }]); + const apiClient = makeApiClient([{ name: "other-channel", digitalChannelID: 42 }]); mockPoll.mockResolvedValue({ failedItems: [] }); - mockExtract.mockReturnValue({ successfulItems: [{ newId: 300, newItem: { processedItemVersionID: 1 } }], failedItems: [] }); + mockExtract.mockReturnValue({ + successfulItems: [{ newId: 300, newItem: { processedItemVersionID: 1 } }], + failedItems: [], + }); - const result = await processPage(makeProps({ apiClient, pageMapper, channel: 'website' })); + const result = await processPage(makeProps({ apiClient, pageMapper, channel: "website" })); // Should proceed (uses fallback channelID=42) — result is success or failure but not an early return - expect(['success', 'failure', 'skip']).toContain(result.status); + expect(["success", "failure", "skip"]).toContain(result.status); }); }); diff --git a/src/lib/pushers/page-pusher/tests/process-sitemap.test.ts b/src/lib/pushers/page-pusher/tests/process-sitemap.test.ts index e76ce74..f61d8da 100644 --- a/src/lib/pushers/page-pusher/tests/process-sitemap.test.ts +++ b/src/lib/pushers/page-pusher/tests/process-sitemap.test.ts @@ -1,23 +1,23 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, setState } from 'core/state'; -import { processSitemap, resetProcessedPageIDs } from '../process-sitemap'; -import { SitemapNode } from 'types/syncAnalysis'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState } from "core/state"; +import { processSitemap, resetProcessedPageIDs } from "../process-sitemap"; +import { SitemapNode } from "types/syncAnalysis"; // Mock processPage — it makes real API calls -jest.mock('../process-page', () => ({ +jest.mock("../process-page", () => ({ processPage: jest.fn(), })); -import { processPage } from '../process-page'; +import { processPage } from "../process-page"; const mockProcessPage = processPage as jest.Mock; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-pstm-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-pstm-")); }); afterAll(() => { @@ -26,13 +26,13 @@ afterAll(() => { beforeEach(() => { resetState(); - setState({ rootPath: tmpDir, sourceGuid: 'src', targetGuid: 'tgt' }); + setState({ rootPath: tmpDir, sourceGuid: "src", targetGuid: "tgt" }); resetProcessedPageIDs(); mockProcessPage.mockClear(); - mockProcessPage.mockResolvedValue({ status: 'success' }); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + mockProcessPage.mockResolvedValue({ status: "success" }); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -46,7 +46,7 @@ function makeNode(pageID: number, children: SitemapNode[] = []): SitemapNode { title: null, name: `page-${pageID}`, pageID, - menuText: '', + menuText: "", visible: { menu: true, sitemap: true }, path: `/${pageID}`, redirect: null, @@ -59,7 +59,7 @@ function makePage(pageID: number, state = 2): any { return { pageID, name: `Page ${pageID}`, - pageType: 'static', + pageType: "static", properties: { state, versionID: 1 }, zones: {}, }; @@ -98,12 +98,12 @@ function makeLogger(): any { function makeProps(overrides: Partial = {}): any { return { - channel: 'website', + channel: "website", pageMapper: makePageMapper(), sitemapNodes: [], - sourceGuid: 'src', - targetGuid: 'tgt', - locale: 'en-us', + sourceGuid: "src", + targetGuid: "tgt", + locale: "en-us", apiClient: makeApiClient(), overwrite: false, sourcePages: [], @@ -115,8 +115,8 @@ function makeProps(overrides: Partial = {}): any { // ─── empty sitemap ──────────────────────────────────────────────────────────── -describe('processSitemap — empty sitemapNodes', () => { - it('returns zero counts for all result fields', async () => { +describe("processSitemap — empty sitemapNodes", () => { + it("returns zero counts for all result fields", async () => { const result = await processSitemap(makeProps({ sitemapNodes: [] })); expect(result.successful).toBe(0); expect(result.failed).toBe(0); @@ -125,7 +125,7 @@ describe('processSitemap — empty sitemapNodes', () => { expect(result.failureDetails).toHaveLength(0); }); - it('does not call processPage when there are no sitemap nodes', async () => { + it("does not call processPage when there are no sitemap nodes", async () => { await processSitemap(makeProps({ sitemapNodes: [] })); expect(mockProcessPage).not.toHaveBeenCalled(); }); @@ -133,16 +133,16 @@ describe('processSitemap — empty sitemapNodes', () => { // ─── missing source page ────────────────────────────────────────────────────── -describe('processSitemap — missing source page', () => { - it('increments failed when a node has no matching source page', async () => { +describe("processSitemap — missing source page", () => { + it("increments failed when a node has no matching source page", async () => { const nodes = [makeNode(42)]; const result = await processSitemap(makeProps({ sitemapNodes: nodes, sourcePages: [] })); expect(result.failed).toBe(1); expect(result.failureDetails).toHaveLength(1); - expect(result.failureDetails[0].name).toContain('42'); + expect(result.failureDetails[0].name).toContain("42"); }); - it('logs the error via logger.page.error when source page is missing', async () => { + it("logs the error via logger.page.error when source page is missing", async () => { const logger = makeLogger(); const nodes = [makeNode(99)]; await processSitemap(makeProps({ sitemapNodes: nodes, sourcePages: [], logger })); @@ -152,17 +152,17 @@ describe('processSitemap — missing source page', () => { // ─── successful processing ──────────────────────────────────────────────────── -describe('processSitemap — successful page processing', () => { - it('increments successful count on processPage success', async () => { - mockProcessPage.mockResolvedValue({ status: 'success' }); +describe("processSitemap — successful page processing", () => { + it("increments successful count on processPage success", async () => { + mockProcessPage.mockResolvedValue({ status: "success" }); const nodes = [makeNode(1)]; const pages = [makePage(1, 2)]; const result = await processSitemap(makeProps({ sitemapNodes: nodes, sourcePages: pages })); expect(result.successful).toBe(1); }); - it('adds pageID to publishableIds when source page state is 2', async () => { - mockProcessPage.mockResolvedValue({ status: 'success' }); + it("adds pageID to publishableIds when source page state is 2", async () => { + mockProcessPage.mockResolvedValue({ status: "success" }); const nodes = [makeNode(1)]; const pages = [makePage(1, 2)]; // state=2 = published const pageMapper = makePageMapper(); @@ -171,8 +171,8 @@ describe('processSitemap — successful page processing', () => { expect(result.publishableIds).toContain(555); }); - it('does NOT add to publishableIds when source page state is not 2', async () => { - mockProcessPage.mockResolvedValue({ status: 'success' }); + it("does NOT add to publishableIds when source page state is not 2", async () => { + mockProcessPage.mockResolvedValue({ status: "success" }); const nodes = [makeNode(1)]; const pages = [makePage(1, 1)]; // state=1 = staging const result = await processSitemap(makeProps({ sitemapNodes: nodes, sourcePages: pages })); @@ -180,48 +180,48 @@ describe('processSitemap — successful page processing', () => { }); }); -describe('processSitemap — auto-publish skip log', () => { +describe("processSitemap — auto-publish skip log", () => { it('logs "Skipping auto-publish" for a staging page when state.autoPublish is on', async () => { - setState({ rootPath: tmpDir, sourceGuid: 'src', targetGuid: 'tgt', autoPublish: 'pages' }); - const consoleSpy = jest.spyOn(console, 'log'); - mockProcessPage.mockResolvedValue({ status: 'success' }); + setState({ rootPath: tmpDir, sourceGuid: "src", targetGuid: "tgt", autoPublish: "pages" }); + const consoleSpy = jest.spyOn(console, "log"); + mockProcessPage.mockResolvedValue({ status: "success" }); const nodes = [makeNode(1)]; const pages = [makePage(1, 1)]; await processSitemap(makeProps({ sitemapNodes: nodes, sourcePages: pages })); - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Skipping auto-publish')); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Skipping auto-publish")); }); - it('does not log "Skipping auto-publish" for a staging page when state.autoPublish is off', async () => { - const consoleSpy = jest.spyOn(console, 'log'); - mockProcessPage.mockResolvedValue({ status: 'success' }); + it('does not log "Skipping auto-publish" for a staging page when state.autoPublish is off', async () => { + const consoleSpy = jest.spyOn(console, "log"); + mockProcessPage.mockResolvedValue({ status: "success" }); const nodes = [makeNode(1)]; - const pages = [makePage(1, 1)]; + const pages = [makePage(1, 1)]; await processSitemap(makeProps({ sitemapNodes: nodes, sourcePages: pages })); - expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining('Skipping auto-publish')); + expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining("Skipping auto-publish")); }); it('does not log "Skipping auto-publish" for a published page even when state.autoPublish is on', async () => { - setState({ rootPath: tmpDir, sourceGuid: 'src', targetGuid: 'tgt', autoPublish: 'pages' }); - const consoleSpy = jest.spyOn(console, 'log'); - mockProcessPage.mockResolvedValue({ status: 'success' }); + setState({ rootPath: tmpDir, sourceGuid: "src", targetGuid: "tgt", autoPublish: "pages" }); + const consoleSpy = jest.spyOn(console, "log"); + mockProcessPage.mockResolvedValue({ status: "success" }); const nodes = [makeNode(1)]; const pages = [makePage(1, 2)]; // state=2 = published — takes the isSourcePublished branch await processSitemap(makeProps({ sitemapNodes: nodes, sourcePages: pages })); - expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining('Skipping auto-publish')); + expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining("Skipping auto-publish")); }); }); // ─── skipped processing ─────────────────────────────────────────────────────── -describe('processSitemap — skipped page processing', () => { - it('increments skipped count on processPage skip', async () => { - mockProcessPage.mockResolvedValue({ status: 'skip' }); +describe("processSitemap — skipped page processing", () => { + it("increments skipped count on processPage skip", async () => { + mockProcessPage.mockResolvedValue({ status: "skip" }); const nodes = [makeNode(1)]; const pages = [makePage(1)]; const result = await processSitemap(makeProps({ sitemapNodes: nodes, sourcePages: pages })); @@ -232,30 +232,30 @@ describe('processSitemap — skipped page processing', () => { // ─── failed processing ──────────────────────────────────────────────────────── -describe('processSitemap — failed page processing', () => { - it('increments failed count on processPage failure', async () => { - mockProcessPage.mockResolvedValue({ status: 'failure', error: 'API error' }); +describe("processSitemap — failed page processing", () => { + it("increments failed count on processPage failure", async () => { + mockProcessPage.mockResolvedValue({ status: "failure", error: "API error" }); const nodes = [makeNode(1)]; const pages = [makePage(1)]; const result = await processSitemap(makeProps({ sitemapNodes: nodes, sourcePages: pages })); expect(result.failed).toBe(1); expect(result.failureDetails).toHaveLength(1); - expect(result.failureDetails[0].error).toBe('API error'); + expect(result.failureDetails[0].error).toBe("API error"); }); - it('records page name in failureDetails', async () => { - mockProcessPage.mockResolvedValue({ status: 'failure', error: 'boom' }); + it("records page name in failureDetails", async () => { + mockProcessPage.mockResolvedValue({ status: "failure", error: "boom" }); const nodes = [makeNode(5)]; const pages = [makePage(5)]; const result = await processSitemap(makeProps({ sitemapNodes: nodes, sourcePages: pages })); - expect(result.failureDetails[0].name).toContain('Page 5'); + expect(result.failureDetails[0].name).toContain("Page 5"); }); }); // ─── duplicate pageID prevention ────────────────────────────────────────────── -describe('processSitemap — duplicate pageID prevention', () => { - it('processes a pageID only once even if it appears twice in the sitemap', async () => { +describe("processSitemap — duplicate pageID prevention", () => { + it("processes a pageID only once even if it appears twice in the sitemap", async () => { // Dynamic pages can appear twice (same pageID, different contentID) const nodes = [makeNode(7), makeNode(7)]; const pages = [makePage(7)]; @@ -266,8 +266,8 @@ describe('processSitemap — duplicate pageID prevention', () => { // ─── recursive children ─────────────────────────────────────────────────────── -describe('processSitemap — recursive child processing', () => { - it('processes child pages of a parent node', async () => { +describe("processSitemap — recursive child processing", () => { + it("processes child pages of a parent node", async () => { const child = makeNode(2); const parent = makeNode(1, [child]); const pages = [makePage(1), makePage(2)]; @@ -275,8 +275,8 @@ describe('processSitemap — recursive child processing', () => { expect(mockProcessPage).toHaveBeenCalledTimes(2); }); - it('aggregates counts from children into the parent result', async () => { - mockProcessPage.mockResolvedValue({ status: 'success' }); + it("aggregates counts from children into the parent result", async () => { + mockProcessPage.mockResolvedValue({ status: "success" }); const child = makeNode(2); const parent = makeNode(1, [child]); const pages = [makePage(1, 2), makePage(2, 2)]; @@ -287,9 +287,9 @@ describe('processSitemap — recursive child processing', () => { // ─── publishableIds deduplication ───────────────────────────────────────────── -describe('processSitemap — publishableIds deduplication', () => { - it('deduplicates publishableIds in the returned result', async () => { - mockProcessPage.mockResolvedValue({ status: 'success' }); +describe("processSitemap — publishableIds deduplication", () => { + it("deduplicates publishableIds in the returned result", async () => { + mockProcessPage.mockResolvedValue({ status: "success" }); // Make getPageMappingByPageID always return the same targetPageID to simulate a duplicate const pageMapper = makePageMapper(); pageMapper.getPageMappingByPageID.mockReturnValue({ targetPageID: 42 }); @@ -306,9 +306,9 @@ describe('processSitemap — publishableIds deduplication', () => { // ─── resetProcessedPageIDs ──────────────────────────────────────────────────── -describe('resetProcessedPageIDs', () => { - it('allows re-processing of a pageID after reset', async () => { - mockProcessPage.mockResolvedValue({ status: 'success' }); +describe("resetProcessedPageIDs", () => { + it("allows re-processing of a pageID after reset", async () => { + mockProcessPage.mockResolvedValue({ status: "success" }); const nodes = [makeNode(1)]; const pages = [makePage(1)]; diff --git a/src/lib/pushers/page-pusher/tests/push-pages.test.ts b/src/lib/pushers/page-pusher/tests/push-pages.test.ts index f85fec7..3c9cfff 100644 --- a/src/lib/pushers/page-pusher/tests/push-pages.test.ts +++ b/src/lib/pushers/page-pusher/tests/push-pages.test.ts @@ -1,16 +1,16 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, setState } from 'core/state'; -import { pushPages } from '../push-pages'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState } from "core/state"; +import { pushPages } from "../push-pages"; // Mock processSitemap — it makes real API calls -jest.mock('../process-sitemap', () => ({ +jest.mock("../process-sitemap", () => ({ processSitemap: jest.fn(), resetProcessedPageIDs: jest.fn(), })); -import { processSitemap, resetProcessedPageIDs } from '../process-sitemap'; +import { processSitemap, resetProcessedPageIDs } from "../process-sitemap"; const mockProcessSitemap = processSitemap as jest.Mock; const mockResetProcessedPageIDs = resetProcessedPageIDs as jest.Mock; @@ -19,7 +19,7 @@ let tmpDir: string; let localeCounter = 0; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-pp2-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-pp2-")); }); afterAll(() => { @@ -30,14 +30,14 @@ beforeEach(() => { resetState(); setState({ rootPath: tmpDir, - sourceGuid: 'src-guid', - targetGuid: 'tgt-guid', - token: 'test-token', + sourceGuid: "src-guid", + targetGuid: "tgt-guid", + token: "test-token", overwrite: false, }); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); mockProcessSitemap.mockClear(); mockResetProcessedPageIDs.mockClear(); mockProcessSitemap.mockResolvedValue({ @@ -60,7 +60,7 @@ function makePage(pageID: number): any { return { pageID, name: `Page ${pageID}`, - pageType: 'static', + pageType: "static", properties: { state: 2, versionID: 1 }, zones: {}, }; @@ -72,7 +72,7 @@ function uniqueLocale(): string { } function writeSitemapFile(guid: string, locale: string, channel: string, nodes: any[]): void { - const dir = path.join(tmpDir, guid, locale, 'nestedsitemap'); + const dir = path.join(tmpDir, guid, locale, "nestedsitemap"); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, `${channel}.json`), JSON.stringify(nodes)); } @@ -82,7 +82,7 @@ function makeSitemapNode(pageID: number): any { title: null, name: `page-${pageID}`, pageID, - menuText: '', + menuText: "", visible: { menu: true, sitemap: true }, path: `/${pageID}`, redirect: null, @@ -93,22 +93,22 @@ function makeSitemapNode(pageID: number): any { // ─── empty pages ────────────────────────────────────────────────────────────── -describe('pushPages — empty pages', () => { - it('returns success with zero counts when pages array is empty', async () => { +describe("pushPages — empty pages", () => { + it("returns success with zero counts when pages array is empty", async () => { const result = await pushPages([], uniqueLocale()); - expect(result.status).toBe('success'); + expect(result.status).toBe("success"); expect(result.successful).toBe(0); expect(result.failed).toBe(0); expect(result.skipped).toBe(0); }); - it('returns success with zero counts when pages is null', async () => { + it("returns success with zero counts when pages is null", async () => { const result = await pushPages(null as any, uniqueLocale()); - expect(result.status).toBe('success'); + expect(result.status).toBe("success"); expect(result.successful).toBe(0); }); - it('does not call processSitemap when pages is empty', async () => { + it("does not call processSitemap when pages is empty", async () => { await pushPages([], uniqueLocale()); expect(mockProcessSitemap).not.toHaveBeenCalled(); }); @@ -116,157 +116,184 @@ describe('pushPages — empty pages', () => { // ─── no sitemaps ────────────────────────────────────────────────────────────── -describe('pushPages — no sitemaps', () => { - it('returns success but skips processing when no sitemap directory exists', async () => { +describe("pushPages — no sitemaps", () => { + it("returns success but skips processing when no sitemap directory exists", async () => { const pages = [makePage(1)]; const locale = uniqueLocale(); // No sitemap file written — SitemapHierarchy.loadAllSitemaps returns {} const result = await pushPages(pages, locale); - expect(result.status).toBe('success'); + expect(result.status).toBe("success"); expect(mockProcessSitemap).not.toHaveBeenCalled(); }); - it('logs a console.log message mentioning the channel when sitemap is empty', async () => { - const consoleSpy = jest.spyOn(console, 'log'); - const guid = 'src-guid'; + it("logs a console.log message mentioning the channel when sitemap is empty", async () => { + const consoleSpy = jest.spyOn(console, "log"); + const guid = "src-guid"; const locale = uniqueLocale(); // Write an empty JSON array for the channel sitemap - const dir = path.join(tmpDir, guid, locale, 'nestedsitemap'); + const dir = path.join(tmpDir, guid, locale, "nestedsitemap"); fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, 'website.json'), JSON.stringify([])); + fs.writeFileSync(path.join(dir, "website.json"), JSON.stringify([])); const pages = [makePage(1)]; await pushPages(pages, locale); - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('website')); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("website")); }); }); // ─── processSitemap delegation ──────────────────────────────────────────────── -describe('pushPages — processSitemap delegation', () => { - it('calls processSitemap once per channel', async () => { - const guid = 'src-guid'; +describe("pushPages — processSitemap delegation", () => { + it("calls processSitemap once per channel", async () => { + const guid = "src-guid"; const locale = uniqueLocale(); - writeSitemapFile(guid, locale, 'website', [makeSitemapNode(1)]); - writeSitemapFile(guid, locale, 'mobile', [makeSitemapNode(2)]); + writeSitemapFile(guid, locale, "website", [makeSitemapNode(1)]); + writeSitemapFile(guid, locale, "mobile", [makeSitemapNode(2)]); const pages = [makePage(1), makePage(2)]; mockProcessSitemap.mockResolvedValue({ - successful: 1, failed: 0, skipped: 0, publishableIds: [], failureDetails: [], + successful: 1, + failed: 0, + skipped: 0, + publishableIds: [], + failureDetails: [], }); await pushPages(pages, locale); expect(mockProcessSitemap).toHaveBeenCalledTimes(2); }); - it('aggregates successful counts from processSitemap', async () => { - const guid = 'src-guid'; + it("aggregates successful counts from processSitemap", async () => { + const guid = "src-guid"; const locale = uniqueLocale(); - writeSitemapFile(guid, locale, 'website', [makeSitemapNode(1)]); + writeSitemapFile(guid, locale, "website", [makeSitemapNode(1)]); const pages = [makePage(1)]; mockProcessSitemap.mockResolvedValue({ - successful: 3, failed: 0, skipped: 0, publishableIds: [], failureDetails: [], + successful: 3, + failed: 0, + skipped: 0, + publishableIds: [], + failureDetails: [], }); const result = await pushPages(pages, locale); expect(result.successful).toBe(3); }); - it('aggregates failed counts and sets status to error', async () => { - const guid = 'src-guid'; + it("aggregates failed counts and sets status to error", async () => { + const guid = "src-guid"; const locale = uniqueLocale(); - writeSitemapFile(guid, locale, 'website', [makeSitemapNode(1)]); + writeSitemapFile(guid, locale, "website", [makeSitemapNode(1)]); const pages = [makePage(1)]; mockProcessSitemap.mockResolvedValue({ - successful: 0, failed: 2, skipped: 0, publishableIds: [], failureDetails: [ - { name: 'Page 1', error: 'API error', type: 'page', pageID: 1 } - ], + successful: 0, + failed: 2, + skipped: 0, + publishableIds: [], + failureDetails: [{ name: "Page 1", error: "API error", type: "page", pageID: 1 }], }); const result = await pushPages(pages, locale); - expect(result.status).toBe('error'); + expect(result.status).toBe("error"); expect(result.failed).toBe(2); }); - it('aggregates skipped counts', async () => { - const guid = 'src-guid'; + it("aggregates skipped counts", async () => { + const guid = "src-guid"; const locale = uniqueLocale(); - writeSitemapFile(guid, locale, 'website', [makeSitemapNode(1)]); + writeSitemapFile(guid, locale, "website", [makeSitemapNode(1)]); const pages = [makePage(1)]; mockProcessSitemap.mockResolvedValue({ - successful: 0, failed: 0, skipped: 5, publishableIds: [], failureDetails: [], + successful: 0, + failed: 0, + skipped: 5, + publishableIds: [], + failureDetails: [], }); const result = await pushPages(pages, locale); expect(result.skipped).toBe(5); }); - it('merges and deduplicates publishableIds across channels', async () => { - const guid = 'src-guid'; + it("merges and deduplicates publishableIds across channels", async () => { + const guid = "src-guid"; const locale = uniqueLocale(); - writeSitemapFile(guid, locale, 'website', [makeSitemapNode(1)]); - writeSitemapFile(guid, locale, 'mobile', [makeSitemapNode(2)]); + writeSitemapFile(guid, locale, "website", [makeSitemapNode(1)]); + writeSitemapFile(guid, locale, "mobile", [makeSitemapNode(2)]); const pages = [makePage(1), makePage(2)]; // Both channels return the same publishable ID (simulating duplicate) mockProcessSitemap.mockResolvedValue({ - successful: 1, failed: 0, skipped: 0, publishableIds: [42], failureDetails: [], + successful: 1, + failed: 0, + skipped: 0, + publishableIds: [42], + failureDetails: [], }); const result = await pushPages(pages, locale); expect(result.publishableIds).toEqual([42]); // deduplicated }); - it('includes failureDetails from processSitemap in the result', async () => { - const guid = 'src-guid'; + it("includes failureDetails from processSitemap in the result", async () => { + const guid = "src-guid"; const locale = uniqueLocale(); - writeSitemapFile(guid, locale, 'website', [makeSitemapNode(1)]); + writeSitemapFile(guid, locale, "website", [makeSitemapNode(1)]); const pages = [makePage(1)]; mockProcessSitemap.mockResolvedValue({ - successful: 0, failed: 1, skipped: 0, publishableIds: [], - failureDetails: [{ name: 'Page 1', error: 'boom', type: 'page', pageID: 1 }], + successful: 0, + failed: 1, + skipped: 0, + publishableIds: [], + failureDetails: [{ name: "Page 1", error: "boom", type: "page", pageID: 1 }], }); const result = await pushPages(pages, locale); expect(result.failureDetails).toHaveLength(1); - expect(result.failureDetails![0].error).toBe('boom'); + expect(result.failureDetails![0].error).toBe("boom"); }); }); // ─── processSitemap throws ──────────────────────────────────────────────────── -describe('pushPages — processSitemap error handling', () => { - it('sets status to error when processSitemap throws', async () => { - const guid = 'src-guid'; +describe("pushPages — processSitemap error handling", () => { + it("sets status to error when processSitemap throws", async () => { + const guid = "src-guid"; const locale = uniqueLocale(); - writeSitemapFile(guid, locale, 'website', [makeSitemapNode(1)]); + writeSitemapFile(guid, locale, "website", [makeSitemapNode(1)]); const pages = [makePage(1)]; - mockProcessSitemap.mockRejectedValue(new Error('unexpected crash')); + mockProcessSitemap.mockRejectedValue(new Error("unexpected crash")); // push-pages.ts calls logger.page.error in the catch block // getLoggerForGuid returns null after resetState, so we mock the logger registry // to avoid the null dereference — easiest to let it catch-all - const result = await pushPages(pages, locale).catch(() => ({ status: 'error', successful: 0, failed: 0, skipped: 0, failureDetails: [] })); - expect(result.status).toBe('error'); + const result = await pushPages(pages, locale).catch(() => ({ + status: "error", + successful: 0, + failed: 0, + skipped: 0, + failureDetails: [], + })); + expect(result.status).toBe("error"); }); }); // ─── resetProcessedPageIDs is called ───────────────────────────────────────── -describe('pushPages — resetProcessedPageIDs', () => { - it('calls resetProcessedPageIDs before processing', async () => { +describe("pushPages — resetProcessedPageIDs", () => { + it("calls resetProcessedPageIDs before processing", async () => { await pushPages([makePage(1)], uniqueLocale()); expect(mockResetProcessedPageIDs).toHaveBeenCalled(); }); diff --git a/src/lib/pushers/page-pusher/tests/sitemap-hierarchy.test.ts b/src/lib/pushers/page-pusher/tests/sitemap-hierarchy.test.ts index fec7da9..826d403 100644 --- a/src/lib/pushers/page-pusher/tests/sitemap-hierarchy.test.ts +++ b/src/lib/pushers/page-pusher/tests/sitemap-hierarchy.test.ts @@ -1,14 +1,14 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, setState } from 'core/state'; -import { SitemapHierarchy } from '../sitemap-hierarchy'; -import { SitemapNode, PageHierarchy } from 'types/syncAnalysis'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState } from "core/state"; +import { SitemapHierarchy } from "../sitemap-hierarchy"; +import { SitemapNode, PageHierarchy } from "types/syncAnalysis"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-sh-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-sh-")); }); afterAll(() => { @@ -18,9 +18,9 @@ afterAll(() => { beforeEach(() => { resetState(); setState({ rootPath: tmpDir }); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -38,7 +38,7 @@ function makeNode(pageID: number, children: SitemapNode[] = []): SitemapNode { title: null, name: `node-${pageID}`, pageID, - menuText: '', + menuText: "", visible: { menu: true, sitemap: true }, path: `/${pageID}`, redirect: null, @@ -53,28 +53,28 @@ function writeSitemapFile(dir: string, channel: string, nodes: SitemapNode[]): v } function sitemapDir(guid: string, locale: string): string { - return path.join(tmpDir, guid, locale, 'nestedsitemap'); + return path.join(tmpDir, guid, locale, "nestedsitemap"); } // ─── constructor ────────────────────────────────────────────────────────────── -describe('SitemapHierarchy constructor', () => { - it('constructs without throwing', () => { +describe("SitemapHierarchy constructor", () => { + it("constructs without throwing", () => { expect(() => new SitemapHierarchy()).not.toThrow(); }); }); // ─── loadNestedSitemap ──────────────────────────────────────────────────────── -describe('SitemapHierarchy.loadNestedSitemap', () => { - it('returns null when file does not exist', () => { +describe("SitemapHierarchy.loadNestedSitemap", () => { + it("returns null when file does not exist", () => { const sh = new SitemapHierarchy(); - const result = sh.loadNestedSitemap(path.join(tmpDir, 'nonexistent.json')); + const result = sh.loadNestedSitemap(path.join(tmpDir, "nonexistent.json")); expect(result).toBeNull(); }); - it('returns parsed sitemap nodes when file is valid JSON', () => { - const filePath = path.join(tmpDir, 'valid.json'); + it("returns parsed sitemap nodes when file is valid JSON", () => { + const filePath = path.join(tmpDir, "valid.json"); const nodes = [makeNode(1), makeNode(2)]; fs.writeFileSync(filePath, JSON.stringify(nodes)); const sh = new SitemapHierarchy(); @@ -83,9 +83,9 @@ describe('SitemapHierarchy.loadNestedSitemap', () => { expect(result![0].pageID).toBe(1); }); - it('returns null when file contains invalid JSON', () => { - const filePath = path.join(tmpDir, 'invalid.json'); - fs.writeFileSync(filePath, '{not valid json}'); + it("returns null when file contains invalid JSON", () => { + const filePath = path.join(tmpDir, "invalid.json"); + fs.writeFileSync(filePath, "{not valid json}"); const sh = new SitemapHierarchy(); const result = sh.loadNestedSitemap(filePath); expect(result).toBeNull(); @@ -94,60 +94,60 @@ describe('SitemapHierarchy.loadNestedSitemap', () => { // ─── loadAllSitemaps ────────────────────────────────────────────────────────── -describe('SitemapHierarchy.loadAllSitemaps', () => { - it('returns empty object when sitemap directory does not exist', () => { +describe("SitemapHierarchy.loadAllSitemaps", () => { + it("returns empty object when sitemap directory does not exist", () => { const sh = new SitemapHierarchy(); - const result = sh.loadAllSitemaps('no-such-guid', 'en-us'); + const result = sh.loadAllSitemaps("no-such-guid", "en-us"); expect(result).toEqual({}); }); - it('loads all .json files as channels', () => { - const guid = 'guid-load-all'; - const locale = 'en-us'; + it("loads all .json files as channels", () => { + const guid = "guid-load-all"; + const locale = "en-us"; const dir = sitemapDir(guid, locale); - writeSitemapFile(dir, 'website', [makeNode(1)]); - writeSitemapFile(dir, 'mobile', [makeNode(2)]); + writeSitemapFile(dir, "website", [makeNode(1)]); + writeSitemapFile(dir, "mobile", [makeNode(2)]); const sh = new SitemapHierarchy(); const result = sh.loadAllSitemaps(guid, locale); - expect(Object.keys(result)).toEqual(expect.arrayContaining(['website', 'mobile'])); + expect(Object.keys(result)).toEqual(expect.arrayContaining(["website", "mobile"])); }); - it('ignores non-.json files in the sitemap directory', () => { - const guid = 'guid-non-json'; - const locale = 'en-us'; + it("ignores non-.json files in the sitemap directory", () => { + const guid = "guid-non-json"; + const locale = "en-us"; const dir = sitemapDir(guid, locale); fs.mkdirSync(dir, { recursive: true }); - writeSitemapFile(dir, 'website', [makeNode(1)]); - fs.writeFileSync(path.join(dir, 'README.txt'), 'ignore me'); + writeSitemapFile(dir, "website", [makeNode(1)]); + fs.writeFileSync(path.join(dir, "README.txt"), "ignore me"); const sh = new SitemapHierarchy(); const result = sh.loadAllSitemaps(guid, locale); - expect(Object.keys(result)).toEqual(['website']); + expect(Object.keys(result)).toEqual(["website"]); }); }); // ─── buildPageHierarchy ─────────────────────────────────────────────────────── -describe('SitemapHierarchy.buildPageHierarchy', () => { - it('returns empty object for an empty sitemap', () => { +describe("SitemapHierarchy.buildPageHierarchy", () => { + it("returns empty object for an empty sitemap", () => { const sh = new SitemapHierarchy(); expect(sh.buildPageHierarchy([])).toEqual({}); }); - it('does not add leaf nodes (no children) to hierarchy', () => { + it("does not add leaf nodes (no children) to hierarchy", () => { const sh = new SitemapHierarchy(); const sitemap = [makeNode(1), makeNode(2)]; const hierarchy = sh.buildPageHierarchy(sitemap); expect(Object.keys(hierarchy)).toHaveLength(0); }); - it('maps parent to direct child IDs', () => { + it("maps parent to direct child IDs", () => { const sh = new SitemapHierarchy(); const sitemap = [makeNode(1, [makeNode(2), makeNode(3)])]; const hierarchy = sh.buildPageHierarchy(sitemap); expect(hierarchy[1]).toEqual([2, 3]); }); - it('handles nested hierarchy recursively', () => { + it("handles nested hierarchy recursively", () => { const sh = new SitemapHierarchy(); const sitemap = [makeNode(1, [makeNode(2, [makeNode(3)])])]; const hierarchy = sh.buildPageHierarchy(sitemap); @@ -158,16 +158,16 @@ describe('SitemapHierarchy.buildPageHierarchy', () => { // ─── groupPagesHierarchically ───────────────────────────────────────────────── -describe('SitemapHierarchy.groupPagesHierarchically', () => { - it('returns each page as its own group when hierarchy is empty', () => { +describe("SitemapHierarchy.groupPagesHierarchically", () => { + it("returns each page as its own group when hierarchy is empty", () => { const sh = new SitemapHierarchy(); const pages = [makePage(1), makePage(2)]; const groups = sh.groupPagesHierarchically(pages, {}); expect(groups).toHaveLength(2); - groups.forEach(g => expect(g.childPages).toHaveLength(0)); + groups.forEach((g) => expect(g.childPages).toHaveLength(0)); }); - it('groups parent and children together', () => { + it("groups parent and children together", () => { const sh = new SitemapHierarchy(); const pages = [makePage(1), makePage(2), makePage(3)]; const hierarchy: PageHierarchy = { 1: [2, 3] }; @@ -177,21 +177,21 @@ describe('SitemapHierarchy.groupPagesHierarchically', () => { expect(groups[0].childPages.map((p: any) => p.pageID)).toEqual(expect.arrayContaining([2, 3])); }); - it('marks all pages within a group as processed (no duplicates)', () => { + it("marks all pages within a group as processed (no duplicates)", () => { const sh = new SitemapHierarchy(); const pages = [makePage(1), makePage(2)]; const hierarchy: PageHierarchy = { 1: [2] }; const groups = sh.groupPagesHierarchically(pages, hierarchy); // Total pages across all groups should equal original page count - const totalIds = groups.flatMap(g => Array.from(g.allPageIds)); + const totalIds = groups.flatMap((g) => Array.from(g.allPageIds)); expect(new Set(totalIds).size).toBe(pages.length); }); }); // ─── calculatePageDepths ────────────────────────────────────────────────────── -describe('SitemapHierarchy.calculatePageDepths', () => { - it('assigns depth 0 to all pages when hierarchy is empty', () => { +describe("SitemapHierarchy.calculatePageDepths", () => { + it("assigns depth 0 to all pages when hierarchy is empty", () => { const sh = new SitemapHierarchy(); const pages = [makePage(1), makePage(2)]; const depths = sh.calculatePageDepths(pages, {}); @@ -199,7 +199,7 @@ describe('SitemapHierarchy.calculatePageDepths', () => { expect(depths.get(2)).toBe(0); }); - it('assigns depth 1 to direct children', () => { + it("assigns depth 1 to direct children", () => { const sh = new SitemapHierarchy(); const pages = [makePage(1), makePage(2)]; const hierarchy: PageHierarchy = { 1: [2] }; @@ -208,7 +208,7 @@ describe('SitemapHierarchy.calculatePageDepths', () => { expect(depths.get(2)).toBe(1); }); - it('assigns depth 2 to grandchildren', () => { + it("assigns depth 2 to grandchildren", () => { const sh = new SitemapHierarchy(); const pages = [makePage(1), makePage(2), makePage(3)]; const hierarchy: PageHierarchy = { 1: [2], 2: [3] }; @@ -216,7 +216,7 @@ describe('SitemapHierarchy.calculatePageDepths', () => { expect(depths.get(3)).toBe(2); }); - it('handles circular references without infinite loop', () => { + it("handles circular references without infinite loop", () => { const sh = new SitemapHierarchy(); const pages = [makePage(1), makePage(2)]; // Circular: 1→2 and 2→1 @@ -227,8 +227,8 @@ describe('SitemapHierarchy.calculatePageDepths', () => { // ─── getProcessingOrder ──────────────────────────────────────────────────────── -describe('SitemapHierarchy.getProcessingOrder', () => { - it('returns all pages in the ordered list', () => { +describe("SitemapHierarchy.getProcessingOrder", () => { + it("returns all pages in the ordered list", () => { const sh = new SitemapHierarchy(); const pages = [makePage(1), makePage(2), makePage(3)]; const hierarchy: PageHierarchy = { 1: [2, 3] }; @@ -236,7 +236,7 @@ describe('SitemapHierarchy.getProcessingOrder', () => { expect(orderedPages).toHaveLength(3); }); - it('ensures parents come before their children', () => { + it("ensures parents come before their children", () => { const sh = new SitemapHierarchy(); const pages = [makePage(1), makePage(2), makePage(3)]; const hierarchy: PageHierarchy = { 1: [2], 2: [3] }; @@ -246,7 +246,7 @@ describe('SitemapHierarchy.getProcessingOrder', () => { expect(idx(2)).toBeLessThan(idx(3)); }); - it('returns depthInfo map alongside orderedPages', () => { + it("returns depthInfo map alongside orderedPages", () => { const sh = new SitemapHierarchy(); const pages = [makePage(1), makePage(2)]; const hierarchy: PageHierarchy = { 1: [2] }; @@ -259,13 +259,13 @@ describe('SitemapHierarchy.getProcessingOrder', () => { // ─── validateProcessingOrder ────────────────────────────────────────────────── -describe('SitemapHierarchy.validateProcessingOrder', () => { - it('returns true for an empty page list', () => { +describe("SitemapHierarchy.validateProcessingOrder", () => { + it("returns true for an empty page list", () => { const sh = new SitemapHierarchy(); expect(sh.validateProcessingOrder([], {})).toBe(true); }); - it('returns true when processing order is dependency-safe', () => { + it("returns true when processing order is dependency-safe", () => { const sh = new SitemapHierarchy(); const pages = [makePage(1), makePage(2)]; const hierarchy: PageHierarchy = { 1: [2] }; @@ -273,7 +273,7 @@ describe('SitemapHierarchy.validateProcessingOrder', () => { expect(sh.validateProcessingOrder(pages, hierarchy)).toBe(true); }); - it('returns false when a child is ordered before its parent', () => { + it("returns false when a child is ordered before its parent", () => { const sh = new SitemapHierarchy(); const pages = [makePage(2), makePage(1)]; // child before parent const hierarchy: PageHierarchy = { 1: [2] }; @@ -283,13 +283,13 @@ describe('SitemapHierarchy.validateProcessingOrder', () => { // ─── extractSiblingOrderFromSitemap ────────────────────────────────────────── -describe('SitemapHierarchy.extractSiblingOrderFromSitemap', () => { - it('returns empty map for empty sitemap', () => { +describe("SitemapHierarchy.extractSiblingOrderFromSitemap", () => { + it("returns empty map for empty sitemap", () => { const sh = new SitemapHierarchy(); expect(sh.extractSiblingOrderFromSitemap([])).toEqual(new Map()); }); - it('maps each page to its next sibling (null for last)', () => { + it("maps each page to its next sibling (null for last)", () => { const sh = new SitemapHierarchy(); const sitemap = [makeNode(1), makeNode(2), makeNode(3)]; const order = sh.extractSiblingOrderFromSitemap(sitemap); @@ -298,7 +298,7 @@ describe('SitemapHierarchy.extractSiblingOrderFromSitemap', () => { expect(order.get(3)).toBeNull(); }); - it('processes children recursively', () => { + it("processes children recursively", () => { const sh = new SitemapHierarchy(); const sitemap = [makeNode(1, [makeNode(10), makeNode(11)])]; const order = sh.extractSiblingOrderFromSitemap(sitemap); @@ -309,20 +309,23 @@ describe('SitemapHierarchy.extractSiblingOrderFromSitemap', () => { // ─── getInsertBeforePageId ──────────────────────────────────────────────────── -describe('SitemapHierarchy.getInsertBeforePageId', () => { - it('returns null when page has no next sibling', () => { +describe("SitemapHierarchy.getInsertBeforePageId", () => { + it("returns null when page has no next sibling", () => { const sh = new SitemapHierarchy(); const order = new Map([[1, null]]); expect(sh.getInsertBeforePageId(1, order)).toBeNull(); }); - it('returns the next sibling ID when one exists', () => { + it("returns the next sibling ID when one exists", () => { const sh = new SitemapHierarchy(); - const order = new Map([[1, 5], [5, null]]); + const order = new Map([ + [1, 5], + [5, null], + ]); expect(sh.getInsertBeforePageId(1, order)).toBe(5); }); - it('returns null when page ID is not in the sibling map', () => { + it("returns null when page ID is not in the sibling map", () => { const sh = new SitemapHierarchy(); const order = new Map(); expect(sh.getInsertBeforePageId(99, order)).toBeNull(); @@ -331,51 +334,47 @@ describe('SitemapHierarchy.getInsertBeforePageId', () => { // ─── getOrphanedPages ──────────────────────────────────────────────────────── -describe('SitemapHierarchy.getOrphanedPages', () => { - it('returns all pages when no groups exist', () => { +describe("SitemapHierarchy.getOrphanedPages", () => { + it("returns all pages when no groups exist", () => { const sh = new SitemapHierarchy(); const pages = [makePage(1), makePage(2)]; const result = sh.getOrphanedPages(pages, []); expect(result).toHaveLength(2); }); - it('returns only pages not covered by any group', () => { + it("returns only pages not covered by any group", () => { const sh = new SitemapHierarchy(); const pages = [makePage(1), makePage(2), makePage(3)]; - const groups = [ - { rootPage: makePage(1), childPages: [makePage(2)], allPageIds: new Set([1, 2]) }, - ]; + const groups = [{ rootPage: makePage(1), childPages: [makePage(2)], allPageIds: new Set([1, 2]) }]; const orphans = sh.getOrphanedPages(pages, groups); expect(orphans).toHaveLength(1); expect(orphans[0].pageID).toBe(3); }); - it('returns empty array when all pages are covered by groups', () => { + it("returns empty array when all pages are covered by groups", () => { const sh = new SitemapHierarchy(); const pages = [makePage(1), makePage(2)]; - const groups = [ - { rootPage: makePage(1), childPages: [makePage(2)], allPageIds: new Set([1, 2]) }, - ]; + const groups = [{ rootPage: makePage(1), childPages: [makePage(2)], allPageIds: new Set([1, 2]) }]; expect(sh.getOrphanedPages(pages, groups)).toHaveLength(0); }); }); // ─── buildPageHierarchyWithDynamicSupport ───────────────────────────────────── -describe('SitemapHierarchy.buildPageHierarchyWithDynamicSupport', () => { - it('returns empty hierarchy for empty sitemap', () => { +describe("SitemapHierarchy.buildPageHierarchyWithDynamicSupport", () => { + it("returns empty hierarchy for empty sitemap", () => { const sh = new SitemapHierarchy(); expect(sh.buildPageHierarchyWithDynamicSupport([])).toEqual({}); }); - it('maps parent page to its children IDs', () => { + it("maps parent page to its children IDs", () => { const sh = new SitemapHierarchy(); const sitemap = [makeNode(1, [makeNode(2), makeNode(3)])]; const hierarchy = sh.buildPageHierarchyWithDynamicSupport(sitemap); expect(hierarchy[1]).toEqual(expect.arrayContaining([2, 3])); }); - it('does not add duplicate child IDs for dynamic pages', () => { + it("does not add duplicate child IDs for dynamic pages", () => { const sh = new SitemapHierarchy(); const dynamicChild: SitemapNode = { ...makeNode(5), contentID: 100 }; const sitemap = [makeNode(1, [dynamicChild])]; @@ -387,17 +386,17 @@ describe('SitemapHierarchy.buildPageHierarchyWithDynamicSupport', () => { // ─── buildPageOrderingData ──────────────────────────────────────────────────── -describe('SitemapHierarchy.buildPageOrderingData', () => { - it('returns hierarchy, siblingOrder and parentToChildrenMap', () => { +describe("SitemapHierarchy.buildPageOrderingData", () => { + it("returns hierarchy, siblingOrder and parentToChildrenMap", () => { const sh = new SitemapHierarchy(); const sitemap = [makeNode(1, [makeNode(2)])]; const data = sh.buildPageOrderingData(sitemap); - expect(data).toHaveProperty('hierarchy'); - expect(data).toHaveProperty('siblingOrder'); - expect(data).toHaveProperty('parentToChildrenMap'); + expect(data).toHaveProperty("hierarchy"); + expect(data).toHaveProperty("siblingOrder"); + expect(data).toHaveProperty("parentToChildrenMap"); }); - it('populates parentToChildrenMap consistently with hierarchy', () => { + it("populates parentToChildrenMap consistently with hierarchy", () => { const sh = new SitemapHierarchy(); const sitemap = [makeNode(10, [makeNode(20), makeNode(30)])]; const { hierarchy, parentToChildrenMap } = sh.buildPageOrderingData(sitemap); @@ -407,17 +406,21 @@ describe('SitemapHierarchy.buildPageOrderingData', () => { // ─── getPagesByDepth ────────────────────────────────────────────────────────── -describe('SitemapHierarchy.getPagesByDepth', () => { - it('groups pages by their depth', () => { +describe("SitemapHierarchy.getPagesByDepth", () => { + it("groups pages by their depth", () => { const sh = new SitemapHierarchy(); const pages = [makePage(1), makePage(2), makePage(3)]; - const depths = new Map([[1, 0], [2, 1], [3, 1]]); + const depths = new Map([ + [1, 0], + [2, 1], + [3, 1], + ]); const byDepth = sh.getPagesByDepth(pages, depths); expect(byDepth.get(0)!.map((p: any) => p.pageID)).toEqual([1]); expect(byDepth.get(1)!.map((p: any) => p.pageID)).toEqual(expect.arrayContaining([2, 3])); }); - it('defaults to depth 0 for pages not in the depth map', () => { + it("defaults to depth 0 for pages not in the depth map", () => { const sh = new SitemapHierarchy(); const pages = [makePage(99)]; const byDepth = sh.getPagesByDepth(pages, new Map()); diff --git a/src/lib/pushers/page-pusher/tests/translate-zone-names.test.ts b/src/lib/pushers/page-pusher/tests/translate-zone-names.test.ts index 6d200a3..457d086 100644 --- a/src/lib/pushers/page-pusher/tests/translate-zone-names.test.ts +++ b/src/lib/pushers/page-pusher/tests/translate-zone-names.test.ts @@ -1,11 +1,11 @@ -import { resetState } from 'core/state'; -import { translateZoneNames } from '../translate-zone-names'; +import { resetState } from "core/state"; +import { translateZoneNames } from "../translate-zone-names"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -23,181 +23,181 @@ function makeTemplate(sectionNames: string[], ordered = true): any { }; } -const module1 = { module: 'Module1', item: null }; -const module2 = { module: 'Module2', item: null }; -const module3 = { module: 'Module3', item: null }; +const module1 = { module: "Module1", item: null }; +const module2 = { module: "Module2", item: null }; +const module3 = { module: "Module3", item: null }; // ─── null / missing inputs ──────────────────────────────────────────────────── -describe('translateZoneNames — null / missing inputs', () => { - it('returns empty object when sourceZones is null and template is null', () => { +describe("translateZoneNames — null / missing inputs", () => { + it("returns empty object when sourceZones is null and template is null", () => { expect(translateZoneNames(null, null)).toEqual({}); }); - it('returns empty object when sourceZones is undefined and template is null', () => { + it("returns empty object when sourceZones is undefined and template is null", () => { expect(translateZoneNames(undefined, null)).toEqual({}); }); - it('returns sourceZones as-is when template is null', () => { + it("returns sourceZones as-is when template is null", () => { const zones = { Main: [module1] }; expect(translateZoneNames(zones, null)).toEqual(zones); }); - it('returns sourceZones as-is when template has no contentSectionDefinitions', () => { + it("returns sourceZones as-is when template has no contentSectionDefinitions", () => { const zones = { Main: [module1] }; const template = {} as any; expect(translateZoneNames(zones, template)).toEqual(zones); }); - it('returns sourceZones as-is when contentSectionDefinitions is null', () => { + it("returns sourceZones as-is when contentSectionDefinitions is null", () => { const zones = { Main: [module1] }; const template = { contentSectionDefinitions: null } as any; expect(translateZoneNames(zones, template)).toEqual(zones); }); - it('returns empty object when sourceZones is null even with valid template', () => { - const template = makeTemplate(['Main']); + it("returns empty object when sourceZones is null even with valid template", () => { + const template = makeTemplate(["Main"]); expect(translateZoneNames(null, template)).toEqual({}); }); }); // ─── 1:1 zone mapping ───────────────────────────────────────────────────────── -describe('translateZoneNames — 1:1 zone mapping', () => { - it('renames a single source zone to the template zone name', () => { +describe("translateZoneNames — 1:1 zone mapping", () => { + it("renames a single source zone to the template zone name", () => { const zones = { OldName: [module1] }; - const template = makeTemplate(['NewName']); + const template = makeTemplate(["NewName"]); const result = translateZoneNames(zones, template); - expect(result).toHaveProperty('NewName'); - expect(result['NewName']).toEqual([module1]); - expect(result).not.toHaveProperty('OldName'); + expect(result).toHaveProperty("NewName"); + expect(result["NewName"]).toEqual([module1]); + expect(result).not.toHaveProperty("OldName"); }); - it('maps multiple source zones to corresponding template zone names in order', () => { + it("maps multiple source zones to corresponding template zone names in order", () => { const zones = { ZoneA: [module1], ZoneB: [module2] }; - const template = makeTemplate(['TargetA', 'TargetB']); + const template = makeTemplate(["TargetA", "TargetB"]); const result = translateZoneNames(zones, template); - expect(result['TargetA']).toEqual([module1]); - expect(result['TargetB']).toEqual([module2]); + expect(result["TargetA"]).toEqual([module1]); + expect(result["TargetB"]).toEqual([module2]); }); - it('stops mapping when source zones run out before template zones', () => { + it("stops mapping when source zones run out before template zones", () => { const zones = { ZoneA: [module1] }; - const template = makeTemplate(['TargetA', 'TargetB']); + const template = makeTemplate(["TargetA", "TargetB"]); const result = translateZoneNames(zones, template); - expect(result['TargetA']).toEqual([module1]); - expect(result).not.toHaveProperty('TargetB'); + expect(result["TargetA"]).toEqual([module1]); + expect(result).not.toHaveProperty("TargetB"); }); }); // ─── itemOrder sorting ──────────────────────────────────────────────────────── -describe('translateZoneNames — template itemOrder sorting', () => { - it('sorts contentSectionDefinitions by itemOrder before mapping', () => { +describe("translateZoneNames — template itemOrder sorting", () => { + it("sorts contentSectionDefinitions by itemOrder before mapping", () => { // Provide template definitions in reverse order — they should be sorted ascending const template: any = { contentSectionDefinitions: [ - { pageItemTemplateReferenceName: 'Second', itemOrder: 1 }, - { pageItemTemplateReferenceName: 'First', itemOrder: 0 }, + { pageItemTemplateReferenceName: "Second", itemOrder: 1 }, + { pageItemTemplateReferenceName: "First", itemOrder: 0 }, ], }; // Source zones in insertion order: ZoneA → First, ZoneB → Second const zones = { ZoneA: [module1], ZoneB: [module2] }; const result = translateZoneNames(zones, template); // After sort: First (0), Second (1) — ZoneA should map to First - expect(result['First']).toEqual([module1]); - expect(result['Second']).toEqual([module2]); + expect(result["First"]).toEqual([module1]); + expect(result["Second"]).toEqual([module2]); }); - it('treats missing itemOrder as 0', () => { + it("treats missing itemOrder as 0", () => { const template: any = { contentSectionDefinitions: [ - { pageItemTemplateReferenceName: 'ZoneX' }, // no itemOrder - { pageItemTemplateReferenceName: 'ZoneY', itemOrder: 1 }, + { pageItemTemplateReferenceName: "ZoneX" }, // no itemOrder + { pageItemTemplateReferenceName: "ZoneY", itemOrder: 1 }, ], }; const zones = { Source1: [module1], Source2: [module2] }; const result = translateZoneNames(zones, template); // Both missing itemOrder and itemOrder=0 sort equally, then ZoneY=1 comes after - expect(Object.keys(result)).toEqual(expect.arrayContaining(['ZoneX', 'ZoneY'])); + expect(Object.keys(result)).toEqual(expect.arrayContaining(["ZoneX", "ZoneY"])); }); }); // ─── overflow: extra source zones combined into main zone ───────────────────── -describe('translateZoneNames — extra source zones collapse into first template zone', () => { - it('combines extra source zone modules into the first template zone', () => { +describe("translateZoneNames — extra source zones collapse into first template zone", () => { + it("combines extra source zone modules into the first template zone", () => { const zones = { ZoneA: [module1], ZoneB: [module2], // overflow }; - const template = makeTemplate(['OnlyZone']); // only one template section + const template = makeTemplate(["OnlyZone"]); // only one template section const result = translateZoneNames(zones, template); - expect(result['OnlyZone']).toEqual([module1, module2]); + expect(result["OnlyZone"]).toEqual([module1, module2]); }); - it('combines modules from multiple overflow zones into first template zone', () => { + it("combines modules from multiple overflow zones into first template zone", () => { const zones = { ZoneA: [module1], ZoneB: [module2], ZoneC: [module3], }; - const template = makeTemplate(['Main']); + const template = makeTemplate(["Main"]); const result = translateZoneNames(zones, template); - expect(result['Main']).toEqual([module1, module2, module3]); + expect(result["Main"]).toEqual([module1, module2, module3]); }); - it('does not create extra zones beyond the template definition', () => { + it("does not create extra zones beyond the template definition", () => { const zones = { Z1: [module1], Z2: [module2], Z3: [module3] }; - const template = makeTemplate(['OnlyZone']); + const template = makeTemplate(["OnlyZone"]); const result = translateZoneNames(zones, template); - expect(Object.keys(result)).toEqual(['OnlyZone']); + expect(Object.keys(result)).toEqual(["OnlyZone"]); }); - it('skips non-array overflow zone content gracefully', () => { + it("skips non-array overflow zone content gracefully", () => { const zones = { ZoneA: [module1], - ZoneB: 'not-an-array' as any, // non-array overflow + ZoneB: "not-an-array" as any, // non-array overflow }; - const template = makeTemplate(['Main']); + const template = makeTemplate(["Main"]); const result = translateZoneNames(zones, template); // ZoneB is not an array, so nothing extra is appended - expect(result['Main']).toEqual([module1]); + expect(result["Main"]).toEqual([module1]); }); - it('skips empty-array overflow zones', () => { + it("skips empty-array overflow zones", () => { const zones = { ZoneA: [module1], ZoneB: [], }; - const template = makeTemplate(['Main']); + const template = makeTemplate(["Main"]); const result = translateZoneNames(zones, template); - expect(result['Main']).toEqual([module1]); + expect(result["Main"]).toEqual([module1]); }); - it('does not trigger overflow collapse when source and template counts are equal', () => { + it("does not trigger overflow collapse when source and template counts are equal", () => { const zones = { Z1: [module1], Z2: [module2] }; - const template = makeTemplate(['T1', 'T2']); + const template = makeTemplate(["T1", "T2"]); const result = translateZoneNames(zones, template); - expect(result['T1']).toEqual([module1]); - expect(result['T2']).toEqual([module2]); + expect(result["T1"]).toEqual([module1]); + expect(result["T2"]).toEqual([module2]); // Should not have concatenated anything - expect(result['T1']).toHaveLength(1); + expect(result["T1"]).toHaveLength(1); }); }); // ─── edge cases ─────────────────────────────────────────────────────────────── -describe('translateZoneNames — edge cases', () => { - it('returns empty object when both sourceZones and template sections are empty', () => { +describe("translateZoneNames — edge cases", () => { + it("returns empty object when both sourceZones and template sections are empty", () => { const result = translateZoneNames({}, makeTemplate([])); expect(result).toEqual({}); }); - it('does not mutate the original sourceZones object', () => { + it("does not mutate the original sourceZones object", () => { const original = { ZoneA: [module1] }; const frozen = Object.freeze({ ...original }); // translateZoneNames creates a new translatedZones object, never writes to sourceZones - expect(() => translateZoneNames(original, makeTemplate(['NewZone']))).not.toThrow(); + expect(() => translateZoneNames(original, makeTemplate(["NewZone"]))).not.toThrow(); }); }); diff --git a/src/lib/pushers/page-pusher/translate-zone-names.ts b/src/lib/pushers/page-pusher/translate-zone-names.ts index 2ea3a31..bf9e3aa 100644 --- a/src/lib/pushers/page-pusher/translate-zone-names.ts +++ b/src/lib/pushers/page-pusher/translate-zone-names.ts @@ -1,38 +1,38 @@ import * as mgmtApi from "@agility/management-sdk"; export function translateZoneNames(sourceZones: any, targetTemplate: mgmtApi.PageModel | null): any { - if (!sourceZones || !targetTemplate?.contentSectionDefinitions) { - return sourceZones || {}; // No template or sections, return as-is - } - - const translatedZones: any = {}; - const sectionNames = targetTemplate.contentSectionDefinitions - .sort((a, b) => (a.itemOrder || 0) - (b.itemOrder || 0)) // Sort by item order - .map((def) => def.pageItemTemplateReferenceName); - - // Map source zones to template section names in order - const sourceZoneEntries = Object.entries(sourceZones); - - for (let i = 0; i < sourceZoneEntries.length && i < sectionNames.length; i++) { - const [sourceZoneName, zoneContent] = sourceZoneEntries[i]; - const targetZoneName = sectionNames[i]; - translatedZones[targetZoneName] = zoneContent; - } - - // CRITICAL FIX: Instead of dropping extra zones, combine them into the main zone - if (sourceZoneEntries.length > sectionNames.length && sectionNames.length > 0) { - const mainZoneName = sectionNames[0]; // Use first (main) zone as target - const mainZoneModules = Array.isArray(translatedZones[mainZoneName]) ? [...translatedZones[mainZoneName]] : []; - - for (let i = sectionNames.length; i < sourceZoneEntries.length; i++) { - const [sourceZoneName, zoneContent] = sourceZoneEntries[i]; - if (Array.isArray(zoneContent) && zoneContent.length > 0) { - mainZoneModules.push(...zoneContent); - } - } - - translatedZones[mainZoneName] = mainZoneModules; - } - - return translatedZones; -} \ No newline at end of file + if (!sourceZones || !targetTemplate?.contentSectionDefinitions) { + return sourceZones || {}; // No template or sections, return as-is + } + + const translatedZones: any = {}; + const sectionNames = targetTemplate.contentSectionDefinitions + .sort((a, b) => (a.itemOrder || 0) - (b.itemOrder || 0)) // Sort by item order + .map((def) => def.pageItemTemplateReferenceName); + + // Map source zones to template section names in order + const sourceZoneEntries = Object.entries(sourceZones); + + for (let i = 0; i < sourceZoneEntries.length && i < sectionNames.length; i++) { + const [sourceZoneName, zoneContent] = sourceZoneEntries[i]; + const targetZoneName = sectionNames[i]; + translatedZones[targetZoneName] = zoneContent; + } + + // CRITICAL FIX: Instead of dropping extra zones, combine them into the main zone + if (sourceZoneEntries.length > sectionNames.length && sectionNames.length > 0) { + const mainZoneName = sectionNames[0]; // Use first (main) zone as target + const mainZoneModules = Array.isArray(translatedZones[mainZoneName]) ? [...translatedZones[mainZoneName]] : []; + + for (let i = sectionNames.length; i < sourceZoneEntries.length; i++) { + const [sourceZoneName, zoneContent] = sourceZoneEntries[i]; + if (Array.isArray(zoneContent) && zoneContent.length > 0) { + mainZoneModules.push(...zoneContent); + } + } + + translatedZones[mainZoneName] = mainZoneModules; + } + + return translatedZones; +} diff --git a/src/lib/pushers/push-operations-config.ts b/src/lib/pushers/push-operations-config.ts index 0df5e77..37001ee 100644 --- a/src/lib/pushers/push-operations-config.ts +++ b/src/lib/pushers/push-operations-config.ts @@ -1,9 +1,8 @@ // Import existing pushers -import { GuidEntities } from './guid-data-loader'; -import { PusherResult } from 'types/sourceData'; -import { getState, setState } from 'core/state'; -import ansiColors from 'ansi-colors'; - +import { GuidEntities } from "./guid-data-loader"; +import { PusherResult } from "types/sourceData"; +import { getState, setState } from "core/state"; +import ansiColors from "ansi-colors"; // Central configuration for all push operations export interface PushOperationConfig { @@ -17,81 +16,81 @@ export interface PushOperationConfig { export const PUSH_OPERATIONS: Record = { galleries: { - name: 'pushGalleries', - description: 'Push asset galleries and media groupings', + name: "pushGalleries", + description: "Push asset galleries and media groupings", handler: async (sourceData, targetData) => { - const { pushGalleries } = await import('./gallery-pusher'); - return await pushGalleries(sourceData['galleries'], targetData['galleries']); + const { pushGalleries } = await import("./gallery-pusher"); + return await pushGalleries(sourceData["galleries"], targetData["galleries"]); }, - elements: ['Galleries'], + elements: ["Galleries"], // dependencies: ['Assets'], // Galleries require Assets to be meaningful - dataKey: 'galleries' + dataKey: "galleries", }, assets: { - name: 'pushAssets', - description: 'Push media files and asset metadata', + name: "pushAssets", + description: "Push media files and asset metadata", handler: async (sourceData, targetData) => { - const { pushAssets } = await import('./asset-pusher'); - return await pushAssets(sourceData['assets'], targetData['assets']); + const { pushAssets } = await import("./asset-pusher"); + return await pushAssets(sourceData["assets"], targetData["assets"]); }, - elements: ['Assets'], - dependencies: ['Galleries'], // Assets require Galleries to be meaningful - dataKey: 'assets' + elements: ["Assets"], + dependencies: ["Galleries"], // Assets require Galleries to be meaningful + dataKey: "assets", }, models: { - name: 'pushModels', - description: 'Push content models and field definitions', + name: "pushModels", + description: "Push content models and field definitions", handler: async (sourceData, targetData) => { - const { pushModels } = await import('./model-pusher'); - return await pushModels(sourceData['models'], targetData['models']); + const { pushModels } = await import("./model-pusher"); + return await pushModels(sourceData["models"], targetData["models"]); }, - elements: ['Models'], - dataKey: 'models' + elements: ["Models"], + dataKey: "models", }, containers: { - name: 'pushContainers', - description: 'Push content containers and views', + name: "pushContainers", + description: "Push content containers and views", handler: async (sourceData, targetData) => { - const { pushContainers } = await import('./container-pusher'); - return await pushContainers(sourceData['containers'], targetData['containers']); + const { pushContainers } = await import("./container-pusher"); + return await pushContainers(sourceData["containers"], targetData["containers"]); }, - elements: ['Containers'], - dataKey: 'containers', - dependencies: ['Models'] // Containers require Models to be meaningful + elements: ["Containers"], + dataKey: "containers", + dependencies: ["Models"], // Containers require Models to be meaningful }, content: { - name: 'pushContent', - description: 'Push content items', + name: "pushContent", + description: "Push content items", handler: async (sourceData, targetData, locale) => { - const { pushContent } = await import('./content-pusher/content-pusher'); - return await pushContent(sourceData['content'], targetData['content'], locale); + const { pushContent } = await import("./content-pusher/content-pusher"); + return await pushContent(sourceData["content"], targetData["content"], locale); }, - elements: ['Content'], - dataKey: 'content', - dependencies: ['Models', 'Containers', 'Assets', 'Galleries', 'Templates'] // Content requires Models and Containers + elements: ["Content"], + dataKey: "content", + dependencies: ["Models", "Containers", "Assets", "Galleries", "Templates"], // Content requires Models and Containers }, templates: { - name: 'pushTemplates', - description: 'Push page templates and layouts', + name: "pushTemplates", + description: "Push page templates and layouts", handler: async (sourceData, targetData, locale) => { - const { pushTemplates } = await import('./template-pusher'); - return await pushTemplates(sourceData['templates'], targetData['templates'], locale); + const { pushTemplates } = await import("./template-pusher"); + return await pushTemplates(sourceData["templates"], targetData["templates"], locale); }, - elements: ['Templates'], - dataKey: 'templates', - dependencies: ['Models', 'Containers', 'Pages', 'Content'] // Templates reference Models for container definitions + elements: ["Templates"], + dataKey: "templates", + dependencies: ["Models", "Containers", "Pages", "Content"], // Templates reference Models for container definitions }, pages: { - name: 'pushPages', - description: 'Push pages and page hierarchy', + name: "pushPages", + description: "Push pages and page hierarchy", handler: async (sourceData, targetData, locale) => { - const { pushPages } = await import('./page-pusher/push-pages'); - return await pushPages(sourceData['pages'], locale); + const { pushPages } = await import("./page-pusher/push-pages"); + return await pushPages(sourceData["pages"], locale); }, - elements: ['Pages'], - dataKey: 'pages', - dependencies: ['Templates', 'Models', 'Containers', 'Content', 'Galleries', 'Assets'] // Pages require Templates, Models, and Containers - } + elements: ["Pages"], + dataKey: "pages", + dependencies: ["Templates", "Models", "Containers", "Content", "Galleries", "Assets"], // Pages require Templates, Models, and Containers + }, }; export class PushOperationsRegistry { @@ -100,24 +99,25 @@ export class PushOperationsRegistry { */ static getOperationsForElements(): PushOperationConfig[] { const state = getState(); - const elementList = state.elements ? state.elements.split(",") : - ['Galleries', 'Assets', 'Models', 'Containers', 'Content', 'Templates', 'Pages']; - + const elementList = state.elements + ? state.elements.split(",") + : ["Galleries", "Assets", "Models", "Containers", "Content", "Templates", "Pages"]; + // Resolve dependencies and update state const { resolvedElements, autoIncluded } = this.resolveDependencies(elementList); - + // Update state.elements with resolved dependencies if any were auto-included if (autoIncluded.length > 0) { // Update the state with resolved elements - setState({ elements: resolvedElements.join(',') }); + setState({ elements: resolvedElements.join(",") }); } - + // Filter operations based on resolved elements - const relevantOperations = Object.values(PUSH_OPERATIONS).filter(operation => { + const relevantOperations = Object.values(PUSH_OPERATIONS).filter((operation) => { // Check if any of the operation's elements are in the resolved element list - return operation.elements.some(element => resolvedElements.includes(element)); + return operation.elements.some((element) => resolvedElements.includes(element)); }); - + return relevantOperations; } @@ -132,39 +132,35 @@ export class PushOperationsRegistry { * Get operation by name */ static getOperationByName(name: string): PushOperationConfig | undefined { - return Object.values(PUSH_OPERATIONS).find(op => op.name === name); + return Object.values(PUSH_OPERATIONS).find((op) => op.name === name); } /** * Get operations by element type */ static getOperationsByElement(element: string): PushOperationConfig[] { - return Object.values(PUSH_OPERATIONS).filter(operation => - operation.elements.includes(element) - ); + return Object.values(PUSH_OPERATIONS).filter((operation) => operation.elements.includes(element)); } /** * Resolve element dependencies */ - private static resolveDependencies(requestedElements: string[]): { - resolvedElements: string[], - autoIncluded: string[] + private static resolveDependencies(requestedElements: string[]): { + resolvedElements: string[]; + autoIncluded: string[]; } { const resolvedElements = new Set(requestedElements); const autoIncluded: string[] = []; - + // Check each requested element for dependencies for (const element of requestedElements) { // Find operations that provide this element - const operations = Object.values(PUSH_OPERATIONS).filter(op => - op.elements.includes(element) - ); - + const operations = Object.values(PUSH_OPERATIONS).filter((op) => op.elements.includes(element)); + // Add dependencies for each operation - operations.forEach(operation => { + operations.forEach((operation) => { if (operation.dependencies) { - operation.dependencies.forEach(dep => { + operation.dependencies.forEach((dep) => { if (!resolvedElements.has(dep)) { resolvedElements.add(dep); autoIncluded.push(dep); @@ -173,10 +169,10 @@ export class PushOperationsRegistry { } }); } - + return { resolvedElements: Array.from(resolvedElements), - autoIncluded + autoIncluded, }; } -} +} diff --git a/src/lib/pushers/template-pusher.ts b/src/lib/pushers/template-pusher.ts index b2ac4d2..ff20c37 100644 --- a/src/lib/pushers/template-pusher.ts +++ b/src/lib/pushers/template-pusher.ts @@ -1,143 +1,142 @@ import * as mgmtApi from "@agility/management-sdk"; import ansiColors from "ansi-colors"; -import { state, getState, getApiClient, getLoggerForGuid } from '../../core/state'; +import { state, getState, getApiClient, getLoggerForGuid } from "../../core/state"; import { TemplateMapper } from "lib/mappers/template-mapper"; import { ModelMapper } from "lib/mappers/model-mapper"; import { ContainerMapper } from "lib/mappers/container-mapper"; import { ContentItemMapper } from "lib/mappers/content-item-mapper"; import { FailureDetail, PusherResult } from "types/sourceData"; - /** * Enhanced template finder with proper target safety and conflict resolution * Logic Flow: Target Safety FIRST → Change Delta SECOND → Conflict Resolution */ export async function pushTemplates( - sourceData: any, - targetData: any, - locale: string - // onProgress?: (processed: number, total: number, status?: 'success' | 'error') => void + sourceData: any, + targetData: any, + locale: string + // onProgress?: (processed: number, total: number, status?: 'success' | 'error') => void ): Promise { + // Extract data from sourceData - unified parameter pattern + const templates: mgmtApi.PageModel[] = sourceData || []; + const { sourceGuid, targetGuid, cachedApiClient: apiClient, overwrite } = state; + const logger = getLoggerForGuid(sourceGuid[0]); + + // console.log(`[Template Debug] Starting template processing. Found ${templates ? templates.length : 0} templates to process.`); + + if (!templates || templates.length === 0) { + console.log("No templates found to process."); + return { status: "success", successful: 0, failed: 0, skipped: 0 }; + } + + let successful = 0; + let failed = 0; + let skipped = 0; + let processedCount = 0; + const totalTemplates = templates.length; + let overallStatus: "success" | "error" = "success"; + const failureDetails: FailureDetail[] = []; + + for (let i = 0; i < templates.length; i++) { + let template = templates[i]; + let originalID = template.pageTemplateID; + let currentStatus: "success" | "error" = "success"; + let templateProcessed = false; + let payload: mgmtApi.PageModel | null = null; + + const { sourceGuid, targetGuid } = state; + const referenceMapper = new TemplateMapper(sourceGuid[0], targetGuid[0]); + + let existingMapping = referenceMapper.getTemplateMapping(template, "source"); + let targetTemplate = + targetData.find((targetTemplate) => targetTemplate.pageTemplateID === existingMapping?.targetPageTemplateID) || + null; + if (!targetTemplate) { + // Try to get the template via the mapper + targetTemplate = referenceMapper.getMappedEntity(existingMapping, "target"); + } - // Extract data from sourceData - unified parameter pattern - const templates: mgmtApi.PageModel[] = sourceData || []; - const { sourceGuid, targetGuid, cachedApiClient: apiClient, overwrite } = state; - const logger = getLoggerForGuid(sourceGuid[0]); - - // console.log(`[Template Debug] Starting template processing. Found ${templates ? templates.length : 0} templates to process.`); - - if (!templates || templates.length === 0) { - console.log('No templates found to process.'); - return { status: 'success', successful: 0, failed: 0, skipped: 0 }; + // Handle templates that exist in target but have no mapping (match by name) + // This ensures downstream pages can find their template mappings + if (!existingMapping && !targetTemplate) { + targetTemplate = targetData.find((t) => t.pageTemplateName === template.pageTemplateName) || null; + if (targetTemplate) { + // Create the mapping for existing target template + referenceMapper.addMapping(template, targetTemplate); + logger.template.skipped(template, "exists in target, mapping created", targetGuid[0]); + skipped++; + processedCount++; + continue; // Skip to next template - mapping is now created + } } - let successful = 0; - let failed = 0; - let skipped = 0; - let processedCount = 0; - const totalTemplates = templates.length; - let overallStatus: 'success' | 'error' = 'success'; - const failureDetails: FailureDetail[] = []; - - for (let i = 0; i < templates.length; i++) { - let template = templates[i]; - let originalID = template.pageTemplateID; - let currentStatus: 'success' | 'error' = 'success'; - let templateProcessed = false; - let payload: mgmtApi.PageModel | null = null; - - - const { sourceGuid, targetGuid } = state; - const referenceMapper = new TemplateMapper(sourceGuid[0], targetGuid[0]); - - let existingMapping = referenceMapper.getTemplateMapping(template, "source"); - let targetTemplate = targetData.find(targetTemplate => targetTemplate.pageTemplateID === existingMapping?.targetPageTemplateID) || null; - if (!targetTemplate) { - // Try to get the template via the mapper - targetTemplate = referenceMapper.getMappedEntity(existingMapping, "target"); - } + const isTargetSafe = existingMapping !== null && referenceMapper.hasTargetChanged(targetTemplate); + const hasSourceChanges = existingMapping !== null && referenceMapper.hasSourceChanged(template); + let shouldUpdate = existingMapping !== null && isTargetSafe && hasSourceChanges; + let shouldSkip = existingMapping !== null && !isTargetSafe && !hasSourceChanges; - // Handle templates that exist in target but have no mapping (match by name) - // This ensures downstream pages can find their template mappings - if (!existingMapping && !targetTemplate) { - targetTemplate = targetData.find(t => t.pageTemplateName === template.pageTemplateName) || null; - if (targetTemplate) { - // Create the mapping for existing target template - referenceMapper.addMapping(template, targetTemplate); - logger.template.skipped(template, "exists in target, mapping created", targetGuid[0]); - skipped++; - processedCount++; - continue; // Skip to next template - mapping is now created - } - } - - const isTargetSafe = existingMapping !== null && referenceMapper.hasTargetChanged(targetTemplate); - const hasSourceChanges = existingMapping !== null && referenceMapper.hasSourceChanged(template); - let shouldUpdate = existingMapping !== null && isTargetSafe && hasSourceChanges; - let shouldSkip = existingMapping !== null && !isTargetSafe && !hasSourceChanges; + if (overwrite && existingMapping && targetTemplate) { + shouldUpdate = true; + shouldSkip = false; + } - if (overwrite && existingMapping && targetTemplate) { - shouldUpdate = true; - shouldSkip = false; + if (shouldSkip) { + if (targetTemplate) { + referenceMapper.addMapping(template, targetTemplate); + } + logger.template.skipped(template, "up to date, skipping", targetGuid[0]); + skipped++; + } else { + let targetId = shouldUpdate ? targetTemplate.pageTemplateID : -1; + + // Prepare payload + const mappedSections = template.contentSectionDefinitions.map((def) => { + const mappedDef = { ...def }; + mappedDef.pageItemTemplateID = shouldUpdate ? def.pageItemTemplateID : -1; + mappedDef.pageTemplateID = targetId; + mappedDef.contentViewID = shouldUpdate ? def.contentViewID : 0; + + if (def.contentDefinitionID) { + const modelMappers = new ModelMapper(sourceGuid[0], targetGuid[0]); + const modelMapping = modelMappers.getModelMappingByID(def.contentDefinitionID, "source"); + if (modelMapping?.targetID) mappedDef.contentDefinitionID = modelMapping.targetID; } - - if (shouldSkip) { - if (targetTemplate) { - referenceMapper.addMapping(template, targetTemplate); - } - logger.template.skipped(template, "up to date, skipping", targetGuid[0]) - skipped++; - } else { - let targetId = shouldUpdate ? targetTemplate.pageTemplateID : -1; - - // Prepare payload - const mappedSections = template.contentSectionDefinitions.map(def => { - const mappedDef = { ...def }; - mappedDef.pageItemTemplateID = shouldUpdate ? def.pageItemTemplateID : -1; - mappedDef.pageTemplateID = targetId; - mappedDef.contentViewID = shouldUpdate ? def.contentViewID : 0; - - if (def.contentDefinitionID) { - const modelMappers = new ModelMapper(sourceGuid[0], targetGuid[0]); - const modelMapping = modelMappers.getModelMappingByID(def.contentDefinitionID, 'source'); - if (modelMapping?.targetID) mappedDef.contentDefinitionID = modelMapping.targetID; - } - if (def.itemContainerID) { - const containerMappers = new ContainerMapper(sourceGuid[0], targetGuid[0]); - const containerMapping = containerMappers.getContainerMappingByContentViewID(def.itemContainerID, 'source'); - if (containerMapping?.targetContentViewID) mappedDef.itemContainerID = containerMapping.targetContentViewID; - } - return mappedDef; - }); - - const payload = { - ...template, - pageTemplateID: targetId, - contentSectionDefinitions: mappedSections - }; - - try { - const savedTemplate = await apiClient.pageMethods.savePageTemplate(targetGuid[0], locale, payload); - referenceMapper.addMapping(template, savedTemplate); - const action = shouldUpdate ? 'updated' : 'created'; - logger.template[action](template, action, targetGuid[0]) - successful++; - } catch (error: any) { - logger.template.error(template, error, targetGuid[0]) - failed++; - currentStatus = 'error'; - overallStatus = 'error'; - failureDetails.push({ - name: template.pageTemplateName, - error: error?.message || String(error), - guid: sourceGuid[0], - }); - } + if (def.itemContainerID) { + const containerMappers = new ContainerMapper(sourceGuid[0], targetGuid[0]); + const containerMapping = containerMappers.getContainerMappingByContentViewID(def.itemContainerID, "source"); + if (containerMapping?.targetContentViewID) mappedDef.itemContainerID = containerMapping.targetContentViewID; } - - processedCount++; + return mappedDef; + }); + + const payload = { + ...template, + pageTemplateID: targetId, + contentSectionDefinitions: mappedSections, + }; + + try { + const savedTemplate = await apiClient.pageMethods.savePageTemplate(targetGuid[0], locale, payload); + referenceMapper.addMapping(template, savedTemplate); + const action = shouldUpdate ? "updated" : "created"; + logger.template[action](template, action, targetGuid[0]); + successful++; + } catch (error: any) { + logger.template.error(template, error, targetGuid[0]); + failed++; + currentStatus = "error"; + overallStatus = "error"; + failureDetails.push({ + name: template.pageTemplateName, + error: error?.message || String(error), + guid: sourceGuid[0], + }); + } } - return { status: overallStatus, successful, failed, skipped, failureDetails }; + processedCount++; + } + + return { status: overallStatus, successful, failed, skipped, failureDetails }; } diff --git a/src/lib/pushers/tests/asset-pusher.test.ts b/src/lib/pushers/tests/asset-pusher.test.ts index 3e4cdb3..5d19349 100644 --- a/src/lib/pushers/tests/asset-pusher.test.ts +++ b/src/lib/pushers/tests/asset-pusher.test.ts @@ -1,13 +1,13 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, setState, state, initializeGuidLogger } from 'core/state'; -import * as stateModule from 'core/state'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState, state, initializeGuidLogger } from "core/state"; +import * as stateModule from "core/state"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-asset-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-asset-")); }); afterAll(() => { @@ -16,11 +16,11 @@ afterAll(() => { beforeEach(() => { resetState(); - setState({ rootPath: tmpDir, sourceGuid: 'src-guid-u', targetGuid: 'tgt-guid-u', token: 'test-token' }); - initializeGuidLogger('src-guid-u', 'push'); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + setState({ rootPath: tmpDir, sourceGuid: "src-guid-u", targetGuid: "tgt-guid-u", token: "test-token" }); + initializeGuidLogger("src-guid-u", "push"); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -32,9 +32,9 @@ afterEach(() => { function makeMedia(overrides: Record = {}): any { return { mediaID: 1, - fileName: 'test.jpg', - originUrl: 'https://example.com/assets/test.jpg', - originKey: '/assets/test.jpg', + fileName: "test.jpg", + originUrl: "https://example.com/assets/test.jpg", + originKey: "/assets/test.jpg", mediaGroupingID: 0, mediaGroupingName: null, ...overrides, @@ -44,7 +44,7 @@ function makeMedia(overrides: Record = {}): any { function makeMockApiClient(overrides: Record = {}): any { return { assetMethods: { - getDefaultContainer: jest.fn().mockResolvedValue({ containerID: 1, name: 'default' }), + getDefaultContainer: jest.fn().mockResolvedValue({ containerID: 1, name: "default" }), getGalleryByName: jest.fn().mockResolvedValue(null), ...overrides, }, @@ -53,64 +53,64 @@ function makeMockApiClient(overrides: Record = {}): any { // ─── pushAssets — empty sourceData guard ───────────────────────────────────── -describe('pushAssets — empty sourceData guard', () => { - it('returns success with zeros when sourceData is empty array', async () => { - const { pushAssets } = await import('../asset-pusher'); +describe("pushAssets — empty sourceData guard", () => { + it("returns success with zeros when sourceData is empty array", async () => { + const { pushAssets } = await import("../asset-pusher"); const result = await pushAssets([], []); - expect(result.status).toBe('success'); + expect(result.status).toBe("success"); expect(result.successful).toBe(0); expect(result.failed).toBe(0); expect(result.skipped).toBe(0); }); - it('returns success with zeros when sourceData is null/undefined coerced to empty', async () => { - const { pushAssets } = await import('../asset-pusher'); + it("returns success with zeros when sourceData is null/undefined coerced to empty", async () => { + const { pushAssets } = await import("../asset-pusher"); const result = await pushAssets(null as any, []); - expect(result.status).toBe('success'); + expect(result.status).toBe("success"); expect(result.successful).toBe(0); }); it('logs "No assets found" when sourceData is empty', async () => { - const consoleSpy = jest.spyOn(console, 'log'); - const { pushAssets } = await import('../asset-pusher'); + const consoleSpy = jest.spyOn(console, "log"); + const { pushAssets } = await import("../asset-pusher"); await pushAssets([], []); - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No assets')); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("No assets")); }); }); // ─── pushAssets — API error fetching default container ─────────────────────── -describe('pushAssets — API error on getDefaultContainer', () => { - it('returns error status when getDefaultContainer throws', async () => { +describe("pushAssets — API error on getDefaultContainer", () => { + it("returns error status when getDefaultContainer throws", async () => { const mockApiClient = makeMockApiClient({ - getDefaultContainer: jest.fn().mockRejectedValue(new Error('Network error')), + getDefaultContainer: jest.fn().mockRejectedValue(new Error("Network error")), }); - jest.spyOn(stateModule, 'getApiClient').mockReturnValue(mockApiClient); + jest.spyOn(stateModule, "getApiClient").mockReturnValue(mockApiClient); - const { pushAssets } = await import('../asset-pusher'); + const { pushAssets } = await import("../asset-pusher"); const media = makeMedia(); const result = await pushAssets([media], []); - expect(result.status).toBe('error'); + expect(result.status).toBe("error"); expect(result.successful).toBe(0); }); - it('calls console.error when getDefaultContainer throws', async () => { - const consoleSpy = jest.spyOn(console, 'error'); + it("calls console.error when getDefaultContainer throws", async () => { + const consoleSpy = jest.spyOn(console, "error"); const mockApiClient = makeMockApiClient({ - getDefaultContainer: jest.fn().mockRejectedValue(new Error('timeout')), + getDefaultContainer: jest.fn().mockRejectedValue(new Error("timeout")), }); - jest.spyOn(stateModule, 'getApiClient').mockReturnValue(mockApiClient); + jest.spyOn(stateModule, "getApiClient").mockReturnValue(mockApiClient); - const { pushAssets } = await import('../asset-pusher'); + const { pushAssets } = await import("../asset-pusher"); await pushAssets([makeMedia()], []); expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('Error fetching default asset container'), + expect.stringContaining("Error fetching default asset container"), expect.any(String) ); }); @@ -118,14 +118,14 @@ describe('pushAssets — API error on getDefaultContainer', () => { // ─── pushAssets — skip when asset exists in target by originKey ─────────────── -describe('pushAssets — skip when asset exists in target by originKey', () => { - it('skips asset that matches target by originKey (no mapping exists)', async () => { +describe("pushAssets — skip when asset exists in target by originKey", () => { + it("skips asset that matches target by originKey (no mapping exists)", async () => { const mockApiClient = makeMockApiClient(); - jest.spyOn(stateModule, 'getApiClient').mockReturnValue(mockApiClient); + jest.spyOn(stateModule, "getApiClient").mockReturnValue(mockApiClient); - const { pushAssets } = await import('../asset-pusher'); + const { pushAssets } = await import("../asset-pusher"); - const originKey = '/assets/shared.jpg'; + const originKey = "/assets/shared.jpg"; const sourceAsset = makeMedia({ originKey, mediaID: 10 }); const targetAsset = makeMedia({ originKey, mediaID: 20 }); @@ -139,18 +139,18 @@ describe('pushAssets — skip when asset exists in target by originKey', () => { // ─── pushAssets — onProgress callback ──────────────────────────────────────── -describe('pushAssets — onProgress callback', () => { - it('calls onProgress once per asset processed', async () => { +describe("pushAssets — onProgress callback", () => { + it("calls onProgress once per asset processed", async () => { // Asset will fail (no local file), but onProgress still fires const mockApiClient = makeMockApiClient(); - jest.spyOn(stateModule, 'getApiClient').mockReturnValue(mockApiClient); + jest.spyOn(stateModule, "getApiClient").mockReturnValue(mockApiClient); - const { pushAssets } = await import('../asset-pusher'); + const { pushAssets } = await import("../asset-pusher"); const onProgress = jest.fn(); const media = makeMedia({ - originUrl: 'https://example.com/nonexistent-file.jpg', - originKey: '/nonexistent-file.jpg', + originUrl: "https://example.com/nonexistent-file.jpg", + originKey: "/nonexistent-file.jpg", }); await pushAssets([media], [], onProgress); @@ -159,17 +159,17 @@ describe('pushAssets — onProgress callback', () => { expect(onProgress).toHaveBeenCalledWith(1, 1, expect.any(String)); }); - it('calls onProgress with correct total count for multiple assets', async () => { + it("calls onProgress with correct total count for multiple assets", async () => { const mockApiClient = makeMockApiClient(); - jest.spyOn(stateModule, 'getApiClient').mockReturnValue(mockApiClient); + jest.spyOn(stateModule, "getApiClient").mockReturnValue(mockApiClient); - const { pushAssets } = await import('../asset-pusher'); + const { pushAssets } = await import("../asset-pusher"); const onProgress = jest.fn(); - const originKey1 = '/a1.jpg'; - const originKey2 = '/a2.jpg'; - const src1 = makeMedia({ mediaID: 1, originKey: originKey1, originUrl: 'https://example.com/a1.jpg' }); - const src2 = makeMedia({ mediaID: 2, originKey: originKey2, originUrl: 'https://example.com/a2.jpg' }); + const originKey1 = "/a1.jpg"; + const originKey2 = "/a2.jpg"; + const src1 = makeMedia({ mediaID: 1, originKey: originKey1, originUrl: "https://example.com/a1.jpg" }); + const src2 = makeMedia({ mediaID: 2, originKey: originKey2, originUrl: "https://example.com/a2.jpg" }); // Put matching assets in target so they get skipped const tgt1 = makeMedia({ mediaID: 11, originKey: originKey1 }); @@ -185,14 +185,14 @@ describe('pushAssets — onProgress callback', () => { // ─── pushAssets — result shape ──────────────────────────────────────────────── -describe('pushAssets — result shape', () => { - it('returns status, successful, failed, skipped fields', async () => { - const { pushAssets } = await import('../asset-pusher'); +describe("pushAssets — result shape", () => { + it("returns status, successful, failed, skipped fields", async () => { + const { pushAssets } = await import("../asset-pusher"); const result = await pushAssets([], []); - expect(result).toHaveProperty('status'); - expect(result).toHaveProperty('successful'); - expect(result).toHaveProperty('failed'); - expect(result).toHaveProperty('skipped'); + expect(result).toHaveProperty("status"); + expect(result).toHaveProperty("successful"); + expect(result).toHaveProperty("failed"); + expect(result).toHaveProperty("skipped"); }); }); diff --git a/src/lib/pushers/tests/batch-polling.test.ts b/src/lib/pushers/tests/batch-polling.test.ts index 80bad09..0e7b622 100644 --- a/src/lib/pushers/tests/batch-polling.test.ts +++ b/src/lib/pushers/tests/batch-polling.test.ts @@ -1,15 +1,15 @@ -import { resetState } from 'core/state'; -import { extractContentBatchResults, extractPageBatchResults, logBatchError, CompletedBatch } from '../batch-polling'; -import * as mgmtApi from '@agility/management-sdk'; +import { resetState } from "core/state"; +import { extractContentBatchResults, extractPageBatchResults, logBatchError, CompletedBatch } from "../batch-polling"; +import * as mgmtApi from "@agility/management-sdk"; const asBatch = (obj: Record): CompletedBatch => obj as CompletedBatch; const asContent = (obj: Record): mgmtApi.ContentItem => obj as mgmtApi.ContentItem; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -18,19 +18,19 @@ afterEach(() => { // ─── extractContentBatchResults — no batch items returned ──────────────────────────── -describe('extractContentBatchResults — no items in batch', () => { - it('marks all originalItems as failed when batch has no items array', () => { +describe("extractContentBatchResults — no items in batch", () => { + it("marks all originalItems as failed when batch has no items array", () => { const originals = [{ contentID: 1 }, { contentID: 2 }] as mgmtApi.ContentItem[]; const result = extractContentBatchResults(asBatch({}), originals); expect(result.failedItems).toHaveLength(2); expect(result.successfulItems).toHaveLength(0); result.failedItems.forEach((f) => { - expect(f.error).toBe('No batch items returned'); + expect(f.error).toBe("No batch items returned"); }); }); - it('marks all originalItems as failed when batch.items is null', () => { + it("marks all originalItems as failed when batch.items is null", () => { const originals = [asContent({ contentID: 1 })] as mgmtApi.ContentItem[]; const result = extractContentBatchResults(asBatch({ items: null }), originals); @@ -38,7 +38,7 @@ describe('extractContentBatchResults — no items in batch', () => { expect(result.successfulItems).toHaveLength(0); }); - it('returns empty summary when batch has no totalItems field', () => { + it("returns empty summary when batch has no totalItems field", () => { const result = extractContentBatchResults(asBatch({}), []); expect(result.summary).toBeUndefined(); }); @@ -46,8 +46,8 @@ describe('extractContentBatchResults — no items in batch', () => { // ─── extractContentBatchResults — legacy items array (happy path) ──────────────────── -describe('extractContentBatchResults — legacy items array', () => { - it('classifies items with itemID > 0 as successful', () => { +describe("extractContentBatchResults — legacy items array", () => { + it("classifies items with itemID > 0 as successful", () => { const batch = { items: [ { itemID: 101, processedItemVersionID: 1 }, @@ -63,7 +63,7 @@ describe('extractContentBatchResults — legacy items array', () => { expect(result.successfulItems[1].newId).toBe(102); }); - it('preserves originalItem reference in successful items', () => { + it("preserves originalItem reference in successful items", () => { const original = { contentID: 99 } as mgmtApi.ContentItem; const batch = { items: [{ itemID: 200, processedItemVersionID: 1 }] }; const result = extractContentBatchResults(asBatch(batch), [original]); @@ -71,7 +71,7 @@ describe('extractContentBatchResults — legacy items array', () => { expect(result.successfulItems[0].originalItem).toBe(original); }); - it('classifies items with itemID <= 0 as failed', () => { + it("classifies items with itemID <= 0 as failed", () => { const batch = { items: [{ itemID: 0 }] }; const originals = [asContent({ contentID: 1 })] as mgmtApi.ContentItem[]; const result = extractContentBatchResults(asBatch(batch), originals); @@ -80,23 +80,23 @@ describe('extractContentBatchResults — legacy items array', () => { expect(result.successfulItems).toHaveLength(0); }); - it('uses errorMessage from item when available', () => { + it("uses errorMessage from item when available", () => { const batch = { items: [{ itemID: 0, errorMessage: '{"message":"field too long"}' }], }; const result = extractContentBatchResults(asBatch(batch), [asContent({ contentID: 1 })]); - expect(result.failedItems[0].error).toBe('field too long'); + expect(result.failedItems[0].error).toBe("field too long"); }); - it('uses fallback error message when errorMessage is absent', () => { + it("uses fallback error message when errorMessage is absent", () => { const batch = { items: [{ itemID: -1 }] }; const result = extractContentBatchResults(asBatch(batch), [asContent({ contentID: 1 })]); - expect(result.failedItems[0].error).toContain('Invalid ID'); + expect(result.failedItems[0].error).toContain("Invalid ID"); }); - it('marks item as failed when itemNull is set even if itemID > 0', () => { + it("marks item as failed when itemNull is set even if itemID > 0", () => { const batch = { items: [{ itemID: 5, itemNull: true }] }; const result = extractContentBatchResults(asBatch(batch), [asContent({ contentID: 1 })]); @@ -107,27 +107,23 @@ describe('extractContentBatchResults — legacy items array', () => { // ─── extractContentBatchResults — batch with extra failedItems field ───────────────── -describe('extractContentBatchResults — batch with failedItems field', () => { - it('classifies items by itemID even when batch has a failedItems field', () => { +describe("extractContentBatchResults — batch with failedItems field", () => { + it("classifies items by itemID even when batch has a failedItems field", () => { const batch = { failedItems: [ - { batchItemId: 1, errorMessage: 'Validation error', errorType: 'ValidationException', itemType: 'Content' }, - ], - items: [ - { itemID: 0, batchItemID: 1 }, + { batchItemId: 1, errorMessage: "Validation error", errorType: "ValidationException", itemType: "Content" }, ], + items: [{ itemID: 0, batchItemID: 1 }], }; const result = extractContentBatchResults(asBatch(batch), [asContent({ contentID: 10 })]); expect(result.failedItems).toHaveLength(1); - expect(result.failedItems[0].error).toContain('Validation error'); + expect(result.failedItems[0].error).toContain("Validation error"); }); - it('marks items with itemID > 0 as successful even when a failedItems field is present', () => { + it("marks items with itemID > 0 as successful even when a failedItems field is present", () => { const batch = { - failedItems: [ - { batchItemId: 1, errorMessage: 'error', errorType: 'Error', itemType: 'Content' }, - ], + failedItems: [{ batchItemId: 1, errorMessage: "error", errorType: "Error", itemType: "Content" }], items: [ { itemID: 0, batchItemID: 1 }, { itemID: 200, batchItemID: 2 }, @@ -144,18 +140,14 @@ describe('extractContentBatchResults — batch with failedItems field', () => { // ─── extractContentBatchResults — summary field ────────────────────────────────────── -describe('extractContentBatchResults — summary', () => { - it('includes summary when batch has totalItems', () => { +describe("extractContentBatchResults — summary", () => { + it("includes summary when batch has totalItems", () => { const batch = { totalItems: 3, successCount: 2, failureCount: 1, durationMs: 500, - items: [ - { itemID: 1, processedItemVersionID: 1 }, - { itemID: 2, processedItemVersionID: 1 }, - { itemID: 0 }, - ], + items: [{ itemID: 1, processedItemVersionID: 1 }, { itemID: 2, processedItemVersionID: 1 }, { itemID: 0 }], }; const originals = [{ contentID: 1 }, { contentID: 2 }, { contentID: 3 }] as mgmtApi.ContentItem[]; const result = extractContentBatchResults(asBatch(batch), originals); @@ -167,7 +159,7 @@ describe('extractContentBatchResults — summary', () => { expect(result.summary!.durationMs).toBe(500); }); - it('defaults successCount and failureCount to 0 when missing from batch', () => { + it("defaults successCount and failureCount to 0 when missing from batch", () => { const batch = { totalItems: 1, items: [{ itemID: 50, processedItemVersionID: 1 }] }; const result = extractContentBatchResults(asBatch(batch), [asContent({ contentID: 1 })]); @@ -178,13 +170,13 @@ describe('extractContentBatchResults — summary', () => { // ─── extractContentBatchResults — empty originalItems edge cases ───────────────────── -describe('extractContentBatchResults — edge cases', () => { - it('handles empty originals array without throwing', () => { +describe("extractContentBatchResults — edge cases", () => { + it("handles empty originals array without throwing", () => { const batch = { items: [] }; expect(() => extractContentBatchResults(asBatch(batch), [])).not.toThrow(); }); - it('returns empty results for empty batch and empty originals', () => { + it("returns empty results for empty batch and empty originals", () => { const result = extractContentBatchResults(asBatch({ items: [] }), []); expect(result.successfulItems).toHaveLength(0); expect(result.failedItems).toHaveLength(0); @@ -193,8 +185,8 @@ describe('extractContentBatchResults — edge cases', () => { // ─── extractPageBatchResults ────────────────────────────────────────────────── -describe('extractPageBatchResults', () => { - it('classifies page items with itemID > 0 as successful', () => { +describe("extractPageBatchResults", () => { + it("classifies page items with itemID > 0 as successful", () => { const batch = { items: [{ itemID: 10 }] }; const originals = [{ pageID: 1 }] as mgmtApi.PageItem[]; const result = extractPageBatchResults(asBatch(batch), originals); @@ -203,15 +195,15 @@ describe('extractPageBatchResults', () => { expect(result.successfulItems[0].newId).toBe(10); }); - it('preserves the PageItem reference in originalItem', () => { - const original = { pageID: 99, title: 'Home' } as mgmtApi.PageItem; + it("preserves the PageItem reference in originalItem", () => { + const original = { pageID: 99, title: "Home" } as mgmtApi.PageItem; const batch = { items: [{ itemID: 10 }] }; const result = extractPageBatchResults(asBatch(batch), [original]); expect(result.successfulItems[0].originalItem).toBe(original); }); - it('classifies page items with itemID <= 0 as failed', () => { + it("classifies page items with itemID <= 0 as failed", () => { const batch = { items: [{ itemID: 0 }] }; const result = extractPageBatchResults(asBatch(batch), [{ pageID: 1 }] as mgmtApi.PageItem[]); @@ -219,37 +211,37 @@ describe('extractPageBatchResults', () => { expect(result.successfulItems).toHaveLength(0); }); - it('marks all pages as failed when batch has no items array', () => { + it("marks all pages as failed when batch has no items array", () => { const originals = [{ pageID: 1 }, { pageID: 2 }] as mgmtApi.PageItem[]; const result = extractPageBatchResults(asBatch({}), originals); expect(result.failedItems).toHaveLength(2); - expect(result.failedItems[0].error).toBe('No batch items returned'); + expect(result.failedItems[0].error).toBe("No batch items returned"); expect(result.successfulItems).toHaveLength(0); }); - it('uses errorMessage from item when available', () => { + it("uses errorMessage from item when available", () => { const batch = { items: [{ itemID: 0, errorMessage: '{"message":"page save failed"}' }] }; const result = extractPageBatchResults(asBatch(batch), [{ pageID: 1 }] as mgmtApi.PageItem[]); - expect(result.failedItems[0].error).toBe('page save failed'); + expect(result.failedItems[0].error).toBe("page save failed"); }); - it('classifies pages by itemID even when batch has a failedItems field', () => { + it("classifies pages by itemID even when batch has a failedItems field", () => { const batch = { failedItems: [ - { batchItemId: 1, errorMessage: 'Page validation error', errorType: 'ValidationException', itemType: 'Page' }, + { batchItemId: 1, errorMessage: "Page validation error", errorType: "ValidationException", itemType: "Page" }, ], items: [{ itemID: 0, batchItemID: 1 }], }; const result = extractPageBatchResults(asBatch(batch), [{ pageID: 1 }] as mgmtApi.PageItem[]); - expect(result.failedItems[0].error).toContain('Page validation error'); + expect(result.failedItems[0].error).toContain("Page validation error"); }); - it('marks pages with itemID > 0 as successful even when a failedItems field is present', () => { + it("marks pages with itemID > 0 as successful even when a failedItems field is present", () => { const batch = { - failedItems: [{ batchItemId: 1, errorMessage: 'error', errorType: 'Error', itemType: 'Page' }], + failedItems: [{ batchItemId: 1, errorMessage: "error", errorType: "Error", itemType: "Page" }], items: [ { itemID: 0, batchItemID: 1 }, { itemID: 50, batchItemID: 2 }, @@ -263,7 +255,7 @@ describe('extractPageBatchResults', () => { expect(result.failedItems).toHaveLength(1); }); - it('includes summary when batch has totalItems', () => { + it("includes summary when batch has totalItems", () => { const batch = { totalItems: 2, successCount: 1, @@ -283,36 +275,30 @@ describe('extractPageBatchResults', () => { // ─── logBatchError ───────────────────────────────────────────────────────────── -describe('logBatchError', () => { - it('logs error message for a failed batch item', () => { - const consoleSpy = jest.spyOn(console, 'error'); - logBatchError({ itemID: 0, errorMessage: 'Something went wrong' }, 0); +describe("logBatchError", () => { + it("logs error message for a failed batch item", () => { + const consoleSpy = jest.spyOn(console, "error"); + logBatchError({ itemID: 0, errorMessage: "Something went wrong" }, 0); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('Item 0') - ); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Item 0")); }); - it('logs batch item details', () => { - const consoleSpy = jest.spyOn(console, 'log'); - logBatchError({ itemID: 5, errorMessage: 'error' }, 0); + it("logs batch item details", () => { + const consoleSpy = jest.spyOn(console, "log"); + logBatchError({ itemID: 5, errorMessage: "error" }, 0); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('Batch Item Details') - ); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Batch Item Details")); }); - it('does not throw when called without originalPayload', () => { - expect(() => logBatchError({ itemID: 1, errorMessage: 'error' }, 0)).not.toThrow(); + it("does not throw when called without originalPayload", () => { + expect(() => logBatchError({ itemID: 1, errorMessage: "error" }, 0)).not.toThrow(); }); - it('logs originalPayload when provided', () => { - const consoleSpy = jest.spyOn(console, 'log'); - const payload = { contentID: 42, properties: { referenceName: 'test-ref' } }; - logBatchError({ itemID: 0, errorMessage: 'error' }, 0, payload); + it("logs originalPayload when provided", () => { + const consoleSpy = jest.spyOn(console, "log"); + const payload = { contentID: 42, properties: { referenceName: "test-ref" } }; + logBatchError({ itemID: 0, errorMessage: "error" }, 0, payload); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('Original Payload') - ); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Original Payload")); }); }); diff --git a/src/lib/pushers/tests/container-pusher.test.ts b/src/lib/pushers/tests/container-pusher.test.ts index bc4bed4..c615306 100644 --- a/src/lib/pushers/tests/container-pusher.test.ts +++ b/src/lib/pushers/tests/container-pusher.test.ts @@ -1,12 +1,12 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, setState, state, initializeGuidLogger } from 'core/state'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState, state, initializeGuidLogger } from "core/state"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-cont-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-cont-")); }); afterAll(() => { @@ -15,11 +15,11 @@ afterAll(() => { beforeEach(() => { resetState(); - setState({ rootPath: tmpDir, sourceGuid: 'src-cont-u', targetGuid: 'tgt-cont-u', token: 'test-token' }); - initializeGuidLogger('src-cont-u', 'push'); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + setState({ rootPath: tmpDir, sourceGuid: "src-cont-u", targetGuid: "tgt-cont-u", token: "test-token" }); + initializeGuidLogger("src-cont-u", "push"); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -45,42 +45,42 @@ function makeContainer(overrides: Record = {}): any { // ─── pushContainers — empty sourceData guard ────────────────────────────────── -describe('pushContainers — empty sourceData guard', () => { - it('returns success with zeros when sourceData is empty', async () => { - const { pushContainers } = await import('../container-pusher'); +describe("pushContainers — empty sourceData guard", () => { + it("returns success with zeros when sourceData is empty", async () => { + const { pushContainers } = await import("../container-pusher"); const result = await pushContainers([], []); - expect(result.status).toBe('success'); + expect(result.status).toBe("success"); expect(result.successful).toBe(0); expect(result.failed).toBe(0); expect(result.skipped).toBe(0); }); - it('returns success with zeros when sourceData is null', async () => { - const { pushContainers } = await import('../container-pusher'); + it("returns success with zeros when sourceData is null", async () => { + const { pushContainers } = await import("../container-pusher"); const result = await pushContainers(null as any, []); - expect(result.status).toBe('success'); + expect(result.status).toBe("success"); expect(result.successful).toBe(0); }); }); // ─── pushContainers — special containers are skipped ────────────────────────── -describe('pushContainers — built-in Agility containers are skipped', () => { +describe("pushContainers — built-in Agility containers are skipped", () => { it.each([ - 'AgilityCSSFiles', - 'AgilityJavascriptFiles', - 'AgilityGlobalCodeTemplates', - 'AgilityModuleCodeTemplates', - 'AgilityPageCodeTemplates', - ])('skips %s without calling the API', async (referenceName) => { + "AgilityCSSFiles", + "AgilityJavascriptFiles", + "AgilityGlobalCodeTemplates", + "AgilityModuleCodeTemplates", + "AgilityPageCodeTemplates", + ])("skips %s without calling the API", async (referenceName) => { const saveContainer = jest.fn().mockResolvedValue(makeContainer()); state.cachedApiClient = { containerMethods: { saveContainer }, } as any; - const { pushContainers } = await import('../container-pusher'); + const { pushContainers } = await import("../container-pusher"); const specialContainer = makeContainer({ referenceName }); @@ -94,14 +94,14 @@ describe('pushContainers — built-in Agility containers are skipped', () => { // ─── pushContainers — shouldCreate path: no model mapping ───────────────────── -describe('pushContainers — create path: no model mapping', () => { - it('skips container when no target model mapping found', async () => { +describe("pushContainers — create path: no model mapping", () => { + it("skips container when no target model mapping found", async () => { const saveContainer = jest.fn().mockResolvedValue(makeContainer()); state.cachedApiClient = { containerMethods: { saveContainer }, } as any; - const { pushContainers } = await import('../container-pusher'); + const { pushContainers } = await import("../container-pusher"); // Container with contentDefinitionID that has no model mapping const sourceContainer = makeContainer({ contentDefinitionID: 9999 }); @@ -116,15 +116,15 @@ describe('pushContainers — create path: no model mapping', () => { // ─── pushContainers — special case: contentDefinitionID === 1 ───────────────── -describe('pushContainers — RichTextArea special case', () => { - it('attempts to create container when contentDefinitionID is 1 (RichTextArea)', async () => { +describe("pushContainers — RichTextArea special case", () => { + it("attempts to create container when contentDefinitionID is 1 (RichTextArea)", async () => { const newContainer = makeContainer({ contentViewID: 500, contentDefinitionID: 1 }); const saveContainer = jest.fn().mockResolvedValue(newContainer); state.cachedApiClient = { containerMethods: { saveContainer }, } as any; - const { pushContainers } = await import('../container-pusher'); + const { pushContainers } = await import("../container-pusher"); const sourceContainer = makeContainer({ contentDefinitionID: 1 }); @@ -136,13 +136,13 @@ describe('pushContainers — RichTextArea special case', () => { expect(result.failed).toBe(0); }); - it('counts as failed when saveContainer throws', async () => { - const saveContainer = jest.fn().mockRejectedValue(new Error('API error')); + it("counts as failed when saveContainer throws", async () => { + const saveContainer = jest.fn().mockRejectedValue(new Error("API error")); state.cachedApiClient = { containerMethods: { saveContainer }, } as any; - const { pushContainers } = await import('../container-pusher'); + const { pushContainers } = await import("../container-pusher"); const sourceContainer = makeContainer({ contentDefinitionID: 1 }); @@ -150,20 +150,20 @@ describe('pushContainers — RichTextArea special case', () => { expect(result.failed).toBe(1); expect(result.successful).toBe(0); - expect(result.status).toBe('error'); + expect(result.status).toBe("error"); }); }); // ─── pushContainers — result shape ──────────────────────────────────────────── -describe('pushContainers — result shape', () => { - it('result has status, successful, failed, skipped fields', async () => { - const { pushContainers } = await import('../container-pusher'); +describe("pushContainers — result shape", () => { + it("result has status, successful, failed, skipped fields", async () => { + const { pushContainers } = await import("../container-pusher"); const result = await pushContainers([], []); - expect(result).toHaveProperty('status'); - expect(result).toHaveProperty('successful'); - expect(result).toHaveProperty('failed'); - expect(result).toHaveProperty('skipped'); + expect(result).toHaveProperty("status"); + expect(result).toHaveProperty("successful"); + expect(result).toHaveProperty("failed"); + expect(result).toHaveProperty("skipped"); }); }); diff --git a/src/lib/pushers/tests/gallery-pusher.test.ts b/src/lib/pushers/tests/gallery-pusher.test.ts index 4f2dc01..c0ef769 100644 --- a/src/lib/pushers/tests/gallery-pusher.test.ts +++ b/src/lib/pushers/tests/gallery-pusher.test.ts @@ -1,13 +1,13 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, setState, state, initializeGuidLogger } from 'core/state'; -import * as stateModule from 'core/state'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState, state, initializeGuidLogger } from "core/state"; +import * as stateModule from "core/state"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-gallery-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-gallery-")); }); afterAll(() => { @@ -16,11 +16,11 @@ afterAll(() => { beforeEach(() => { resetState(); - setState({ rootPath: tmpDir, sourceGuid: 'src-gal-u', targetGuid: 'tgt-gal-u', token: 'test-token' }); - initializeGuidLogger('src-gal-u', 'push'); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + setState({ rootPath: tmpDir, sourceGuid: "src-gal-u", targetGuid: "tgt-gal-u", token: "test-token" }); + initializeGuidLogger("src-gal-u", "push"); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -59,50 +59,50 @@ function makeApiClient(saveGalleryImpl?: jest.Mock): any { // ─── pushGalleries — empty sourceData guard ─────────────────────────────────── -describe('pushGalleries — empty sourceData guard', () => { - it('returns success with zeros when sourceData is empty', async () => { - jest.spyOn(stateModule, 'getApiClient').mockReturnValue(makeApiClient()); +describe("pushGalleries — empty sourceData guard", () => { + it("returns success with zeros when sourceData is empty", async () => { + jest.spyOn(stateModule, "getApiClient").mockReturnValue(makeApiClient()); - const { pushGalleries } = await import('../gallery-pusher'); + const { pushGalleries } = await import("../gallery-pusher"); const result = await pushGalleries([], []); - expect(result.status).toBe('success'); + expect(result.status).toBe("success"); expect(result.successful).toBe(0); expect(result.failed).toBe(0); expect(result.skipped).toBe(0); }); - it('returns success with zeros when sourceData is null', async () => { - jest.spyOn(stateModule, 'getApiClient').mockReturnValue(makeApiClient()); + it("returns success with zeros when sourceData is null", async () => { + jest.spyOn(stateModule, "getApiClient").mockReturnValue(makeApiClient()); - const { pushGalleries } = await import('../gallery-pusher'); + const { pushGalleries } = await import("../gallery-pusher"); const result = await pushGalleries(null as any, []); - expect(result.status).toBe('success'); + expect(result.status).toBe("success"); expect(result.successful).toBe(0); }); it('logs "No galleries found" when empty', async () => { - jest.spyOn(stateModule, 'getApiClient').mockReturnValue(makeApiClient()); + jest.spyOn(stateModule, "getApiClient").mockReturnValue(makeApiClient()); - const consoleSpy = jest.spyOn(console, 'log'); - const { pushGalleries } = await import('../gallery-pusher'); + const consoleSpy = jest.spyOn(console, "log"); + const { pushGalleries } = await import("../gallery-pusher"); await pushGalleries([], []); - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No galleries')); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("No galleries")); }); }); // ─── pushGalleries — skip when gallery already exists in target by name ──────── -describe('pushGalleries — skip when gallery exists in target by name', () => { - it('skips gallery that already exists in target by name when no mapping exists', async () => { - jest.spyOn(stateModule, 'getApiClient').mockReturnValue(makeApiClient()); +describe("pushGalleries — skip when gallery exists in target by name", () => { + it("skips gallery that already exists in target by name when no mapping exists", async () => { + jest.spyOn(stateModule, "getApiClient").mockReturnValue(makeApiClient()); - const { pushGalleries } = await import('../gallery-pusher'); + const { pushGalleries } = await import("../gallery-pusher"); - const sourceGallery = makeGallery({ name: 'Shared Gallery' }); - const targetGallery = makeGallery({ name: 'Shared Gallery' }); + const sourceGallery = makeGallery({ name: "Shared Gallery" }); + const targetGallery = makeGallery({ name: "Shared Gallery" }); const result = await pushGalleries([sourceGallery], [targetGallery]); @@ -113,14 +113,14 @@ describe('pushGalleries — skip when gallery exists in target by name', () => { // ─── pushGalleries — create new gallery ─────────────────────────────────────── -describe('pushGalleries — create new gallery', () => { - it('calls saveGallery when gallery does not exist in target', async () => { +describe("pushGalleries — create new gallery", () => { + it("calls saveGallery when gallery does not exist in target", async () => { const saveMock = jest.fn().mockResolvedValue(makeGallery({ mediaGroupingID: 999 })); - jest.spyOn(stateModule, 'getApiClient').mockReturnValue(makeApiClient(saveMock)); + jest.spyOn(stateModule, "getApiClient").mockReturnValue(makeApiClient(saveMock)); - const { pushGalleries } = await import('../gallery-pusher'); + const { pushGalleries } = await import("../gallery-pusher"); - const sourceGallery = makeGallery({ name: 'Brand New Gallery' }); + const sourceGallery = makeGallery({ name: "Brand New Gallery" }); const result = await pushGalleries([sourceGallery], []); @@ -129,34 +129,34 @@ describe('pushGalleries — create new gallery', () => { expect(result.failed).toBe(0); }); - it('marks as failed and returns error status when saveGallery throws', async () => { - const saveMock = jest.fn().mockRejectedValue(new Error('API error creating gallery')); - jest.spyOn(stateModule, 'getApiClient').mockReturnValue(makeApiClient(saveMock)); + it("marks as failed and returns error status when saveGallery throws", async () => { + const saveMock = jest.fn().mockRejectedValue(new Error("API error creating gallery")); + jest.spyOn(stateModule, "getApiClient").mockReturnValue(makeApiClient(saveMock)); - const { pushGalleries } = await import('../gallery-pusher'); + const { pushGalleries } = await import("../gallery-pusher"); - const sourceGallery = makeGallery({ name: 'Error Gallery' }); + const sourceGallery = makeGallery({ name: "Error Gallery" }); const result = await pushGalleries([sourceGallery], []); expect(result.failed).toBe(1); - expect(result.status).toBe('error'); + expect(result.status).toBe("error"); expect(result.successful).toBe(0); }); }); // ─── pushGalleries — result shape ────────────────────────────────────────────── -describe('pushGalleries — result shape', () => { - it('returns status, successful, failed, skipped fields', async () => { - jest.spyOn(stateModule, 'getApiClient').mockReturnValue(makeApiClient()); +describe("pushGalleries — result shape", () => { + it("returns status, successful, failed, skipped fields", async () => { + jest.spyOn(stateModule, "getApiClient").mockReturnValue(makeApiClient()); - const { pushGalleries } = await import('../gallery-pusher'); + const { pushGalleries } = await import("../gallery-pusher"); const result = await pushGalleries([], []); - expect(result).toHaveProperty('status'); - expect(result).toHaveProperty('successful'); - expect(result).toHaveProperty('failed'); - expect(result).toHaveProperty('skipped'); + expect(result).toHaveProperty("status"); + expect(result).toHaveProperty("successful"); + expect(result).toHaveProperty("failed"); + expect(result).toHaveProperty("skipped"); }); }); diff --git a/src/lib/pushers/tests/guid-data-loader.test.ts b/src/lib/pushers/tests/guid-data-loader.test.ts index 010e47a..9b9d500 100644 --- a/src/lib/pushers/tests/guid-data-loader.test.ts +++ b/src/lib/pushers/tests/guid-data-loader.test.ts @@ -1,13 +1,13 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, setState, state } from 'core/state'; -import { GuidDataLoader } from '../guid-data-loader'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState, state } from "core/state"; +import { GuidDataLoader } from "../guid-data-loader"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-gdl-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-gdl-")); }); afterAll(() => { @@ -17,9 +17,9 @@ afterAll(() => { beforeEach(() => { resetState(); setState({ rootPath: tmpDir }); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -28,25 +28,25 @@ afterEach(() => { // ─── constructor ────────────────────────────────────────────────────────────── -describe('GuidDataLoader constructor', () => { - it('constructs without throwing for a valid guid', () => { - expect(() => new GuidDataLoader('test-guid-u')).not.toThrow(); +describe("GuidDataLoader constructor", () => { + it("constructs without throwing for a valid guid", () => { + expect(() => new GuidDataLoader("test-guid-u")).not.toThrow(); }); - it('getGuid returns the guid passed to constructor', () => { - const loader = new GuidDataLoader('my-test-guid'); - expect(loader.getGuid()).toBe('my-test-guid'); + it("getGuid returns the guid passed to constructor", () => { + const loader = new GuidDataLoader("my-test-guid"); + expect(loader.getGuid()).toBe("my-test-guid"); }); }); // ─── resetLoggingFlags ──────────────────────────────────────────────────────── -describe('GuidDataLoader.resetLoggingFlags', () => { - it('can be called without throwing', () => { +describe("GuidDataLoader.resetLoggingFlags", () => { + it("can be called without throwing", () => { expect(() => GuidDataLoader.resetLoggingFlags()).not.toThrow(); }); - it('can be called multiple times without throwing', () => { + it("can be called multiple times without throwing", () => { GuidDataLoader.resetLoggingFlags(); GuidDataLoader.resetLoggingFlags(); expect(true).toBe(true); @@ -55,9 +55,9 @@ describe('GuidDataLoader.resetLoggingFlags', () => { // ─── hasNoContent ───────────────────────────────────────────────────────────── -describe('GuidDataLoader.hasNoContent', () => { - it('returns true when all arrays are empty', () => { - const loader = new GuidDataLoader('guid'); +describe("GuidDataLoader.hasNoContent", () => { + it("returns true when all arrays are empty", () => { + const loader = new GuidDataLoader("guid"); const entities = { pages: [], templates: [], @@ -71,8 +71,8 @@ describe('GuidDataLoader.hasNoContent', () => { expect(loader.hasNoContent(entities)).toBe(true); }); - it('returns false when pages array has items', () => { - const loader = new GuidDataLoader('guid'); + it("returns false when pages array has items", () => { + const loader = new GuidDataLoader("guid"); const entities = { pages: [{ pageID: 1 }], templates: [], @@ -86,14 +86,14 @@ describe('GuidDataLoader.hasNoContent', () => { expect(loader.hasNoContent(entities)).toBe(false); }); - it('returns false when models array has items', () => { - const loader = new GuidDataLoader('guid'); + it("returns false when models array has items", () => { + const loader = new GuidDataLoader("guid"); const entities = { pages: [], templates: [], containers: [], lists: [], - models: [{ id: 1, referenceName: 'TestModel' }], + models: [{ id: 1, referenceName: "TestModel" }], content: [], assets: [], galleries: [], @@ -101,8 +101,8 @@ describe('GuidDataLoader.hasNoContent', () => { expect(loader.hasNoContent(entities)).toBe(false); }); - it('returns false when assets array has items', () => { - const loader = new GuidDataLoader('guid'); + it("returns false when assets array has items", () => { + const loader = new GuidDataLoader("guid"); const entities = { pages: [], templates: [], @@ -119,9 +119,9 @@ describe('GuidDataLoader.hasNoContent', () => { // ─── getEntityCounts ────────────────────────────────────────────────────────── -describe('GuidDataLoader.getEntityCounts', () => { - it('returns correct counts for all entity types', () => { - const loader = new GuidDataLoader('guid'); +describe("GuidDataLoader.getEntityCounts", () => { + it("returns correct counts for all entity types", () => { + const loader = new GuidDataLoader("guid"); const entities = { pages: [1, 2, 3], templates: [1], @@ -144,8 +144,8 @@ describe('GuidDataLoader.getEntityCounts', () => { expect(counts.galleries).toBe(3); }); - it('returns all zeros for empty entities', () => { - const loader = new GuidDataLoader('guid'); + it("returns all zeros for empty entities", () => { + const loader = new GuidDataLoader("guid"); const entities = { pages: [], templates: [], @@ -163,72 +163,78 @@ describe('GuidDataLoader.getEntityCounts', () => { }); }); - it('returns counts object with all expected keys', () => { - const loader = new GuidDataLoader('guid'); + it("returns counts object with all expected keys", () => { + const loader = new GuidDataLoader("guid"); const entities = { - pages: [], templates: [], containers: [], lists: [], - models: [], content: [], assets: [], galleries: [], + pages: [], + templates: [], + containers: [], + lists: [], + models: [], + content: [], + assets: [], + galleries: [], }; const counts = loader.getEntityCounts(entities); - expect(counts).toHaveProperty('pages'); - expect(counts).toHaveProperty('templates'); - expect(counts).toHaveProperty('containers'); - expect(counts).toHaveProperty('lists'); - expect(counts).toHaveProperty('models'); - expect(counts).toHaveProperty('content'); - expect(counts).toHaveProperty('assets'); - expect(counts).toHaveProperty('galleries'); + expect(counts).toHaveProperty("pages"); + expect(counts).toHaveProperty("templates"); + expect(counts).toHaveProperty("containers"); + expect(counts).toHaveProperty("lists"); + expect(counts).toHaveProperty("models"); + expect(counts).toHaveProperty("content"); + expect(counts).toHaveProperty("assets"); + expect(counts).toHaveProperty("galleries"); }); }); // ─── validateDataStructure ──────────────────────────────────────────────────── -describe('GuidDataLoader.validateDataStructure', () => { - it('returns false when instance path does not exist', () => { - setState({ rootPath: path.join(tmpDir, 'nonexistent-subdir') }); - const loader = new GuidDataLoader('missing-guid-u'); +describe("GuidDataLoader.validateDataStructure", () => { + it("returns false when instance path does not exist", () => { + setState({ rootPath: path.join(tmpDir, "nonexistent-subdir") }); + const loader = new GuidDataLoader("missing-guid-u"); - expect(loader.validateDataStructure('en-us')).toBe(false); + expect(loader.validateDataStructure("en-us")).toBe(false); expect(console.error).toHaveBeenCalled(); }); - it('returns true when instance path exists (rootPath/guid)', () => { + it("returns true when instance path exists (rootPath/guid)", () => { // fileOperations builds instancePath as rootPath/guid (in guid-level non-legacy mode) - const instanceDir = path.join(tmpDir, 'validate-guid-u'); + const instanceDir = path.join(tmpDir, "validate-guid-u"); fs.mkdirSync(instanceDir, { recursive: true }); setState({ rootPath: tmpDir }); - const loader = new GuidDataLoader('validate-guid-u'); + const loader = new GuidDataLoader("validate-guid-u"); - expect(loader.validateDataStructure('en-us')).toBe(true); + expect(loader.validateDataStructure("en-us")).toBe(true); }); }); // ─── loadGuidEntities — with prepared filesystem ───────────────────────────── -describe('GuidDataLoader.loadGuidEntities', () => { - it('returns GuidEntities with empty arrays when only Models element is requested and no files exist', async () => { +describe("GuidDataLoader.loadGuidEntities", () => { + it("returns GuidEntities with empty arrays when only Models element is requested and no files exist", async () => { // Don't include Galleries in elements to avoid scan errors on missing gallery dir - state.elements = 'Models'; + state.elements = "Models"; state.isSync = false; - state.modelsWithDeps = ''; + state.modelsWithDeps = ""; - const loader = new GuidDataLoader('no-files-model-guid-u'); - const entities = await loader.loadGuidEntities('en-us'); + const loader = new GuidDataLoader("no-files-model-guid-u"); + const entities = await loader.loadGuidEntities("en-us"); expect(entities).toBeDefined(); expect(Array.isArray(entities.models)).toBe(true); expect(entities.models).toHaveLength(0); }); - it('returns all required fields as arrays', async () => { - state.elements = 'Models'; + it("returns all required fields as arrays", async () => { + state.elements = "Models"; state.isSync = false; - state.modelsWithDeps = ''; + state.modelsWithDeps = ""; - const loader = new GuidDataLoader('fields-check-guid-u'); - const entities = await loader.loadGuidEntities('en-us'); + const loader = new GuidDataLoader("fields-check-guid-u"); + const entities = await loader.loadGuidEntities("en-us"); expect(Array.isArray(entities.pages)).toBe(true); expect(Array.isArray(entities.templates)).toBe(true); @@ -240,13 +246,13 @@ describe('GuidDataLoader.loadGuidEntities', () => { expect(Array.isArray(entities.galleries)).toBe(true); }); - it('result fields are never null or undefined', async () => { - state.elements = 'Models,Containers'; + it("result fields are never null or undefined", async () => { + state.elements = "Models,Containers"; state.isSync = false; - state.modelsWithDeps = ''; + state.modelsWithDeps = ""; - const loader = new GuidDataLoader('null-check-guid-u'); - const entities = await loader.loadGuidEntities('en-us'); + const loader = new GuidDataLoader("null-check-guid-u"); + const entities = await loader.loadGuidEntities("en-us"); Object.entries(entities).forEach(([key, value]) => { expect(value).not.toBeNull(); @@ -254,19 +260,19 @@ describe('GuidDataLoader.loadGuidEntities', () => { }); }); - it('filterGuidEntitiesByModels returns empty arrays when no matching models exist', async () => { - state.elements = 'Models'; + it("filterGuidEntitiesByModels returns empty arrays when no matching models exist", async () => { + state.elements = "Models"; state.isSync = false; - state.modelsWithDeps = ''; + state.modelsWithDeps = ""; - const loader = new GuidDataLoader('filter-test-guid-u'); + const loader = new GuidDataLoader("filter-test-guid-u"); // When models filter is set with valid name but no model files exist, // the validation will fail because the model doesn't exist in loaded data. // filterOptions with valid names resolves to empty when no models loaded. // An invalid model name throws Model validation failed error. // We test the non-filtering path by passing no filterOptions. - const entities = await loader.loadGuidEntities('en-us'); + const entities = await loader.loadGuidEntities("en-us"); // Without filtering, all arrays are returned (empty when no files) expect(Array.isArray(entities.models)).toBe(true); @@ -274,15 +280,15 @@ describe('GuidDataLoader.loadGuidEntities', () => { expect(Array.isArray(entities.pages)).toBe(true); }); - it('throws Model validation failed when filterOptions.models contains unknown model', async () => { - state.elements = 'Models'; + it("throws Model validation failed when filterOptions.models contains unknown model", async () => { + state.elements = "Models"; state.isSync = false; - state.modelsWithDeps = ''; + state.modelsWithDeps = ""; - const loader = new GuidDataLoader('validate-filter-guid-u'); + const loader = new GuidDataLoader("validate-filter-guid-u"); - await expect( - loader.loadGuidEntities('en-us', { models: ['NonExistentModel'] }) - ).rejects.toThrow(/Model validation failed/); + await expect(loader.loadGuidEntities("en-us", { models: ["NonExistentModel"] })).rejects.toThrow( + /Model validation failed/ + ); }); }); diff --git a/src/lib/pushers/tests/model-pusher.test.ts b/src/lib/pushers/tests/model-pusher.test.ts index 499b657..e45339f 100644 --- a/src/lib/pushers/tests/model-pusher.test.ts +++ b/src/lib/pushers/tests/model-pusher.test.ts @@ -168,7 +168,7 @@ describe("pushModels — source-side rename orphans a mapping and halts the sync id: 118, referenceName: "ContactUsSendMessageForm", lastModifiedDate: new Date(2025, 0, 1).toISOString(), - } as any, + } as any ); const saveModel = jest.fn().mockResolvedValue(makeModel({ id: 999 })); diff --git a/src/lib/pushers/tests/orchestrate-pushers.test.ts b/src/lib/pushers/tests/orchestrate-pushers.test.ts index a4cd745..38914a5 100644 --- a/src/lib/pushers/tests/orchestrate-pushers.test.ts +++ b/src/lib/pushers/tests/orchestrate-pushers.test.ts @@ -1,13 +1,13 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, setState, state } from 'core/state'; -import { Pushers } from '../orchestrate-pushers'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState, state } from "core/state"; +import { Pushers } from "../orchestrate-pushers"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-orch-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-orch-")); }); afterAll(() => { @@ -17,9 +17,9 @@ afterAll(() => { beforeEach(() => { resetState(); setState({ rootPath: tmpDir }); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -28,47 +28,47 @@ afterEach(() => { // ─── constructor ────────────────────────────────────────────────────────────── -describe('Pushers constructor', () => { - it('constructs without throwing with no config', () => { +describe("Pushers constructor", () => { + it("constructs without throwing with no config", () => { expect(() => new Pushers()).not.toThrow(); }); - it('constructs without throwing with empty config', () => { + it("constructs without throwing with empty config", () => { expect(() => new Pushers({})).not.toThrow(); }); - it('constructs without throwing with onOperationStart callback', () => { + it("constructs without throwing with onOperationStart callback", () => { const config = { onOperationStart: jest.fn() }; expect(() => new Pushers(config)).not.toThrow(); }); - it('constructs without throwing when state has sourceGuid set', () => { - setState({ sourceGuid: 'src-guid-u', targetGuid: 'tgt-guid-u' }); + it("constructs without throwing when state has sourceGuid set", () => { + setState({ sourceGuid: "src-guid-u", targetGuid: "tgt-guid-u" }); expect(() => new Pushers()).not.toThrow(); }); }); // ─── getPushSummary ─────────────────────────────────────────────────────────── -describe('Pushers.getPushSummary', () => { - it('returns summary shape with expected keys', () => { +describe("Pushers.getPushSummary", () => { + it("returns summary shape with expected keys", () => { const pushers = new Pushers(); const summary = pushers.getPushSummary(); - expect(summary).toHaveProperty('totalOperations'); - expect(summary).toHaveProperty('successfulOperations'); - expect(summary).toHaveProperty('failedOperations'); - expect(summary).toHaveProperty('overallSuccess'); - expect(summary).toHaveProperty('duration'); + expect(summary).toHaveProperty("totalOperations"); + expect(summary).toHaveProperty("successfulOperations"); + expect(summary).toHaveProperty("failedOperations"); + expect(summary).toHaveProperty("overallSuccess"); + expect(summary).toHaveProperty("duration"); }); - it('returns overallSuccess as true by default', () => { + it("returns overallSuccess as true by default", () => { const pushers = new Pushers(); const summary = pushers.getPushSummary(); expect(summary.overallSuccess).toBe(true); }); - it('returns non-negative duration', () => { + it("returns non-negative duration", () => { const pushers = new Pushers(); const summary = pushers.getPushSummary(); expect(summary.duration).toBeGreaterThanOrEqual(0); @@ -77,13 +77,13 @@ describe('Pushers.getPushSummary', () => { // ─── reset ──────────────────────────────────────────────────────────────────── -describe('Pushers.reset', () => { - it('does not throw when called', () => { +describe("Pushers.reset", () => { + it("does not throw when called", () => { const pushers = new Pushers(); expect(() => pushers.reset()).not.toThrow(); }); - it('duration increases after reset + time passes', () => { + it("duration increases after reset + time passes", () => { const pushers = new Pushers(); const summaryBefore = pushers.getPushSummary(); pushers.reset(); @@ -95,13 +95,13 @@ describe('Pushers.reset', () => { // ─── updateConfig ───────────────────────────────────────────────────────────── -describe('Pushers.updateConfig', () => { - it('does not throw when updating config', () => { +describe("Pushers.updateConfig", () => { + it("does not throw when updating config", () => { const pushers = new Pushers(); expect(() => pushers.updateConfig({ onOperationStart: jest.fn() })).not.toThrow(); }); - it('allows partial config updates', () => { + it("allows partial config updates", () => { const cb = jest.fn(); const pushers = new Pushers({ onOperationComplete: cb }); expect(() => pushers.updateConfig({ onOperationStart: jest.fn() })).not.toThrow(); @@ -110,37 +110,39 @@ describe('Pushers.updateConfig', () => { // ─── instanceOrchestrator — guard clause: missing GUIDs ────────────────────── -describe('Pushers.instanceOrchestrator — guard clause', () => { - it('throws when no sourceGuid is set', async () => { +describe("Pushers.instanceOrchestrator — guard clause", () => { + it("throws when no sourceGuid is set", async () => { const pushers = new Pushers(); // state has no sourceGuid after resetState - await expect(pushers.instanceOrchestrator()).rejects.toThrow( - /No source or target GUIDs/ - ); + await expect(pushers.instanceOrchestrator()).rejects.toThrow(/No source or target GUIDs/); }); - it('throws when no targetGuid is set', async () => { - setState({ sourceGuid: 'src-guid-u' }); + it("throws when no targetGuid is set", async () => { + setState({ sourceGuid: "src-guid-u" }); const pushers = new Pushers(); - await expect(pushers.instanceOrchestrator()).rejects.toThrow( - /No source or target GUIDs/ - ); + await expect(pushers.instanceOrchestrator()).rejects.toThrow(/No source or target GUIDs/); }); }); // ─── executePushOperation — skips on empty data ─────────────────────────────── -describe('Pushers.executePushOperation — empty data skip', () => { - it('returns zero counts when elementData is empty array', async () => { - setState({ sourceGuid: 'src-u', targetGuid: 'tgt-u' }); +describe("Pushers.executePushOperation — empty data skip", () => { + it("returns zero counts when elementData is empty array", async () => { + setState({ sourceGuid: "src-u", targetGuid: "tgt-u" }); const pushers = new Pushers(); - const { PUSH_OPERATIONS } = await import('../push-operations-config'); + const { PUSH_OPERATIONS } = await import("../push-operations-config"); const config = PUSH_OPERATIONS.models; const emptySource: any = { - pages: [], templates: [], containers: [], lists: [], - models: [], content: [], assets: [], galleries: [] + pages: [], + templates: [], + containers: [], + lists: [], + models: [], + content: [], + assets: [], + galleries: [], }; const emptyTarget: any = { ...emptySource }; @@ -148,8 +150,8 @@ describe('Pushers.executePushOperation — empty data skip', () => { config, sourceData: emptySource, targetData: emptyTarget, - locale: 'en-us', - elements: ['Models'], + locale: "en-us", + elements: ["Models"], }); expect(result.success).toBe(0); @@ -157,25 +159,30 @@ describe('Pushers.executePushOperation — empty data skip', () => { expect(result.skipped).toBe(0); }); - it('returns zero counts when element is not in requested elements', async () => { - setState({ sourceGuid: 'src-u', targetGuid: 'tgt-u' }); + it("returns zero counts when element is not in requested elements", async () => { + setState({ sourceGuid: "src-u", targetGuid: "tgt-u" }); const pushers = new Pushers(); - const { PUSH_OPERATIONS } = await import('../push-operations-config'); + const { PUSH_OPERATIONS } = await import("../push-operations-config"); const config = PUSH_OPERATIONS.models; const sourceData: any = { - pages: [], templates: [], containers: [], lists: [], - models: [{ id: 1, referenceName: 'TestModel' }], - content: [], assets: [], galleries: [] + pages: [], + templates: [], + containers: [], + lists: [], + models: [{ id: 1, referenceName: "TestModel" }], + content: [], + assets: [], + galleries: [], }; const result = await pushers.executePushOperation({ config, sourceData, targetData: { ...sourceData }, - locale: 'en-us', - elements: ['Pages'], // Models not in requested elements + locale: "en-us", + elements: ["Pages"], // Models not in requested elements }); expect(result.success).toBe(0); @@ -185,60 +192,70 @@ describe('Pushers.executePushOperation — empty data skip', () => { // ─── executePushOperation — callbacks ───────────────────────────────────────── -describe('Pushers.executePushOperation — callbacks', () => { - it('calls onOperationStart when data is non-empty', async () => { - setState({ sourceGuid: 'src-u', targetGuid: 'tgt-u' }); +describe("Pushers.executePushOperation — callbacks", () => { + it("calls onOperationStart when data is non-empty", async () => { + setState({ sourceGuid: "src-u", targetGuid: "tgt-u" }); const onOperationStart = jest.fn(); const pushers = new Pushers({ onOperationStart }); - const { PUSH_OPERATIONS } = await import('../push-operations-config'); + const { PUSH_OPERATIONS } = await import("../push-operations-config"); const config = { ...PUSH_OPERATIONS.models, - handler: jest.fn().mockResolvedValue({ status: 'success', successful: 0, failed: 0, skipped: 0 }), + handler: jest.fn().mockResolvedValue({ status: "success", successful: 0, failed: 0, skipped: 0 }), }; const sourceData: any = { - pages: [], templates: [], containers: [], lists: [], - models: [{ id: 1, referenceName: 'TestModel' }], - content: [], assets: [], galleries: [] + pages: [], + templates: [], + containers: [], + lists: [], + models: [{ id: 1, referenceName: "TestModel" }], + content: [], + assets: [], + galleries: [], }; await pushers.executePushOperation({ config, sourceData, targetData: { ...sourceData }, - locale: 'en-us', - elements: ['Models'], + locale: "en-us", + elements: ["Models"], }); - expect(onOperationStart).toHaveBeenCalledWith('pushModels', 'src-u', 'tgt-u'); + expect(onOperationStart).toHaveBeenCalledWith("pushModels", "src-u", "tgt-u"); }); - it('calls onOperationComplete when data is non-empty', async () => { - setState({ sourceGuid: 'src-u', targetGuid: 'tgt-u' }); + it("calls onOperationComplete when data is non-empty", async () => { + setState({ sourceGuid: "src-u", targetGuid: "tgt-u" }); const onOperationComplete = jest.fn(); const pushers = new Pushers({ onOperationComplete }); - const { PUSH_OPERATIONS } = await import('../push-operations-config'); + const { PUSH_OPERATIONS } = await import("../push-operations-config"); const config = { ...PUSH_OPERATIONS.models, - handler: jest.fn().mockResolvedValue({ status: 'success', successful: 1, failed: 0, skipped: 0 }), + handler: jest.fn().mockResolvedValue({ status: "success", successful: 1, failed: 0, skipped: 0 }), }; const sourceData: any = { - pages: [], templates: [], containers: [], lists: [], - models: [{ id: 1, referenceName: 'TestModel' }], - content: [], assets: [], galleries: [] + pages: [], + templates: [], + containers: [], + lists: [], + models: [{ id: 1, referenceName: "TestModel" }], + content: [], + assets: [], + galleries: [], }; await pushers.executePushOperation({ config, sourceData, targetData: { ...sourceData }, - locale: 'en-us', - elements: ['Models'], + locale: "en-us", + elements: ["Models"], }); - expect(onOperationComplete).toHaveBeenCalledWith('pushModels', 'src-u', 'tgt-u', true); + expect(onOperationComplete).toHaveBeenCalledWith("pushModels", "src-u", "tgt-u", true); }); }); diff --git a/src/lib/pushers/tests/push-operations-config.test.ts b/src/lib/pushers/tests/push-operations-config.test.ts index 6fc8531..5798586 100644 --- a/src/lib/pushers/tests/push-operations-config.test.ts +++ b/src/lib/pushers/tests/push-operations-config.test.ts @@ -1,11 +1,11 @@ -import { resetState, setState } from 'core/state'; -import { PUSH_OPERATIONS, PushOperationsRegistry } from '../push-operations-config'; +import { resetState, setState } from "core/state"; +import { PUSH_OPERATIONS, PushOperationsRegistry } from "../push-operations-config"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -14,164 +14,165 @@ afterEach(() => { // ─── PUSH_OPERATIONS registry shape ────────────────────────────────────────── -describe('PUSH_OPERATIONS registry', () => { - it('exports all expected operation keys', () => { +describe("PUSH_OPERATIONS registry", () => { + it("exports all expected operation keys", () => { const keys = Object.keys(PUSH_OPERATIONS); - expect(keys).toContain('galleries'); - expect(keys).toContain('assets'); - expect(keys).toContain('models'); - expect(keys).toContain('containers'); - expect(keys).toContain('content'); - expect(keys).toContain('templates'); - expect(keys).toContain('pages'); + expect(keys).toContain("galleries"); + expect(keys).toContain("assets"); + expect(keys).toContain("models"); + expect(keys).toContain("containers"); + expect(keys).toContain("content"); + expect(keys).toContain("templates"); + expect(keys).toContain("pages"); }); - it.each([ - 'galleries', 'assets', 'models', 'containers', 'content', 'templates', 'pages' - ])('%s operation has required fields', (key) => { - const op = PUSH_OPERATIONS[key]; - expect(op.name).toBeTruthy(); - expect(op.description).toBeTruthy(); - expect(typeof op.handler).toBe('function'); - expect(Array.isArray(op.elements)).toBe(true); - expect(op.elements.length).toBeGreaterThan(0); - expect(typeof op.dataKey).toBe('string'); - }); + it.each(["galleries", "assets", "models", "containers", "content", "templates", "pages"])( + "%s operation has required fields", + (key) => { + const op = PUSH_OPERATIONS[key]; + expect(op.name).toBeTruthy(); + expect(op.description).toBeTruthy(); + expect(typeof op.handler).toBe("function"); + expect(Array.isArray(op.elements)).toBe(true); + expect(op.elements.length).toBeGreaterThan(0); + expect(typeof op.dataKey).toBe("string"); + } + ); - it('galleries operation targets Galleries element', () => { - expect(PUSH_OPERATIONS.galleries.elements).toContain('Galleries'); - expect(PUSH_OPERATIONS.galleries.dataKey).toBe('galleries'); + it("galleries operation targets Galleries element", () => { + expect(PUSH_OPERATIONS.galleries.elements).toContain("Galleries"); + expect(PUSH_OPERATIONS.galleries.dataKey).toBe("galleries"); }); - it('assets operation targets Assets element', () => { - expect(PUSH_OPERATIONS.assets.elements).toContain('Assets'); - expect(PUSH_OPERATIONS.assets.dataKey).toBe('assets'); + it("assets operation targets Assets element", () => { + expect(PUSH_OPERATIONS.assets.elements).toContain("Assets"); + expect(PUSH_OPERATIONS.assets.dataKey).toBe("assets"); }); - it('models operation targets Models element', () => { - expect(PUSH_OPERATIONS.models.elements).toContain('Models'); - expect(PUSH_OPERATIONS.models.dataKey).toBe('models'); + it("models operation targets Models element", () => { + expect(PUSH_OPERATIONS.models.elements).toContain("Models"); + expect(PUSH_OPERATIONS.models.dataKey).toBe("models"); }); - it('containers operation targets Containers element', () => { - expect(PUSH_OPERATIONS.containers.elements).toContain('Containers'); - expect(PUSH_OPERATIONS.containers.dataKey).toBe('containers'); + it("containers operation targets Containers element", () => { + expect(PUSH_OPERATIONS.containers.elements).toContain("Containers"); + expect(PUSH_OPERATIONS.containers.dataKey).toBe("containers"); }); - it('content operation targets Content element', () => { - expect(PUSH_OPERATIONS.content.elements).toContain('Content'); - expect(PUSH_OPERATIONS.content.dataKey).toBe('content'); + it("content operation targets Content element", () => { + expect(PUSH_OPERATIONS.content.elements).toContain("Content"); + expect(PUSH_OPERATIONS.content.dataKey).toBe("content"); }); - it('templates operation targets Templates element', () => { - expect(PUSH_OPERATIONS.templates.elements).toContain('Templates'); - expect(PUSH_OPERATIONS.templates.dataKey).toBe('templates'); + it("templates operation targets Templates element", () => { + expect(PUSH_OPERATIONS.templates.elements).toContain("Templates"); + expect(PUSH_OPERATIONS.templates.dataKey).toBe("templates"); }); - it('pages operation targets Pages element', () => { - expect(PUSH_OPERATIONS.pages.elements).toContain('Pages'); - expect(PUSH_OPERATIONS.pages.dataKey).toBe('pages'); + it("pages operation targets Pages element", () => { + expect(PUSH_OPERATIONS.pages.elements).toContain("Pages"); + expect(PUSH_OPERATIONS.pages.dataKey).toBe("pages"); }); }); // ─── PushOperationsRegistry.getAllOperations ────────────────────────────────── -describe('PushOperationsRegistry.getAllOperations', () => { - it('returns 7 operations total', () => { +describe("PushOperationsRegistry.getAllOperations", () => { + it("returns 7 operations total", () => { const ops = PushOperationsRegistry.getAllOperations(); expect(ops).toHaveLength(7); }); - it('each operation has a handler function', () => { + it("each operation has a handler function", () => { const ops = PushOperationsRegistry.getAllOperations(); ops.forEach((op) => { - expect(typeof op.handler).toBe('function'); + expect(typeof op.handler).toBe("function"); }); }); }); // ─── PushOperationsRegistry.getOperationByName ─────────────────────────────── -describe('PushOperationsRegistry.getOperationByName', () => { - it('finds pushGalleries by name', () => { - const op = PushOperationsRegistry.getOperationByName('pushGalleries'); +describe("PushOperationsRegistry.getOperationByName", () => { + it("finds pushGalleries by name", () => { + const op = PushOperationsRegistry.getOperationByName("pushGalleries"); expect(op).toBeDefined(); - expect(op!.name).toBe('pushGalleries'); + expect(op!.name).toBe("pushGalleries"); }); - it('finds pushModels by name', () => { - const op = PushOperationsRegistry.getOperationByName('pushModels'); + it("finds pushModels by name", () => { + const op = PushOperationsRegistry.getOperationByName("pushModels"); expect(op).toBeDefined(); - expect(op!.name).toBe('pushModels'); + expect(op!.name).toBe("pushModels"); }); - it('returns undefined for unknown name', () => { - const op = PushOperationsRegistry.getOperationByName('nonexistent'); + it("returns undefined for unknown name", () => { + const op = PushOperationsRegistry.getOperationByName("nonexistent"); expect(op).toBeUndefined(); }); }); // ─── PushOperationsRegistry.getOperationsByElement ─────────────────────────── -describe('PushOperationsRegistry.getOperationsByElement', () => { - it('returns the galleries operation for Galleries element', () => { - const ops = PushOperationsRegistry.getOperationsByElement('Galleries'); +describe("PushOperationsRegistry.getOperationsByElement", () => { + it("returns the galleries operation for Galleries element", () => { + const ops = PushOperationsRegistry.getOperationsByElement("Galleries"); expect(ops).toHaveLength(1); - expect(ops[0].name).toBe('pushGalleries'); + expect(ops[0].name).toBe("pushGalleries"); }); - it('returns the assets operation for Assets element', () => { - const ops = PushOperationsRegistry.getOperationsByElement('Assets'); + it("returns the assets operation for Assets element", () => { + const ops = PushOperationsRegistry.getOperationsByElement("Assets"); expect(ops).toHaveLength(1); - expect(ops[0].name).toBe('pushAssets'); + expect(ops[0].name).toBe("pushAssets"); }); - it('returns empty array for unknown element', () => { - const ops = PushOperationsRegistry.getOperationsByElement('UnknownElement'); + it("returns empty array for unknown element", () => { + const ops = PushOperationsRegistry.getOperationsByElement("UnknownElement"); expect(ops).toHaveLength(0); }); }); // ─── PushOperationsRegistry.getOperationsForElements — dependency resolution ── -describe('PushOperationsRegistry.getOperationsForElements', () => { - it('returns all operations when elements contains all types', () => { - setState({ elements: 'Galleries,Assets,Models,Containers,Content,Templates,Pages' }); +describe("PushOperationsRegistry.getOperationsForElements", () => { + it("returns all operations when elements contains all types", () => { + setState({ elements: "Galleries,Assets,Models,Containers,Content,Templates,Pages" }); const ops = PushOperationsRegistry.getOperationsForElements(); expect(ops.length).toBeGreaterThanOrEqual(7); }); - it('includes Galleries when only Assets is requested (dependency resolution)', () => { - setState({ elements: 'Assets' }); + it("includes Galleries when only Assets is requested (dependency resolution)", () => { + setState({ elements: "Assets" }); const ops = PushOperationsRegistry.getOperationsForElements(); const names = ops.map((o) => o.name); - expect(names).toContain('pushAssets'); + expect(names).toContain("pushAssets"); // Dependency: Assets depends on Galleries - expect(names).toContain('pushGalleries'); + expect(names).toContain("pushGalleries"); }); - it('includes Models when only Containers is requested', () => { - setState({ elements: 'Containers' }); + it("includes Models when only Containers is requested", () => { + setState({ elements: "Containers" }); const ops = PushOperationsRegistry.getOperationsForElements(); const names = ops.map((o) => o.name); - expect(names).toContain('pushContainers'); - expect(names).toContain('pushModels'); + expect(names).toContain("pushContainers"); + expect(names).toContain("pushModels"); }); - it('returns at least the models operation for Models-only elements', () => { - setState({ elements: 'Models' }); + it("returns at least the models operation for Models-only elements", () => { + setState({ elements: "Models" }); const ops = PushOperationsRegistry.getOperationsForElements(); const names = ops.map((o) => o.name); - expect(names).toContain('pushModels'); + expect(names).toContain("pushModels"); }); - it('uses all default elements when state.elements is empty string', () => { - setState({ elements: '' }); + it("uses all default elements when state.elements is empty string", () => { + setState({ elements: "" }); const ops = PushOperationsRegistry.getOperationsForElements(); // Should include all default elements const names = ops.map((o) => o.name); - expect(names).toContain('pushGalleries'); - expect(names).toContain('pushModels'); - expect(names).toContain('pushPages'); + expect(names).toContain("pushGalleries"); + expect(names).toContain("pushModels"); + expect(names).toContain("pushPages"); }); }); diff --git a/src/lib/pushers/tests/template-pusher.test.ts b/src/lib/pushers/tests/template-pusher.test.ts index 466464e..926951a 100644 --- a/src/lib/pushers/tests/template-pusher.test.ts +++ b/src/lib/pushers/tests/template-pusher.test.ts @@ -1,12 +1,12 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, setState, state, initializeGuidLogger } from 'core/state'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState, state, initializeGuidLogger } from "core/state"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-tpl-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-tpl-")); }); afterAll(() => { @@ -15,11 +15,11 @@ afterAll(() => { beforeEach(() => { resetState(); - setState({ rootPath: tmpDir, sourceGuid: 'src-tpl-u', targetGuid: 'tgt-tpl-u', token: 'test-token' }); - initializeGuidLogger('src-tpl-u', 'push'); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + setState({ rootPath: tmpDir, sourceGuid: "src-tpl-u", targetGuid: "tgt-tpl-u", token: "test-token" }); + initializeGuidLogger("src-tpl-u", "push"); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -44,30 +44,30 @@ function makeTemplate(overrides: Record = {}): any { // ─── pushTemplates — empty sourceData guard ─────────────────────────────────── -describe('pushTemplates — empty sourceData guard', () => { - it('returns success with zeros when sourceData is empty', async () => { +describe("pushTemplates — empty sourceData guard", () => { + it("returns success with zeros when sourceData is empty", async () => { state.cachedApiClient = { pageMethods: { savePageTemplate: jest.fn() }, } as any; - const { pushTemplates } = await import('../template-pusher'); - const result = await pushTemplates([], [], 'en-us'); + const { pushTemplates } = await import("../template-pusher"); + const result = await pushTemplates([], [], "en-us"); - expect(result.status).toBe('success'); + expect(result.status).toBe("success"); expect(result.successful).toBe(0); expect(result.failed).toBe(0); expect(result.skipped).toBe(0); }); - it('returns success with zeros when sourceData is null', async () => { + it("returns success with zeros when sourceData is null", async () => { state.cachedApiClient = { pageMethods: { savePageTemplate: jest.fn() }, } as any; - const { pushTemplates } = await import('../template-pusher'); - const result = await pushTemplates(null as any, [], 'en-us'); + const { pushTemplates } = await import("../template-pusher"); + const result = await pushTemplates(null as any, [], "en-us"); - expect(result.status).toBe('success'); + expect(result.status).toBe("success"); expect(result.successful).toBe(0); }); @@ -76,29 +76,29 @@ describe('pushTemplates — empty sourceData guard', () => { pageMethods: { savePageTemplate: jest.fn() }, } as any; - const consoleSpy = jest.spyOn(console, 'log'); - const { pushTemplates } = await import('../template-pusher'); - await pushTemplates([], [], 'en-us'); + const consoleSpy = jest.spyOn(console, "log"); + const { pushTemplates } = await import("../template-pusher"); + await pushTemplates([], [], "en-us"); - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No templates')); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("No templates")); }); }); // ─── pushTemplates — skip when template exists in target by name ─────────────── -describe('pushTemplates — skip when template exists in target by name (no mapping)', () => { - it('skips template that exists in target by name and creates mapping', async () => { +describe("pushTemplates — skip when template exists in target by name (no mapping)", () => { + it("skips template that exists in target by name and creates mapping", async () => { const savePageTemplate = jest.fn().mockResolvedValue(makeTemplate()); state.cachedApiClient = { pageMethods: { savePageTemplate }, } as any; - const { pushTemplates } = await import('../template-pusher'); + const { pushTemplates } = await import("../template-pusher"); - const sourceTpl = makeTemplate({ pageTemplateName: 'SharedTemplate' }); - const targetTpl = makeTemplate({ pageTemplateName: 'SharedTemplate' }); + const sourceTpl = makeTemplate({ pageTemplateName: "SharedTemplate" }); + const targetTpl = makeTemplate({ pageTemplateName: "SharedTemplate" }); - const result = await pushTemplates([sourceTpl], [targetTpl], 'en-us'); + const result = await pushTemplates([sourceTpl], [targetTpl], "en-us"); expect(result.skipped).toBe(1); expect(result.successful).toBe(0); @@ -108,65 +108,65 @@ describe('pushTemplates — skip when template exists in target by name (no mapp // ─── pushTemplates — create path ────────────────────────────────────────────── -describe('pushTemplates — create new template', () => { - it('calls savePageTemplate when no existing mapping and not in target by name', async () => { +describe("pushTemplates — create new template", () => { + it("calls savePageTemplate when no existing mapping and not in target by name", async () => { const savedTpl = makeTemplate({ pageTemplateID: 99 }); const savePageTemplate = jest.fn().mockResolvedValue(savedTpl); state.cachedApiClient = { pageMethods: { savePageTemplate }, } as any; - const { pushTemplates } = await import('../template-pusher'); + const { pushTemplates } = await import("../template-pusher"); - const sourceTpl = makeTemplate({ pageTemplateName: 'UniqueNewTemplate' }); + const sourceTpl = makeTemplate({ pageTemplateName: "UniqueNewTemplate" }); - const result = await pushTemplates([sourceTpl], [], 'en-us'); + const result = await pushTemplates([sourceTpl], [], "en-us"); expect(savePageTemplate).toHaveBeenCalledTimes(1); expect(result.successful).toBe(1); expect(result.failed).toBe(0); }); - it('counts as failed when savePageTemplate throws', async () => { - const savePageTemplate = jest.fn().mockRejectedValue(new Error('API error')); + it("counts as failed when savePageTemplate throws", async () => { + const savePageTemplate = jest.fn().mockRejectedValue(new Error("API error")); state.cachedApiClient = { pageMethods: { savePageTemplate }, } as any; - const { pushTemplates } = await import('../template-pusher'); + const { pushTemplates } = await import("../template-pusher"); - const sourceTpl = makeTemplate({ pageTemplateName: 'ErrorTemplate' }); + const sourceTpl = makeTemplate({ pageTemplateName: "ErrorTemplate" }); - const result = await pushTemplates([sourceTpl], [], 'en-us'); + const result = await pushTemplates([sourceTpl], [], "en-us"); expect(result.failed).toBe(1); expect(result.successful).toBe(0); - expect(result.status).toBe('error'); + expect(result.status).toBe("error"); }); }); // ─── pushTemplates — result shape ──────────────────────────────────────────── -describe('pushTemplates — result shape', () => { - it('returns status, successful, failed, skipped fields', async () => { +describe("pushTemplates — result shape", () => { + it("returns status, successful, failed, skipped fields", async () => { state.cachedApiClient = { pageMethods: { savePageTemplate: jest.fn() }, } as any; - const { pushTemplates } = await import('../template-pusher'); - const result = await pushTemplates([], [], 'en-us'); + const { pushTemplates } = await import("../template-pusher"); + const result = await pushTemplates([], [], "en-us"); - expect(result).toHaveProperty('status'); - expect(result).toHaveProperty('successful'); - expect(result).toHaveProperty('failed'); - expect(result).toHaveProperty('skipped'); + expect(result).toHaveProperty("status"); + expect(result).toHaveProperty("successful"); + expect(result).toHaveProperty("failed"); + expect(result).toHaveProperty("skipped"); }); }); // ─── pushTemplates — overwrite mode ────────────────────────────────────────── -describe('pushTemplates — overwrite mode', () => { - it('calls savePageTemplate for new template regardless of overwrite setting', async () => { +describe("pushTemplates — overwrite mode", () => { + it("calls savePageTemplate for new template regardless of overwrite setting", async () => { state.overwrite = false; const savedTpl = makeTemplate({ pageTemplateID: 88 }); @@ -175,12 +175,12 @@ describe('pushTemplates — overwrite mode', () => { pageMethods: { savePageTemplate }, } as any; - const { pushTemplates } = await import('../template-pusher'); + const { pushTemplates } = await import("../template-pusher"); // Template not in target, no mapping — goes through create path - const sourceTpl = makeTemplate({ pageTemplateName: 'NewUniqueTemplate2' }); + const sourceTpl = makeTemplate({ pageTemplateName: "NewUniqueTemplate2" }); - const result = await pushTemplates([sourceTpl], [], 'en-us'); + const result = await pushTemplates([sourceTpl], [], "en-us"); expect(savePageTemplate).toHaveBeenCalledTimes(1); expect(result.successful).toBe(1); diff --git a/src/lib/shared/get-all-channels.ts b/src/lib/shared/get-all-channels.ts index dbf25d2..f41b4af 100644 --- a/src/lib/shared/get-all-channels.ts +++ b/src/lib/shared/get-all-channels.ts @@ -1,25 +1,20 @@ import { getApiClient } from "../../core/state"; -export interface Channel -{ - channel: string, - digitalChannelId: number +export interface Channel { + channel: string; + digitalChannelId: number; } -export async function getAllChannels( - guid: string, - locale: string -): Promise { - // TODO: we should create a new mgmt SDK method to do this so we don't have to loop - const apiClient = getApiClient(); +export async function getAllChannels(guid: string, locale: string): Promise { + // TODO: we should create a new mgmt SDK method to do this so we don't have to loop + const apiClient = getApiClient(); - const sitemaps = await apiClient.pageMethods.getSitemap(guid, locale); - - return sitemaps.map(sitemap => { - return { - channel: sitemap.name, - digitalChannelId: sitemap.digitalChannelID - } - }); + const sitemaps = await apiClient.pageMethods.getSitemap(guid, locale); + return sitemaps.map((sitemap) => { + return { + channel: sitemap.name, + digitalChannelId: sitemap.digitalChannelID, + }; + }); } diff --git a/src/lib/shared/get-fetch-api-status.ts b/src/lib/shared/get-fetch-api-status.ts index 88957a9..289c0b7 100644 --- a/src/lib/shared/get-fetch-api-status.ts +++ b/src/lib/shared/get-fetch-api-status.ts @@ -1,88 +1,88 @@ /** * Fetch API Status Checker - * + * * Checks if the Fetch API CDN sync is complete for an instance. * Used before pull operations and after publishing to ensure * changes have propagated to the CDN. */ -import * as mgmtApi from '@agility/management-sdk'; -import { getApiClient } from '../../core/state'; -import ansiColors from 'ansi-colors'; +import * as mgmtApi from "@agility/management-sdk"; +import { getApiClient } from "../../core/state"; +import ansiColors from "ansi-colors"; -export type FetchApiSyncMode = 'fetch' | 'preview'; +export type FetchApiSyncMode = "fetch" | "preview"; export interface FetchApiStatus { - timestamp?: string; - completionTime?: string; - errorMessage?: string; - inProgress: boolean; - itemsAffected: number; - lastContentVersionID: number; - lastDeletedContentVersionID: number; - lastDeletedPageVersionID: number; - leaseID?: string; - maxChangeDate?: string; - maxContentModelDate?: string; - pushType: number; - startTime?: string; - websiteName?: string; + timestamp?: string; + completionTime?: string; + errorMessage?: string; + inProgress: boolean; + itemsAffected: number; + lastContentVersionID: number; + lastDeletedContentVersionID: number; + lastDeletedPageVersionID: number; + leaseID?: string; + maxChangeDate?: string; + maxContentModelDate?: string; + pushType: number; + startTime?: string; + websiteName?: string; } /** * Get the Fetch API sync status for an instance - * + * * @param guid - The instance GUID * @param mode - Sync mode: 'fetch' (live) or 'preview'. Defaults to 'fetch'. * @param waitForCompletion - If true, polls until sync is complete. Defaults to false. * @returns The sync status */ export async function getFetchApiStatus( - guid: string, - mode: FetchApiSyncMode = 'fetch', - waitForCompletion: boolean = false + guid: string, + mode: FetchApiSyncMode = "fetch", + waitForCompletion: boolean = false ): Promise { - const apiClient = getApiClient(); - return apiClient.instanceMethods.getFetchApiStatus(guid, mode, waitForCompletion); + const apiClient = getApiClient(); + return apiClient.instanceMethods.getFetchApiStatus(guid, mode, waitForCompletion); } /** * Wait for Fetch API sync to complete with progress messaging * Returns log lines for capturing in logger - * + * * @param guid - The instance GUID * @param mode - Sync mode: 'fetch' (live) or 'preview'. Defaults to 'fetch'. * @param silent - If true, suppresses console output. Defaults to false. * @returns Object containing final status and log lines */ export async function waitForFetchApiSync( - guid: string, - mode: FetchApiSyncMode = 'fetch', - silent: boolean = false + guid: string, + mode: FetchApiSyncMode = "fetch", + silent: boolean = false ): Promise<{ status: FetchApiStatus; logLines: string[] }> { - const logLines: string[] = []; - - // First check if sync is in progress - const initialStatus = await getFetchApiStatus(guid, mode, false); - - if (!initialStatus.inProgress) { - return { status: initialStatus, logLines }; - } - - // Sync is in progress, wait for completion - const waitingMsg = ansiColors.gray(`Waiting for Fetch API sync to complete...`); - logLines.push(waitingMsg); - if (!silent) { - console.log(waitingMsg); - } - - const finalStatus = await getFetchApiStatus(guid, mode, true); - - const completeMsg = ansiColors.green(`✓ Fetch API sync complete \n`); - logLines.push(completeMsg); - if (!silent) { - console.log(completeMsg); - } - - return { status: finalStatus, logLines }; + const logLines: string[] = []; + + // First check if sync is in progress + const initialStatus = await getFetchApiStatus(guid, mode, false); + + if (!initialStatus.inProgress) { + return { status: initialStatus, logLines }; + } + + // Sync is in progress, wait for completion + const waitingMsg = ansiColors.gray(`Waiting for Fetch API sync to complete...`); + logLines.push(waitingMsg); + if (!silent) { + console.log(waitingMsg); + } + + const finalStatus = await getFetchApiStatus(guid, mode, true); + + const completeMsg = ansiColors.green(`✓ Fetch API sync complete \n`); + logLines.push(completeMsg); + if (!silent) { + console.log(completeMsg); + } + + return { status: finalStatus, logLines }; } diff --git a/src/lib/shared/index.ts b/src/lib/shared/index.ts index d7c024f..7db8265 100644 --- a/src/lib/shared/index.ts +++ b/src/lib/shared/index.ts @@ -7,36 +7,36 @@ export * from "../loggers"; export * from "../pushers/batch-polling"; export * from "./link-type-detector"; export { GuidDataLoader, GuidEntities, SourceEntities } from "../pushers/guid-data-loader"; -export function prettyException(error: any): string { return error.message || error.toString(); } -export function logBatchError(error: any, context: string): void { console.error("Batch Error:", error); } +export function prettyException(error: any): string { + return error.message || error.toString(); +} +export function logBatchError(error: any, context: string): void { + console.error("Batch Error:", error); +} export { pollBatchUntilComplete, extractContentBatchResults, extractPageBatchResults } from "../pushers/batch-polling"; // Source publish status checker - checks source instance publish status export { - checkSourcePublishStatus, - filterPublishedContent, - filterPublishedPages, - isPublished -} from './source-publish-status-checker'; + checkSourcePublishStatus, + filterPublishedContent, + filterPublishedPages, + isPublished, +} from "./source-publish-status-checker"; // Fetch API status checker - checks if CDN sync is complete export { - getFetchApiStatus, - waitForFetchApiSync, - type FetchApiStatus, - type FetchApiSyncMode -} from './get-fetch-api-status'; + getFetchApiStatus, + waitForFetchApiSync, + type FetchApiStatus, + type FetchApiSyncMode, +} from "./get-fetch-api-status"; // Re-export types from central types folder -export { - ItemState, - type SourceItemData, - type PublishStatusResult -} from '../../types'; +export { ItemState, type SourceItemData, type PublishStatusResult } from "../../types"; // Version utility -import * as fs from 'fs'; -import * as path from 'path'; +import * as fs from "fs"; +import * as path from "path"; /** * Get package version from package.json @@ -48,19 +48,19 @@ import * as path from 'path'; export function getPackageVersion(): string { const possiblePaths = [ // Try current working directory first (for development) - path.join(process.cwd(), 'package.json'), + path.join(process.cwd(), "package.json"), // Try the CLI installation directory (for installed CLI) - path.join(__dirname, '../../package.json'), - path.join(__dirname, '../../../package.json'), + path.join(__dirname, "../../package.json"), + path.join(__dirname, "../../../package.json"), // Try one more level up for different installation structures - path.join(__dirname, '../../../../package.json') + path.join(__dirname, "../../../../package.json"), ]; for (const packageJsonPath of possiblePaths) { try { if (fs.existsSync(packageJsonPath)) { - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - if (packageJson.version && packageJson.name === '@agility/cli') { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + if (packageJson.version && packageJson.name === "@agility/cli") { return packageJson.version; } } @@ -71,7 +71,7 @@ export function getPackageVersion(): string { } // If we can't find the package.json or version, return 'unknown' - return 'unknown'; + return "unknown"; } /** @@ -80,9 +80,9 @@ export function getPackageVersion(): string { export function generateLogHeader(operationType: string, additionalInfo: Record = {}): string { const timestamp = new Date().toISOString(); const version = getPackageVersion(); - + const headerLines = [ - '='.repeat(80), + "=".repeat(80), `Agility CLI ${operationType} Operation Log`, `Version: ${version}`, `Timestamp: ${timestamp}`, @@ -93,8 +93,8 @@ export function generateLogHeader(operationType: string, additionalInfo: Record< headerLines.push(`${key}: ${value}`); }); - headerLines.push('='.repeat(80)); - headerLines.push(''); + headerLines.push("=".repeat(80)); + headerLines.push(""); - return headerLines.join('\n'); + return headerLines.join("\n"); } diff --git a/src/lib/shared/link-type-detector.ts b/src/lib/shared/link-type-detector.ts index 39de50e..5964a68 100644 --- a/src/lib/shared/link-type-detector.ts +++ b/src/lib/shared/link-type-detector.ts @@ -1,13 +1,13 @@ /** * Link Type Detection Service - * + * * Detects Agility CMS link types from model field configurations to enable * proper handling of different content linking patterns and eliminate false * broken chain reports from field configuration misinterpretation. */ export interface LinkTypeDetection { - type: 'dropdown' | 'searchlistbox' | 'grid' | 'nested' | 'shared' | 'unknown'; + type: "dropdown" | "searchlistbox" | "grid" | "nested" | "shared" | "unknown"; strategy: string; requiresMapping: boolean; followDependencies: boolean; @@ -23,96 +23,95 @@ export interface ContentFieldAnalysis { } export class LinkTypeDetector { - /** * Detect link type from a Content field's settings */ detectLinkType(field: any): LinkTypeDetection { - if (field.type !== 'Content') { - return { - type: 'unknown', - strategy: 'not-content-field', - requiresMapping: false, - followDependencies: false + if (field.type !== "Content") { + return { + type: "unknown", + strategy: "not-content-field", + requiresMapping: false, + followDependencies: false, }; } - + const settings = field.settings; - const renderAs = settings.RenderAs || ''; - const nestedTypeID = settings.LinkedContentNestedTypeID || ''; - const sharedContent = settings.SharedContent || ''; - const contentView = settings.ContentView || ''; - + const renderAs = settings.RenderAs || ""; + const nestedTypeID = settings.LinkedContentNestedTypeID || ""; + const sharedContent = settings.SharedContent || ""; + const contentView = settings.ContentView || ""; + // 1. DROPDOWN LINKS (Shared Content) - if (renderAs === 'dropdown' && sharedContent !== '_newcontent_agility_') { - return { - type: 'dropdown', - strategy: 'Use ID mapping only, don\'t follow dependencies', + if (renderAs === "dropdown" && sharedContent !== "_newcontent_agility_") { + return { + type: "dropdown", + strategy: "Use ID mapping only, don't follow dependencies", requiresMapping: true, - followDependencies: false + followDependencies: false, }; } - + // 2. SEARCHLISTBOX LINKS (Filtered Selection) - if (renderAs === 'searchlistbox') { - return { - type: 'searchlistbox', - strategy: 'Reference via contentID in separate field with remapping', + if (renderAs === "searchlistbox") { + return { + type: "searchlistbox", + strategy: "Reference via contentID in separate field with remapping", requiresMapping: true, - followDependencies: true + followDependencies: true, }; } - + // 3. GRID LINKS (Multi-item Lists) - if (renderAs === 'grid' && nestedTypeID === '1') { - return { - type: 'grid', - strategy: 'Link to shared list with mapping + optional sorting', + if (renderAs === "grid" && nestedTypeID === "1") { + return { + type: "grid", + strategy: "Link to shared list with mapping + optional sorting", requiresMapping: true, - followDependencies: true + followDependencies: true, }; } - - // 4. NESTED LINKS (Single Item Containers) - if (renderAs === '' && nestedTypeID === '0') { - return { - type: 'nested', - strategy: 'Create container if missing, link locally', + + // 4. NESTED LINKS (Single Item Containers) + if (renderAs === "" && nestedTypeID === "0") { + return { + type: "nested", + strategy: "Create container if missing, link locally", requiresMapping: true, - followDependencies: true + followDependencies: true, }; } - + // 5. SHARED CONTENT (Specific View Names) - if (contentView !== '_newcontent_agility_' && sharedContent !== '_newcontent_agility_') { - return { - type: 'shared', - strategy: 'Treat as shared, use content view metadata for context', + if (contentView !== "_newcontent_agility_" && sharedContent !== "_newcontent_agility_") { + return { + type: "shared", + strategy: "Treat as shared, use content view metadata for context", requiresMapping: true, - followDependencies: false + followDependencies: false, }; } - - return { - type: 'unknown', - strategy: 'unhandled-pattern', - requiresMapping: false, - followDependencies: false + + return { + type: "unknown", + strategy: "unhandled-pattern", + requiresMapping: false, + followDependencies: false, }; } - + /** * Analyze all Content fields in a model and extract real references vs field settings */ analyzeModelContentFields(model: any): ContentFieldAnalysis[] { if (!model.fields) return []; - + return model.fields - .filter((field: any) => field.type === 'Content') + .filter((field: any) => field.type === "Content") .map((field: any) => { const linkType = this.detectLinkType(field); const settings = field.settings; - + // Identify field configuration strings (NOT content references) const fieldConfigurationStrings: string[] = []; if (settings.LinkeContentDropdownValueField) { @@ -121,62 +120,62 @@ export class LinkTypeDetector { if (settings.LinkeContentDropdownTextField) { fieldConfigurationStrings.push(settings.LinkeContentDropdownTextField); } - + // Extract actual content references (depend on link type) const actualContentReferences: string[] = []; if (settings.ContentDefinition) { actualContentReferences.push(settings.ContentDefinition); } - + return { fieldName: field.name, linkType, - contentDefinition: settings.ContentDefinition || '', + contentDefinition: settings.ContentDefinition || "", actualContentReferences, - fieldConfigurationStrings + fieldConfigurationStrings, }; }); } - + /** * Check if a reference string is a field configuration (should be ignored) */ isFieldConfigurationString(referenceString: string, model: any): boolean { const analysis = this.analyzeModelContentFields(model); - - return analysis.some(fieldAnalysis => - fieldAnalysis.fieldConfigurationStrings.includes(referenceString) - ); + + return analysis.some((fieldAnalysis) => fieldAnalysis.fieldConfigurationStrings.includes(referenceString)); } - + /** * Extract only real content references from a model (filter out field settings) */ - extractRealContentReferences(model: any): Array<{ fieldName: string; contentDefinition: string; linkType: LinkTypeDetection }> { + extractRealContentReferences( + model: any + ): Array<{ fieldName: string; contentDefinition: string; linkType: LinkTypeDetection }> { const analysis = this.analyzeModelContentFields(model); - + return analysis - .filter(fieldAnalysis => fieldAnalysis.actualContentReferences.length > 0) - .map(fieldAnalysis => ({ + .filter((fieldAnalysis) => fieldAnalysis.actualContentReferences.length > 0) + .map((fieldAnalysis) => ({ fieldName: fieldAnalysis.fieldName, contentDefinition: fieldAnalysis.contentDefinition, - linkType: fieldAnalysis.linkType + linkType: fieldAnalysis.linkType, })); } - + /** * Get human-readable description of link type */ getLinkTypeDescription(linkType: LinkTypeDetection): string { const typeDescriptions = { - dropdown: '🔽 Dropdown (Shared Content)', - searchlistbox: '🔍 SearchListBox (Filtered Selection)', - grid: '📋 Grid (Multi-item List)', - nested: '📦 Nested (Single Container)', - shared: '🔗 Shared (Content View)', - unknown: '❓ Unknown Pattern' + dropdown: "🔽 Dropdown (Shared Content)", + searchlistbox: "🔍 SearchListBox (Filtered Selection)", + grid: "📋 Grid (Multi-item List)", + nested: "📦 Nested (Single Container)", + shared: "🔗 Shared (Content View)", + unknown: "❓ Unknown Pattern", }; - - return typeDescriptions[linkType.type] || '❓ Unknown'; + + return typeDescriptions[linkType.type] || "❓ Unknown"; } -} \ No newline at end of file +} diff --git a/src/lib/shared/sleep.ts b/src/lib/shared/sleep.ts index 87326f9..4984e12 100644 --- a/src/lib/shared/sleep.ts +++ b/src/lib/shared/sleep.ts @@ -4,5 +4,5 @@ * @returns Promise that resolves after the specified delay. */ export function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/src/lib/shared/source-publish-status-checker.ts b/src/lib/shared/source-publish-status-checker.ts index 7fd099b..0f5378f 100644 --- a/src/lib/shared/source-publish-status-checker.ts +++ b/src/lib/shared/source-publish-status-checker.ts @@ -1,20 +1,14 @@ /** * Source Publish Status Checker - * + * * Reads source instance files from the agility-files folder to determine * which items are published in the source instance. This allows workflow operations * to only process items in the target that match the source publish state. * Uses fileOperations for consistent filesystem access. */ -import { fileOperations } from '../../core'; -import { - ContentMapping, - PageMapping, - ItemState, - SourceItemData, - PublishStatusResult -} from '../../types'; +import { fileOperations } from "../../core"; +import { ContentMapping, PageMapping, ItemState, SourceItemData, PublishStatusResult } from "../../types"; // Re-export types for convenience export { ItemState, SourceItemData, PublishStatusResult }; @@ -23,111 +17,111 @@ export { ItemState, SourceItemData, PublishStatusResult }; * Check if an item is published based on its state */ export function isPublished(itemState: number): boolean { - return itemState === ItemState.Published; + return itemState === ItemState.Published; } /** * Read source item data using fileOperations */ -function readSourceItem(fileOps: fileOperations, type: 'item' | 'page', id: number): SourceItemData | null { - try { - const data = fileOps.readJsonFile(`${type}/${id}.json`); - return data as SourceItemData | null; - } catch (error: any) { - return null; - } +function readSourceItem(fileOps: fileOperations, type: "item" | "page", id: number): SourceItemData | null { + try { + const data = fileOps.readJsonFile(`${type}/${id}.json`); + return data as SourceItemData | null; + } catch (error: any) { + return null; + } } /** * Filter content mappings to only include items that are published in the source */ export function filterPublishedContent( - contentMappings: ContentMapping[], - sourceGuid: string, - locales: string[] + contentMappings: ContentMapping[], + sourceGuid: string, + locales: string[] ): PublishStatusResult { - const result: PublishStatusResult = { - publishedContentIds: [], - unpublishedContentIds: [], - publishedPageIds: [], - unpublishedPageIds: [], - errors: [] - }; - - for (const mapping of contentMappings) { - let found = false; - let isItemPublished = false; - - // Try each locale to find the source item - for (const locale of locales) { - const fileOps = new fileOperations(sourceGuid, locale); - const sourceItem = readSourceItem(fileOps, 'item', mapping.sourceContentID); - - if (sourceItem && sourceItem.properties) { - found = true; - isItemPublished = isPublished(sourceItem.properties.state); - break; - } - } - - if (!found) { - result.errors.push(`Source content item ${mapping.sourceContentID} not found in local files`); - // Default to publishing if source not found (preserve existing behavior) - result.publishedContentIds.push(mapping.targetContentID); - } else if (isItemPublished) { - result.publishedContentIds.push(mapping.targetContentID); - } else { - result.unpublishedContentIds.push(mapping.targetContentID); - } + const result: PublishStatusResult = { + publishedContentIds: [], + unpublishedContentIds: [], + publishedPageIds: [], + unpublishedPageIds: [], + errors: [], + }; + + for (const mapping of contentMappings) { + let found = false; + let isItemPublished = false; + + // Try each locale to find the source item + for (const locale of locales) { + const fileOps = new fileOperations(sourceGuid, locale); + const sourceItem = readSourceItem(fileOps, "item", mapping.sourceContentID); + + if (sourceItem && sourceItem.properties) { + found = true; + isItemPublished = isPublished(sourceItem.properties.state); + break; + } + } + + if (!found) { + result.errors.push(`Source content item ${mapping.sourceContentID} not found in local files`); + // Default to publishing if source not found (preserve existing behavior) + result.publishedContentIds.push(mapping.targetContentID); + } else if (isItemPublished) { + result.publishedContentIds.push(mapping.targetContentID); + } else { + result.unpublishedContentIds.push(mapping.targetContentID); } + } - return result; + return result; } /** * Filter page mappings to only include pages that are published in the source */ export function filterPublishedPages( - pageMappings: PageMapping[], - sourceGuid: string, - locales: string[] + pageMappings: PageMapping[], + sourceGuid: string, + locales: string[] ): PublishStatusResult { - const result: PublishStatusResult = { - publishedContentIds: [], - unpublishedContentIds: [], - publishedPageIds: [], - unpublishedPageIds: [], - errors: [] - }; - - for (const mapping of pageMappings) { - let found = false; - let isItemPublished = false; - - // Try each locale to find the source page - for (const locale of locales) { - const fileOps = new fileOperations(sourceGuid, locale); - const sourceItem = readSourceItem(fileOps, 'page', mapping.sourcePageID); - - if (sourceItem && sourceItem.properties) { - found = true; - isItemPublished = isPublished(sourceItem.properties.state); - break; - } - } - - if (!found) { - result.errors.push(`Source page ${mapping.sourcePageID} not found in local files`); - // Default to publishing if source not found (preserve existing behavior) - result.publishedPageIds.push(mapping.targetPageID); - } else if (isItemPublished) { - result.publishedPageIds.push(mapping.targetPageID); - } else { - result.unpublishedPageIds.push(mapping.targetPageID); - } + const result: PublishStatusResult = { + publishedContentIds: [], + unpublishedContentIds: [], + publishedPageIds: [], + unpublishedPageIds: [], + errors: [], + }; + + for (const mapping of pageMappings) { + let found = false; + let isItemPublished = false; + + // Try each locale to find the source page + for (const locale of locales) { + const fileOps = new fileOperations(sourceGuid, locale); + const sourceItem = readSourceItem(fileOps, "page", mapping.sourcePageID); + + if (sourceItem && sourceItem.properties) { + found = true; + isItemPublished = isPublished(sourceItem.properties.state); + break; + } + } + + if (!found) { + result.errors.push(`Source page ${mapping.sourcePageID} not found in local files`); + // Default to publishing if source not found (preserve existing behavior) + result.publishedPageIds.push(mapping.targetPageID); + } else if (isItemPublished) { + result.publishedPageIds.push(mapping.targetPageID); + } else { + result.unpublishedPageIds.push(mapping.targetPageID); } + } - return result; + return result; } /** @@ -136,22 +130,22 @@ export function filterPublishedPages( * CRITICAL: All ID arrays are deduplicated to prevent "item already in batch" errors */ export function checkSourcePublishStatus( - contentMappings: ContentMapping[], - pageMappings: PageMapping[], - sourceGuid: string, - locales: string[] + contentMappings: ContentMapping[], + pageMappings: PageMapping[], + sourceGuid: string, + locales: string[] ): PublishStatusResult { - const contentResult = filterPublishedContent(contentMappings, sourceGuid, locales); - const pageResult = filterPublishedPages(pageMappings, sourceGuid, locales); - - // CRITICAL: Deduplicate all ID arrays to prevent "item already in batch" API errors - // Duplicate IDs can occur when the same source→target mapping appears multiple times - // (e.g., from multiple locales or duplicate entries in mapping files) - return { - publishedContentIds: Array.from(new Set(contentResult.publishedContentIds)), - unpublishedContentIds: Array.from(new Set(contentResult.unpublishedContentIds)), - publishedPageIds: Array.from(new Set(pageResult.publishedPageIds)), - unpublishedPageIds: Array.from(new Set(pageResult.unpublishedPageIds)), - errors: [...contentResult.errors, ...pageResult.errors] - }; + const contentResult = filterPublishedContent(contentMappings, sourceGuid, locales); + const pageResult = filterPublishedPages(pageMappings, sourceGuid, locales); + + // CRITICAL: Deduplicate all ID arrays to prevent "item already in batch" API errors + // Duplicate IDs can occur when the same source→target mapping appears multiple times + // (e.g., from multiple locales or duplicate entries in mapping files) + return { + publishedContentIds: Array.from(new Set(contentResult.publishedContentIds)), + unpublishedContentIds: Array.from(new Set(contentResult.unpublishedContentIds)), + publishedPageIds: Array.from(new Set(pageResult.publishedPageIds)), + unpublishedPageIds: Array.from(new Set(pageResult.unpublishedPageIds)), + errors: [...contentResult.errors, ...pageResult.errors], + }; } diff --git a/src/lib/shared/tests/get-all-channels.test.ts b/src/lib/shared/tests/get-all-channels.test.ts index c2d70d1..7f0acb1 100644 --- a/src/lib/shared/tests/get-all-channels.test.ts +++ b/src/lib/shared/tests/get-all-channels.test.ts @@ -1,11 +1,11 @@ -import { resetState } from 'core/state'; -import { getAllChannels } from '../get-all-channels'; +import { resetState } from "core/state"; +import { getAllChannels } from "../get-all-channels"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -15,59 +15,59 @@ afterEach(() => { function makeApiClient(sitemaps: any[]) { return { pageMethods: { - getSitemap: jest.fn().mockResolvedValue(sitemaps) - } + getSitemap: jest.fn().mockResolvedValue(sitemaps), + }, }; } -describe('getAllChannels', () => { - it('returns a Channel for each sitemap entry', async () => { +describe("getAllChannels", () => { + it("returns a Channel for each sitemap entry", async () => { const sitemaps = [ - { name: 'Website', digitalChannelID: 1 }, - { name: 'Mobile', digitalChannelID: 2 }, + { name: "Website", digitalChannelID: 1 }, + { name: "Mobile", digitalChannelID: 2 }, ]; - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue(makeApiClient(sitemaps)); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue(makeApiClient(sitemaps)); - const result = await getAllChannels('test-guid', 'en-us'); + const result = await getAllChannels("test-guid", "en-us"); expect(result).toHaveLength(2); - expect(result[0]).toEqual({ channel: 'Website', digitalChannelId: 1 }); - expect(result[1]).toEqual({ channel: 'Mobile', digitalChannelId: 2 }); + expect(result[0]).toEqual({ channel: "Website", digitalChannelId: 1 }); + expect(result[1]).toEqual({ channel: "Mobile", digitalChannelId: 2 }); }); - it('returns an empty array when getSitemap returns no entries', async () => { - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue(makeApiClient([])); + it("returns an empty array when getSitemap returns no entries", async () => { + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue(makeApiClient([])); - const result = await getAllChannels('test-guid', 'en-us'); + const result = await getAllChannels("test-guid", "en-us"); expect(result).toEqual([]); }); - it('passes guid and locale to getSitemap', async () => { + it("passes guid and locale to getSitemap", async () => { const getSitemap = jest.fn().mockResolvedValue([]); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ - pageMethods: { getSitemap } + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ + pageMethods: { getSitemap }, }); - await getAllChannels('my-guid', 'fr-fr'); + await getAllChannels("my-guid", "fr-fr"); - expect(getSitemap).toHaveBeenCalledWith('my-guid', 'fr-fr'); + expect(getSitemap).toHaveBeenCalledWith("my-guid", "fr-fr"); }); - it('maps digitalChannelID (capital D) to digitalChannelId', async () => { - const sitemaps = [{ name: 'Channel A', digitalChannelID: 42 }]; - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue(makeApiClient(sitemaps)); + it("maps digitalChannelID (capital D) to digitalChannelId", async () => { + const sitemaps = [{ name: "Channel A", digitalChannelID: 42 }]; + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue(makeApiClient(sitemaps)); - const result = await getAllChannels('g', 'en-us'); + const result = await getAllChannels("g", "en-us"); expect(result[0].digitalChannelId).toBe(42); }); - it('propagates rejection from getSitemap', async () => { - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ - pageMethods: { getSitemap: jest.fn().mockRejectedValue(new Error('API error')) } + it("propagates rejection from getSitemap", async () => { + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ + pageMethods: { getSitemap: jest.fn().mockRejectedValue(new Error("API error")) }, }); - await expect(getAllChannels('g', 'en-us')).rejects.toThrow('API error'); + await expect(getAllChannels("g", "en-us")).rejects.toThrow("API error"); }); }); diff --git a/src/lib/shared/tests/get-fetch-api-status.test.ts b/src/lib/shared/tests/get-fetch-api-status.test.ts index c801997..3328795 100644 --- a/src/lib/shared/tests/get-fetch-api-status.test.ts +++ b/src/lib/shared/tests/get-fetch-api-status.test.ts @@ -1,18 +1,18 @@ -import { resetState } from 'core/state'; -import { getFetchApiStatus, waitForFetchApiSync } from '../get-fetch-api-status'; +import { resetState } from "core/state"; +import { getFetchApiStatus, waitForFetchApiSync } from "../get-fetch-api-status"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { jest.restoreAllMocks(); }); -function makeStatus(overrides: Partial<{ inProgress: boolean }> = {}): any { +function makeStatus(overrides: Partial<{ inProgress: boolean }> = {}): any { return { inProgress: false, itemsAffected: 0, @@ -20,76 +20,74 @@ function makeStatus(overrides: Partial<{ inProgress: boolean }> = {}): any { lastDeletedContentVersionID: 0, lastDeletedPageVersionID: 0, pushType: 1, - ...overrides + ...overrides, }; } function makeApiClient(statusOrFn: any) { - const fn = typeof statusOrFn === 'function' - ? statusOrFn - : jest.fn().mockResolvedValue(statusOrFn); + const fn = typeof statusOrFn === "function" ? statusOrFn : jest.fn().mockResolvedValue(statusOrFn); return { - instanceMethods: { getFetchApiStatus: fn } + instanceMethods: { getFetchApiStatus: fn }, }; } // ─── getFetchApiStatus ───────────────────────────────────────────────────────── -describe('getFetchApiStatus', () => { - it('returns the status from the API client', async () => { +describe("getFetchApiStatus", () => { + it("returns the status from the API client", async () => { const status = makeStatus(); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue(makeApiClient(status)); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue(makeApiClient(status)); - const result = await getFetchApiStatus('my-guid'); + const result = await getFetchApiStatus("my-guid"); expect(result).toBe(status); }); - it('passes guid, mode and waitForCompletion to the API client', async () => { + it("passes guid, mode and waitForCompletion to the API client", async () => { const mockFn = jest.fn().mockResolvedValue(makeStatus()); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue(makeApiClient(mockFn)); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue(makeApiClient(mockFn)); - await getFetchApiStatus('abc', 'preview', true); + await getFetchApiStatus("abc", "preview", true); - expect(mockFn).toHaveBeenCalledWith('abc', 'preview', true); + expect(mockFn).toHaveBeenCalledWith("abc", "preview", true); }); - it('defaults mode to fetch and waitForCompletion to false', async () => { + it("defaults mode to fetch and waitForCompletion to false", async () => { const mockFn = jest.fn().mockResolvedValue(makeStatus()); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue(makeApiClient(mockFn)); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue(makeApiClient(mockFn)); - await getFetchApiStatus('abc'); + await getFetchApiStatus("abc"); - expect(mockFn).toHaveBeenCalledWith('abc', 'fetch', false); + expect(mockFn).toHaveBeenCalledWith("abc", "fetch", false); }); - it('propagates API errors', async () => { - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue({ - instanceMethods: { getFetchApiStatus: jest.fn().mockRejectedValue(new Error('network')) } + it("propagates API errors", async () => { + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue({ + instanceMethods: { getFetchApiStatus: jest.fn().mockRejectedValue(new Error("network")) }, }); - await expect(getFetchApiStatus('abc')).rejects.toThrow('network'); + await expect(getFetchApiStatus("abc")).rejects.toThrow("network"); }); }); // ─── waitForFetchApiSync — sync not in progress ──────────────────────────────── -describe('waitForFetchApiSync — sync already complete', () => { - it('returns immediately when inProgress is false', async () => { +describe("waitForFetchApiSync — sync already complete", () => { + it("returns immediately when inProgress is false", async () => { const status = makeStatus({ inProgress: false }); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue(makeApiClient(status)); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue(makeApiClient(status)); - const result = await waitForFetchApiSync('my-guid'); + const result = await waitForFetchApiSync("my-guid"); expect(result.status).toBe(status); expect(result.logLines).toHaveLength(0); }); - it('makes only one API call when sync is already complete', async () => { + it("makes only one API call when sync is already complete", async () => { const mockFn = jest.fn().mockResolvedValue(makeStatus({ inProgress: false })); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue(makeApiClient(mockFn)); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue(makeApiClient(mockFn)); - await waitForFetchApiSync('my-guid'); + await waitForFetchApiSync("my-guid"); expect(mockFn).toHaveBeenCalledTimes(1); }); @@ -97,66 +95,71 @@ describe('waitForFetchApiSync — sync already complete', () => { // ─── waitForFetchApiSync — sync in progress ─────────────────────────────────── -describe('waitForFetchApiSync — sync in progress', () => { - it('waits for completion and returns two log lines', async () => { +describe("waitForFetchApiSync — sync in progress", () => { + it("waits for completion and returns two log lines", async () => { const inProgressStatus = makeStatus({ inProgress: true }); const completedStatus = makeStatus({ inProgress: false }); - const mockFn = jest.fn() + const mockFn = jest + .fn() .mockResolvedValueOnce(inProgressStatus) // initial check (waitForCompletion=false) - .mockResolvedValueOnce(completedStatus); // wait call (waitForCompletion=true) - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue(makeApiClient(mockFn)); + .mockResolvedValueOnce(completedStatus); // wait call (waitForCompletion=true) + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue(makeApiClient(mockFn)); - const result = await waitForFetchApiSync('my-guid'); + const result = await waitForFetchApiSync("my-guid"); expect(result.status).toBe(completedStatus); expect(result.logLines).toHaveLength(2); }); - it('calls API with waitForCompletion=true on second call', async () => { - const mockFn = jest.fn() + it("calls API with waitForCompletion=true on second call", async () => { + const mockFn = jest + .fn() .mockResolvedValueOnce(makeStatus({ inProgress: true })) .mockResolvedValueOnce(makeStatus({ inProgress: false })); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue(makeApiClient(mockFn)); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue(makeApiClient(mockFn)); - await waitForFetchApiSync('my-guid', 'fetch'); + await waitForFetchApiSync("my-guid", "fetch"); - expect(mockFn).toHaveBeenNthCalledWith(2, 'my-guid', 'fetch', true); + expect(mockFn).toHaveBeenNthCalledWith(2, "my-guid", "fetch", true); }); - it('suppresses console.log when silent=true but still returns logLines', async () => { - const mockFn = jest.fn() + it("suppresses console.log when silent=true but still returns logLines", async () => { + const mockFn = jest + .fn() .mockResolvedValueOnce(makeStatus({ inProgress: true })) .mockResolvedValueOnce(makeStatus({ inProgress: false })); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue(makeApiClient(mockFn)); - const consoleSpy = jest.spyOn(console, 'log'); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue(makeApiClient(mockFn)); + const consoleSpy = jest.spyOn(console, "log"); - const result = await waitForFetchApiSync('my-guid', 'fetch', true); + const result = await waitForFetchApiSync("my-guid", "fetch", true); expect(consoleSpy).not.toHaveBeenCalled(); expect(result.logLines).toHaveLength(2); }); - it('outputs to console.log when silent=false', async () => { - const mockFn = jest.fn() + it("outputs to console.log when silent=false", async () => { + const mockFn = jest + .fn() .mockResolvedValueOnce(makeStatus({ inProgress: true })) .mockResolvedValueOnce(makeStatus({ inProgress: false })); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue(makeApiClient(mockFn)); - const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue(makeApiClient(mockFn)); + const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {}); - await waitForFetchApiSync('my-guid', 'fetch', false); + await waitForFetchApiSync("my-guid", "fetch", false); expect(consoleSpy).toHaveBeenCalledTimes(2); }); - it('uses preview mode when specified', async () => { - const mockFn = jest.fn() + it("uses preview mode when specified", async () => { + const mockFn = jest + .fn() .mockResolvedValueOnce(makeStatus({ inProgress: true })) .mockResolvedValueOnce(makeStatus({ inProgress: false })); - jest.spyOn(require('core/state'), 'getApiClient').mockReturnValue(makeApiClient(mockFn)); + jest.spyOn(require("core/state"), "getApiClient").mockReturnValue(makeApiClient(mockFn)); - await waitForFetchApiSync('my-guid', 'preview'); + await waitForFetchApiSync("my-guid", "preview"); - expect(mockFn).toHaveBeenNthCalledWith(1, 'my-guid', 'preview', false); - expect(mockFn).toHaveBeenNthCalledWith(2, 'my-guid', 'preview', true); + expect(mockFn).toHaveBeenNthCalledWith(1, "my-guid", "preview", false); + expect(mockFn).toHaveBeenNthCalledWith(2, "my-guid", "preview", true); }); }); diff --git a/src/lib/shared/tests/link-type-detector.test.ts b/src/lib/shared/tests/link-type-detector.test.ts index b8d85cd..51eb3fb 100644 --- a/src/lib/shared/tests/link-type-detector.test.ts +++ b/src/lib/shared/tests/link-type-detector.test.ts @@ -1,11 +1,11 @@ -import { resetState } from 'core/state'; -import { LinkTypeDetector } from '../link-type-detector'; +import { resetState } from "core/state"; +import { LinkTypeDetector } from "../link-type-detector"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -14,290 +14,310 @@ afterEach(() => { // ─── detectLinkType ──────────────────────────────────────────────────────────── -describe('LinkTypeDetector.detectLinkType', () => { +describe("LinkTypeDetector.detectLinkType", () => { const detector = new LinkTypeDetector(); - it('returns unknown when field type is not Content', () => { - const result = detector.detectLinkType({ type: 'Text', settings: {} }); - expect(result.type).toBe('unknown'); - expect(result.strategy).toBe('not-content-field'); + it("returns unknown when field type is not Content", () => { + const result = detector.detectLinkType({ type: "Text", settings: {} }); + expect(result.type).toBe("unknown"); + expect(result.strategy).toBe("not-content-field"); expect(result.requiresMapping).toBe(false); expect(result.followDependencies).toBe(false); }); - it('detects dropdown type when renderAs is dropdown and SharedContent is not _newcontent_agility_', () => { + it("detects dropdown type when renderAs is dropdown and SharedContent is not _newcontent_agility_", () => { const field = { - type: 'Content', - settings: { RenderAs: 'dropdown', SharedContent: 'some-list', LinkedContentNestedTypeID: '', ContentView: '' } + type: "Content", + settings: { RenderAs: "dropdown", SharedContent: "some-list", LinkedContentNestedTypeID: "", ContentView: "" }, }; const result = detector.detectLinkType(field); - expect(result.type).toBe('dropdown'); + expect(result.type).toBe("dropdown"); expect(result.requiresMapping).toBe(true); expect(result.followDependencies).toBe(false); }); - it('does NOT detect dropdown when SharedContent is _newcontent_agility_', () => { + it("does NOT detect dropdown when SharedContent is _newcontent_agility_", () => { const field = { - type: 'Content', - settings: { RenderAs: 'dropdown', SharedContent: '_newcontent_agility_', LinkedContentNestedTypeID: '', ContentView: '' } + type: "Content", + settings: { + RenderAs: "dropdown", + SharedContent: "_newcontent_agility_", + LinkedContentNestedTypeID: "", + ContentView: "", + }, }; const result = detector.detectLinkType(field); - expect(result.type).not.toBe('dropdown'); + expect(result.type).not.toBe("dropdown"); }); - it('detects searchlistbox type when renderAs is searchlistbox', () => { + it("detects searchlistbox type when renderAs is searchlistbox", () => { const field = { - type: 'Content', - settings: { RenderAs: 'searchlistbox', SharedContent: '', LinkedContentNestedTypeID: '', ContentView: '' } + type: "Content", + settings: { RenderAs: "searchlistbox", SharedContent: "", LinkedContentNestedTypeID: "", ContentView: "" }, }; const result = detector.detectLinkType(field); - expect(result.type).toBe('searchlistbox'); + expect(result.type).toBe("searchlistbox"); expect(result.requiresMapping).toBe(true); expect(result.followDependencies).toBe(true); }); - it('detects grid type when renderAs is grid and nestedTypeID is 1', () => { + it("detects grid type when renderAs is grid and nestedTypeID is 1", () => { const field = { - type: 'Content', - settings: { RenderAs: 'grid', LinkedContentNestedTypeID: '1', SharedContent: '', ContentView: '' } + type: "Content", + settings: { RenderAs: "grid", LinkedContentNestedTypeID: "1", SharedContent: "", ContentView: "" }, }; const result = detector.detectLinkType(field); - expect(result.type).toBe('grid'); + expect(result.type).toBe("grid"); expect(result.requiresMapping).toBe(true); expect(result.followDependencies).toBe(true); }); - it('does NOT detect grid when nestedTypeID is not 1', () => { + it("does NOT detect grid when nestedTypeID is not 1", () => { const field = { - type: 'Content', - settings: { RenderAs: 'grid', LinkedContentNestedTypeID: '0', SharedContent: '', ContentView: '' } + type: "Content", + settings: { RenderAs: "grid", LinkedContentNestedTypeID: "0", SharedContent: "", ContentView: "" }, }; const result = detector.detectLinkType(field); - expect(result.type).not.toBe('grid'); + expect(result.type).not.toBe("grid"); }); - it('detects nested type when renderAs is empty and nestedTypeID is 0', () => { + it("detects nested type when renderAs is empty and nestedTypeID is 0", () => { const field = { - type: 'Content', - settings: { RenderAs: '', LinkedContentNestedTypeID: '0', SharedContent: '', ContentView: '' } + type: "Content", + settings: { RenderAs: "", LinkedContentNestedTypeID: "0", SharedContent: "", ContentView: "" }, }; const result = detector.detectLinkType(field); - expect(result.type).toBe('nested'); + expect(result.type).toBe("nested"); expect(result.requiresMapping).toBe(true); expect(result.followDependencies).toBe(true); }); - it('detects shared type when contentView and sharedContent are not _newcontent_agility_', () => { + it("detects shared type when contentView and sharedContent are not _newcontent_agility_", () => { const field = { - type: 'Content', - settings: { RenderAs: 'some-other', LinkedContentNestedTypeID: '', SharedContent: 'some-content', ContentView: 'some-view' } + type: "Content", + settings: { + RenderAs: "some-other", + LinkedContentNestedTypeID: "", + SharedContent: "some-content", + ContentView: "some-view", + }, }; const result = detector.detectLinkType(field); - expect(result.type).toBe('shared'); + expect(result.type).toBe("shared"); expect(result.requiresMapping).toBe(true); expect(result.followDependencies).toBe(false); }); - it('returns unknown for unhandled pattern', () => { + it("returns unknown for unhandled pattern", () => { const field = { - type: 'Content', - settings: { RenderAs: 'other', LinkedContentNestedTypeID: '5', SharedContent: '_newcontent_agility_', ContentView: '_newcontent_agility_' } + type: "Content", + settings: { + RenderAs: "other", + LinkedContentNestedTypeID: "5", + SharedContent: "_newcontent_agility_", + ContentView: "_newcontent_agility_", + }, }; const result = detector.detectLinkType(field); - expect(result.type).toBe('unknown'); - expect(result.strategy).toBe('unhandled-pattern'); + expect(result.type).toBe("unknown"); + expect(result.strategy).toBe("unhandled-pattern"); }); }); // ─── analyzeModelContentFields ───────────────────────────────────────────────── -describe('LinkTypeDetector.analyzeModelContentFields', () => { +describe("LinkTypeDetector.analyzeModelContentFields", () => { const detector = new LinkTypeDetector(); - it('returns empty array when model has no fields', () => { + it("returns empty array when model has no fields", () => { expect(detector.analyzeModelContentFields({})).toEqual([]); expect(detector.analyzeModelContentFields({ fields: [] })).toEqual([]); }); - it('filters out non-Content fields', () => { + it("filters out non-Content fields", () => { const model = { fields: [ - { type: 'Text', name: 'title', settings: {} }, - { type: 'Number', name: 'count', settings: {} }, - ] + { type: "Text", name: "title", settings: {} }, + { type: "Number", name: "count", settings: {} }, + ], }; expect(detector.analyzeModelContentFields(model)).toHaveLength(0); }); - it('includes Content fields with correct fieldName and contentDefinition', () => { + it("includes Content fields with correct fieldName and contentDefinition", () => { const model = { fields: [ { - type: 'Content', - name: 'relatedArticles', + type: "Content", + name: "relatedArticles", settings: { - RenderAs: 'dropdown', - SharedContent: 'articles', - ContentDefinition: 'article', - LinkedContentNestedTypeID: '', - ContentView: '' - } - } - ] + RenderAs: "dropdown", + SharedContent: "articles", + ContentDefinition: "article", + LinkedContentNestedTypeID: "", + ContentView: "", + }, + }, + ], }; const result = detector.analyzeModelContentFields(model); expect(result).toHaveLength(1); - expect(result[0].fieldName).toBe('relatedArticles'); - expect(result[0].contentDefinition).toBe('article'); - expect(result[0].actualContentReferences).toContain('article'); + expect(result[0].fieldName).toBe("relatedArticles"); + expect(result[0].contentDefinition).toBe("article"); + expect(result[0].actualContentReferences).toContain("article"); }); - it('captures LinkeContentDropdownValueField as fieldConfigurationString', () => { + it("captures LinkeContentDropdownValueField as fieldConfigurationString", () => { const model = { fields: [ { - type: 'Content', - name: 'myField', + type: "Content", + name: "myField", settings: { - RenderAs: 'dropdown', - SharedContent: 'some-list', - LinkeContentDropdownValueField: 'id', - LinkeContentDropdownTextField: 'label', - LinkedContentNestedTypeID: '', - ContentView: '', - ContentDefinition: 'myDef' - } - } - ] + RenderAs: "dropdown", + SharedContent: "some-list", + LinkeContentDropdownValueField: "id", + LinkeContentDropdownTextField: "label", + LinkedContentNestedTypeID: "", + ContentView: "", + ContentDefinition: "myDef", + }, + }, + ], }; const result = detector.analyzeModelContentFields(model); - expect(result[0].fieldConfigurationStrings).toContain('id'); - expect(result[0].fieldConfigurationStrings).toContain('label'); + expect(result[0].fieldConfigurationStrings).toContain("id"); + expect(result[0].fieldConfigurationStrings).toContain("label"); }); - it('returns empty actualContentReferences when ContentDefinition is missing', () => { + it("returns empty actualContentReferences when ContentDefinition is missing", () => { const model = { fields: [ { - type: 'Content', - name: 'myField', - settings: { RenderAs: '', LinkedContentNestedTypeID: '0', SharedContent: '', ContentView: '' } - } - ] + type: "Content", + name: "myField", + settings: { RenderAs: "", LinkedContentNestedTypeID: "0", SharedContent: "", ContentView: "" }, + }, + ], }; const result = detector.analyzeModelContentFields(model); expect(result[0].actualContentReferences).toHaveLength(0); - expect(result[0].contentDefinition).toBe(''); + expect(result[0].contentDefinition).toBe(""); }); }); // ─── isFieldConfigurationString ──────────────────────────────────────────────── -describe('LinkTypeDetector.isFieldConfigurationString', () => { +describe("LinkTypeDetector.isFieldConfigurationString", () => { const detector = new LinkTypeDetector(); - it('returns true when referenceString is a field configuration string', () => { + it("returns true when referenceString is a field configuration string", () => { const model = { fields: [ { - type: 'Content', - name: 'myField', + type: "Content", + name: "myField", settings: { - RenderAs: 'dropdown', - SharedContent: 'some-list', - LinkeContentDropdownValueField: 'itemID', - LinkedContentNestedTypeID: '', - ContentView: '', - ContentDefinition: 'def' - } - } - ] + RenderAs: "dropdown", + SharedContent: "some-list", + LinkeContentDropdownValueField: "itemID", + LinkedContentNestedTypeID: "", + ContentView: "", + ContentDefinition: "def", + }, + }, + ], }; - expect(detector.isFieldConfigurationString('itemID', model)).toBe(true); + expect(detector.isFieldConfigurationString("itemID", model)).toBe(true); }); - it('returns false when referenceString is NOT a field configuration string', () => { + it("returns false when referenceString is NOT a field configuration string", () => { const model = { fields: [ { - type: 'Content', - name: 'myField', + type: "Content", + name: "myField", settings: { - RenderAs: 'dropdown', - SharedContent: 'some-list', - LinkeContentDropdownValueField: 'itemID', - LinkedContentNestedTypeID: '', - ContentView: '', - ContentDefinition: 'myDef' - } - } - ] + RenderAs: "dropdown", + SharedContent: "some-list", + LinkeContentDropdownValueField: "itemID", + LinkedContentNestedTypeID: "", + ContentView: "", + ContentDefinition: "myDef", + }, + }, + ], }; - expect(detector.isFieldConfigurationString('myDef', model)).toBe(false); + expect(detector.isFieldConfigurationString("myDef", model)).toBe(false); }); - it('returns false for model with no Content fields', () => { - const model = { fields: [{ type: 'Text', name: 'title', settings: {} }] }; - expect(detector.isFieldConfigurationString('anything', model)).toBe(false); + it("returns false for model with no Content fields", () => { + const model = { fields: [{ type: "Text", name: "title", settings: {} }] }; + expect(detector.isFieldConfigurationString("anything", model)).toBe(false); }); }); // ─── extractRealContentReferences ───────────────────────────────────────────── -describe('LinkTypeDetector.extractRealContentReferences', () => { +describe("LinkTypeDetector.extractRealContentReferences", () => { const detector = new LinkTypeDetector(); - it('returns empty array when no Content fields have ContentDefinition', () => { + it("returns empty array when no Content fields have ContentDefinition", () => { const model = { fields: [ { - type: 'Content', - name: 'myField', - settings: { RenderAs: '', LinkedContentNestedTypeID: '0', SharedContent: '', ContentView: '' } - } - ] + type: "Content", + name: "myField", + settings: { RenderAs: "", LinkedContentNestedTypeID: "0", SharedContent: "", ContentView: "" }, + }, + ], }; expect(detector.extractRealContentReferences(model)).toHaveLength(0); }); - it('returns references for Content fields with ContentDefinition', () => { + it("returns references for Content fields with ContentDefinition", () => { const model = { fields: [ { - type: 'Content', - name: 'hero', + type: "Content", + name: "hero", settings: { - RenderAs: 'dropdown', - SharedContent: 'heroItems', - ContentDefinition: 'hero-module', - LinkedContentNestedTypeID: '', - ContentView: '' - } - } - ] + RenderAs: "dropdown", + SharedContent: "heroItems", + ContentDefinition: "hero-module", + LinkedContentNestedTypeID: "", + ContentView: "", + }, + }, + ], }; const result = detector.extractRealContentReferences(model); expect(result).toHaveLength(1); - expect(result[0].fieldName).toBe('hero'); - expect(result[0].contentDefinition).toBe('hero-module'); + expect(result[0].fieldName).toBe("hero"); + expect(result[0].contentDefinition).toBe("hero-module"); expect(result[0].linkType).toBeDefined(); }); }); // ─── getLinkTypeDescription ──────────────────────────────────────────────────── -describe('LinkTypeDetector.getLinkTypeDescription', () => { +describe("LinkTypeDetector.getLinkTypeDescription", () => { const detector = new LinkTypeDetector(); it.each([ - ['dropdown', 'Dropdown'], - ['searchlistbox', 'SearchListBox'], - ['grid', 'Grid'], - ['nested', 'Nested'], - ['shared', 'Shared'], - ['unknown', 'Unknown'], + ["dropdown", "Dropdown"], + ["searchlistbox", "SearchListBox"], + ["grid", "Grid"], + ["nested", "Nested"], + ["shared", "Shared"], + ["unknown", "Unknown"], ] as const)('returns description containing "%s" keyword for type %s', (type, keyword) => { - const result = detector.getLinkTypeDescription({ type, strategy: '', requiresMapping: false, followDependencies: false }); + const result = detector.getLinkTypeDescription({ + type, + strategy: "", + requiresMapping: false, + followDependencies: false, + }); expect(result).toContain(keyword); }); }); diff --git a/src/lib/shared/tests/sleep.test.ts b/src/lib/shared/tests/sleep.test.ts index fd2aa87..16e7eda 100644 --- a/src/lib/shared/tests/sleep.test.ts +++ b/src/lib/shared/tests/sleep.test.ts @@ -1,11 +1,11 @@ -import { resetState } from 'core/state'; -import { sleep } from '../sleep'; +import { resetState } from "core/state"; +import { sleep } from "../sleep"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); jest.useFakeTimers(); }); @@ -14,21 +14,23 @@ afterEach(() => { jest.useRealTimers(); }); -describe('sleep', () => { - it('returns a Promise', () => { +describe("sleep", () => { + it("returns a Promise", () => { const result = sleep(100); expect(result).toBeInstanceOf(Promise); }); - it('resolves after the specified delay', async () => { + it("resolves after the specified delay", async () => { const p = sleep(500); jest.advanceTimersByTime(500); await expect(p).resolves.toBeUndefined(); }); - it('does not resolve before the delay elapses', async () => { + it("does not resolve before the delay elapses", async () => { let resolved = false; - sleep(1000).then(() => { resolved = true; }); + sleep(1000).then(() => { + resolved = true; + }); jest.advanceTimersByTime(999); // Flush microtasks without advancing macro timers @@ -36,13 +38,13 @@ describe('sleep', () => { expect(resolved).toBe(false); }); - it('resolves immediately for 0 ms', async () => { + it("resolves immediately for 0 ms", async () => { const p = sleep(0); jest.advanceTimersByTime(0); await expect(p).resolves.toBeUndefined(); }); - it.each([100, 500, 1000, 5000])('resolves after %i ms', async (ms) => { + it.each([100, 500, 1000, 5000])("resolves after %i ms", async (ms) => { const p = sleep(ms); jest.advanceTimersByTime(ms); await expect(p).resolves.toBeUndefined(); diff --git a/src/lib/shared/tests/source-publish-status-checker.test.ts b/src/lib/shared/tests/source-publish-status-checker.test.ts index b3acee9..8bf8ea2 100644 --- a/src/lib/shared/tests/source-publish-status-checker.test.ts +++ b/src/lib/shared/tests/source-publish-status-checker.test.ts @@ -1,18 +1,18 @@ -import { resetState } from 'core/state'; +import { resetState } from "core/state"; import { isPublished, filterPublishedContent, filterPublishedPages, checkSourcePublishStatus, - ItemState -} from '../source-publish-status-checker'; -import { fileOperations } from 'core/fileOperations'; + ItemState, +} from "../source-publish-status-checker"; +import { fileOperations } from "core/fileOperations"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -21,8 +21,8 @@ afterEach(() => { // ─── isPublished ────────────────────────────────────────────────────────────── -describe('isPublished', () => { - it('returns true for ItemState.Published (2)', () => { +describe("isPublished", () => { + it("returns true for ItemState.Published (2)", () => { expect(isPublished(ItemState.Published)).toBe(true); }); @@ -35,91 +35,95 @@ describe('isPublished', () => { ItemState.AwaitingApproval, ItemState.Declined, ItemState.Unpublished, - ])('returns false for state %i', (state) => { + ])("returns false for state %i", (state) => { expect(isPublished(state)).toBe(false); }); }); // ─── filterPublishedContent ──────────────────────────────────────────────────── -describe('filterPublishedContent', () => { +describe("filterPublishedContent", () => { const makeMapping = (sourceContentID: number, targetContentID: number) => ({ - sourceGuid: 'src-guid', - targetGuid: 'tgt-guid', + sourceGuid: "src-guid", + targetGuid: "tgt-guid", sourceContentID, targetContentID, sourceVersionID: 1, targetVersionID: 1, }); - it('places targetContentID in publishedContentIds when source item is Published', () => { - jest.spyOn(fileOperations.prototype, 'readJsonFile').mockReturnValue({ - properties: { state: ItemState.Published, modified: '', versionID: 1 } + it("places targetContentID in publishedContentIds when source item is Published", () => { + jest.spyOn(fileOperations.prototype, "readJsonFile").mockReturnValue({ + properties: { state: ItemState.Published, modified: "", versionID: 1 }, }); - const result = filterPublishedContent([makeMapping(10, 20)], 'src', ['en-us']); + const result = filterPublishedContent([makeMapping(10, 20)], "src", ["en-us"]); expect(result.publishedContentIds).toContain(20); expect(result.unpublishedContentIds).toHaveLength(0); expect(result.errors).toHaveLength(0); }); - it('places targetContentID in unpublishedContentIds when source item is not Published', () => { - jest.spyOn(fileOperations.prototype, 'readJsonFile').mockReturnValue({ - properties: { state: ItemState.Staging, modified: '', versionID: 1 } + it("places targetContentID in unpublishedContentIds when source item is not Published", () => { + jest.spyOn(fileOperations.prototype, "readJsonFile").mockReturnValue({ + properties: { state: ItemState.Staging, modified: "", versionID: 1 }, }); - const result = filterPublishedContent([makeMapping(10, 20)], 'src', ['en-us']); + const result = filterPublishedContent([makeMapping(10, 20)], "src", ["en-us"]); expect(result.unpublishedContentIds).toContain(20); expect(result.publishedContentIds).toHaveLength(0); expect(result.errors).toHaveLength(0); }); - it('adds error and defaults to published when source item not found', () => { - jest.spyOn(fileOperations.prototype, 'readJsonFile').mockReturnValue(null); + it("adds error and defaults to published when source item not found", () => { + jest.spyOn(fileOperations.prototype, "readJsonFile").mockReturnValue(null); - const result = filterPublishedContent([makeMapping(99, 200)], 'src', ['en-us']); + const result = filterPublishedContent([makeMapping(99, 200)], "src", ["en-us"]); expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toContain('99'); + expect(result.errors[0]).toContain("99"); expect(result.publishedContentIds).toContain(200); }); - it('stops checking locales after the first one that has the item', () => { - const readJsonFileMock = jest.spyOn(fileOperations.prototype, 'readJsonFile') - .mockReturnValueOnce({ properties: { state: ItemState.Published, modified: '', versionID: 1 } }) + it("stops checking locales after the first one that has the item", () => { + const readJsonFileMock = jest + .spyOn(fileOperations.prototype, "readJsonFile") + .mockReturnValueOnce({ properties: { state: ItemState.Published, modified: "", versionID: 1 } }) .mockReturnValue(null); - filterPublishedContent([makeMapping(10, 20)], 'src', ['en-us', 'fr-fr']); + filterPublishedContent([makeMapping(10, 20)], "src", ["en-us", "fr-fr"]); // Only the first locale should have been checked (readJsonFile called once) expect(readJsonFileMock).toHaveBeenCalledTimes(1); }); - it('tries subsequent locales when first locale returns null', () => { - const readJsonFileMock = jest.spyOn(fileOperations.prototype, 'readJsonFile') + it("tries subsequent locales when first locale returns null", () => { + const readJsonFileMock = jest + .spyOn(fileOperations.prototype, "readJsonFile") .mockReturnValueOnce(null) // en-us — not found - .mockReturnValueOnce({ properties: { state: ItemState.Published, modified: '', versionID: 1 } }); // fr-fr — found + .mockReturnValueOnce({ properties: { state: ItemState.Published, modified: "", versionID: 1 } }); // fr-fr — found - const result = filterPublishedContent([makeMapping(10, 20)], 'src', ['en-us', 'fr-fr']); + const result = filterPublishedContent([makeMapping(10, 20)], "src", ["en-us", "fr-fr"]); expect(readJsonFileMock).toHaveBeenCalledTimes(2); expect(result.publishedContentIds).toContain(20); expect(result.errors).toHaveLength(0); }); - it('handles empty mappings array', () => { - const result = filterPublishedContent([], 'src', ['en-us']); + it("handles empty mappings array", () => { + const result = filterPublishedContent([], "src", ["en-us"]); expect(result.publishedContentIds).toHaveLength(0); expect(result.unpublishedContentIds).toHaveLength(0); expect(result.errors).toHaveLength(0); }); - it('handles item with properties missing (returns null data gracefully)', () => { - jest.spyOn(fileOperations.prototype, 'readJsonFile').mockReturnValue({ /* no properties */ }); + it("handles item with properties missing (returns null data gracefully)", () => { + jest.spyOn(fileOperations.prototype, "readJsonFile").mockReturnValue({ + /* no properties */ + }); - const result = filterPublishedContent([makeMapping(10, 20)], 'src', ['en-us']); + const result = filterPublishedContent([makeMapping(10, 20)], "src", ["en-us"]); // No properties means item not found in this locale — error added, defaults to published expect(result.errors).toHaveLength(1); @@ -129,10 +133,10 @@ describe('filterPublishedContent', () => { // ─── filterPublishedPages ────────────────────────────────────────────────────── -describe('filterPublishedPages', () => { +describe("filterPublishedPages", () => { const makePageMapping = (sourcePageID: number, targetPageID: number) => ({ - sourceGuid: 'src-guid', - targetGuid: 'tgt-guid', + sourceGuid: "src-guid", + targetGuid: "tgt-guid", sourcePageID, targetPageID, sourceVersionID: 1, @@ -141,41 +145,41 @@ describe('filterPublishedPages', () => { targetPageTemplateName: null, }); - it('places targetPageID in publishedPageIds when source page is Published', () => { - jest.spyOn(fileOperations.prototype, 'readJsonFile').mockReturnValue({ - properties: { state: ItemState.Published, modified: '', versionID: 1 } + it("places targetPageID in publishedPageIds when source page is Published", () => { + jest.spyOn(fileOperations.prototype, "readJsonFile").mockReturnValue({ + properties: { state: ItemState.Published, modified: "", versionID: 1 }, }); - const result = filterPublishedPages([makePageMapping(1, 10)], 'src', ['en-us']); + const result = filterPublishedPages([makePageMapping(1, 10)], "src", ["en-us"]); expect(result.publishedPageIds).toContain(10); expect(result.unpublishedPageIds).toHaveLength(0); expect(result.errors).toHaveLength(0); }); - it('places targetPageID in unpublishedPageIds when source page is not Published', () => { - jest.spyOn(fileOperations.prototype, 'readJsonFile').mockReturnValue({ - properties: { state: ItemState.Unpublished, modified: '', versionID: 1 } + it("places targetPageID in unpublishedPageIds when source page is not Published", () => { + jest.spyOn(fileOperations.prototype, "readJsonFile").mockReturnValue({ + properties: { state: ItemState.Unpublished, modified: "", versionID: 1 }, }); - const result = filterPublishedPages([makePageMapping(1, 10)], 'src', ['en-us']); + const result = filterPublishedPages([makePageMapping(1, 10)], "src", ["en-us"]); expect(result.unpublishedPageIds).toContain(10); expect(result.publishedPageIds).toHaveLength(0); }); - it('adds error and defaults to published when page not found', () => { - jest.spyOn(fileOperations.prototype, 'readJsonFile').mockReturnValue(null); + it("adds error and defaults to published when page not found", () => { + jest.spyOn(fileOperations.prototype, "readJsonFile").mockReturnValue(null); - const result = filterPublishedPages([makePageMapping(55, 100)], 'src', ['en-us']); + const result = filterPublishedPages([makePageMapping(55, 100)], "src", ["en-us"]); expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toContain('55'); + expect(result.errors[0]).toContain("55"); expect(result.publishedPageIds).toContain(100); }); - it('handles empty page mappings array', () => { - const result = filterPublishedPages([], 'src', ['en-us']); + it("handles empty page mappings array", () => { + const result = filterPublishedPages([], "src", ["en-us"]); expect(result.publishedPageIds).toHaveLength(0); expect(result.unpublishedPageIds).toHaveLength(0); expect(result.errors).toHaveLength(0); @@ -184,10 +188,10 @@ describe('filterPublishedPages', () => { // ─── checkSourcePublishStatus ────────────────────────────────────────────────── -describe('checkSourcePublishStatus', () => { +describe("checkSourcePublishStatus", () => { const makeContentMapping = (sourceContentID: number, targetContentID: number) => ({ - sourceGuid: 'src', - targetGuid: 'tgt', + sourceGuid: "src", + targetGuid: "tgt", sourceContentID, targetContentID, sourceVersionID: 1, @@ -195,8 +199,8 @@ describe('checkSourcePublishStatus', () => { }); const makePageMapping = (sourcePageID: number, targetPageID: number) => ({ - sourceGuid: 'src', - targetGuid: 'tgt', + sourceGuid: "src", + targetGuid: "tgt", sourcePageID, targetPageID, sourceVersionID: 1, @@ -205,86 +209,65 @@ describe('checkSourcePublishStatus', () => { targetPageTemplateName: null, }); - it('combines content and page results into a single PublishStatusResult', () => { - jest.spyOn(fileOperations.prototype, 'readJsonFile').mockReturnValue({ - properties: { state: ItemState.Published, modified: '', versionID: 1 } + it("combines content and page results into a single PublishStatusResult", () => { + jest.spyOn(fileOperations.prototype, "readJsonFile").mockReturnValue({ + properties: { state: ItemState.Published, modified: "", versionID: 1 }, }); - const result = checkSourcePublishStatus( - [makeContentMapping(1, 10)], - [makePageMapping(2, 20)], - 'src', - ['en-us'] - ); + const result = checkSourcePublishStatus([makeContentMapping(1, 10)], [makePageMapping(2, 20)], "src", ["en-us"]); expect(result.publishedContentIds).toContain(10); expect(result.publishedPageIds).toContain(20); }); - it('deduplicates publishedContentIds', () => { - jest.spyOn(fileOperations.prototype, 'readJsonFile').mockReturnValue({ - properties: { state: ItemState.Published, modified: '', versionID: 1 } + it("deduplicates publishedContentIds", () => { + jest.spyOn(fileOperations.prototype, "readJsonFile").mockReturnValue({ + properties: { state: ItemState.Published, modified: "", versionID: 1 }, }); // Same targetContentID from two locales (simulating duplicate mappings) - const result = checkSourcePublishStatus( - [makeContentMapping(1, 10), makeContentMapping(1, 10)], - [], - 'src', - ['en-us'] - ); - - const occurrences = result.publishedContentIds.filter(id => id === 10); + const result = checkSourcePublishStatus([makeContentMapping(1, 10), makeContentMapping(1, 10)], [], "src", [ + "en-us", + ]); + + const occurrences = result.publishedContentIds.filter((id) => id === 10); expect(occurrences).toHaveLength(1); }); - it('deduplicates unpublishedContentIds', () => { - jest.spyOn(fileOperations.prototype, 'readJsonFile').mockReturnValue({ - properties: { state: ItemState.Staging, modified: '', versionID: 1 } + it("deduplicates unpublishedContentIds", () => { + jest.spyOn(fileOperations.prototype, "readJsonFile").mockReturnValue({ + properties: { state: ItemState.Staging, modified: "", versionID: 1 }, }); - const result = checkSourcePublishStatus( - [makeContentMapping(1, 10), makeContentMapping(1, 10)], - [], - 'src', - ['en-us'] - ); + const result = checkSourcePublishStatus([makeContentMapping(1, 10), makeContentMapping(1, 10)], [], "src", [ + "en-us", + ]); - const occurrences = result.unpublishedContentIds.filter(id => id === 10); + const occurrences = result.unpublishedContentIds.filter((id) => id === 10); expect(occurrences).toHaveLength(1); }); - it('deduplicates publishedPageIds', () => { - jest.spyOn(fileOperations.prototype, 'readJsonFile').mockReturnValue({ - properties: { state: ItemState.Published, modified: '', versionID: 1 } + it("deduplicates publishedPageIds", () => { + jest.spyOn(fileOperations.prototype, "readJsonFile").mockReturnValue({ + properties: { state: ItemState.Published, modified: "", versionID: 1 }, }); - const result = checkSourcePublishStatus( - [], - [makePageMapping(2, 20), makePageMapping(2, 20)], - 'src', - ['en-us'] - ); + const result = checkSourcePublishStatus([], [makePageMapping(2, 20), makePageMapping(2, 20)], "src", ["en-us"]); - const occurrences = result.publishedPageIds.filter(id => id === 20); + const occurrences = result.publishedPageIds.filter((id) => id === 20); expect(occurrences).toHaveLength(1); }); - it('merges errors from both content and page checks', () => { - jest.spyOn(fileOperations.prototype, 'readJsonFile').mockReturnValue(null); + it("merges errors from both content and page checks", () => { + jest.spyOn(fileOperations.prototype, "readJsonFile").mockReturnValue(null); - const result = checkSourcePublishStatus( - [makeContentMapping(1, 10)], - [makePageMapping(2, 20)], - 'src', - ['en-us'] - ); + const result = checkSourcePublishStatus([makeContentMapping(1, 10)], [makePageMapping(2, 20)], "src", ["en-us"]); expect(result.errors).toHaveLength(2); }); - it('handles empty mappings for both content and pages', () => { - const result = checkSourcePublishStatus([], [], 'src', ['en-us']); + it("handles empty mappings for both content and pages", () => { + const result = checkSourcePublishStatus([], [], "src", ["en-us"]); expect(result.publishedContentIds).toHaveLength(0); expect(result.unpublishedContentIds).toHaveLength(0); expect(result.publishedPageIds).toHaveLength(0); diff --git a/src/lib/ui/console/console-manager.ts b/src/lib/ui/console/console-manager.ts index a5f2cda..01080bd 100644 --- a/src/lib/ui/console/console-manager.ts +++ b/src/lib/ui/console/console-manager.ts @@ -1,8 +1,8 @@ -import { fileOperations } from '../../../core/fileOperations'; -import { getState } from '../../../core/state'; -import ansiColors from 'ansi-colors'; +import { fileOperations } from "../../../core/fileOperations"; +import { getState } from "../../../core/state"; +import ansiColors from "ansi-colors"; -export type ConsoleMode = 'headless' | 'verbose' | 'plain'; +export type ConsoleMode = "headless" | "verbose" | "plain"; export interface ConsoleState { mode: ConsoleMode; @@ -23,10 +23,10 @@ export class ConsoleManager { constructor() { this.state = { - mode: 'plain', + mode: "plain", originalLog: console.log, originalError: console.error, - isRedirected: false + isRedirected: false, }; } @@ -39,14 +39,14 @@ export class ConsoleManager { this.redirectionHandlers = handlers; switch (mode) { - case 'headless': + case "headless": this.setupHeadlessMode(); break; - case 'verbose': + case "verbose": this.setupVerboseMode(); break; // Remove blessed case - no longer supported - case 'plain': + case "plain": this.setupPlainMode(); break; } @@ -119,7 +119,7 @@ export class ConsoleManager { * Format console arguments into a single message string */ private formatMessage(args: any[]): string { - return args.map(arg => String(arg)).join(" "); + return args.map((arg) => String(arg)).join(" "); } /** @@ -222,7 +222,7 @@ export class ConsoleManager { /** * Finalize log file and return path */ - finalizeLogFile(operationType: 'pull' | 'push' | 'sync'): string | null { + finalizeLogFile(operationType: "pull" | "push" | "sync"): string | null { if (!this.fileOps) return null; return this.fileOps.finalizeLogFile(operationType); } @@ -240,4 +240,4 @@ export class ConsoleManager { getConsoleState(): ConsoleState { return { ...this.state }; } -} \ No newline at end of file +} diff --git a/src/lib/ui/console/console-setup-utils.ts b/src/lib/ui/console/console-setup-utils.ts index 31a1e22..2003001 100644 --- a/src/lib/ui/console/console-setup-utils.ts +++ b/src/lib/ui/console/console-setup-utils.ts @@ -1,9 +1,9 @@ -import { ConsoleManager, ConsoleMode, ConsoleRedirectionHandlers } from './console-manager'; -import { FileLogger } from './file-logger'; -import { LoggingModes } from './logging-modes'; +import { ConsoleManager, ConsoleMode, ConsoleRedirectionHandlers } from "./console-manager"; +import { FileLogger } from "./file-logger"; +import { LoggingModes } from "./logging-modes"; export interface ConsoleSetupConfig { - operationType: 'pull' | 'push' | 'sync'; + operationType: "pull" | "push" | "sync"; guid?: string; forceMode?: ConsoleMode; handlers?: ConsoleRedirectionHandlers; @@ -22,21 +22,21 @@ export interface ConsoleSetupResult { export function createConsoleSetup(config: ConsoleSetupConfig): ConsoleSetupResult { // Determine mode from state or use forced mode const mode = config.forceMode || LoggingModes.determineMode(); - + // Create file logger const fileLogger = FileLogger.fromState(config.operationType, config.guid); - + // Create console manager const consoleManager = new ConsoleManager(); - + // Setup console mode with file operations and handlers consoleManager.setupMode(mode, fileLogger.getFileOps(), config.handlers); - + return { consoleManager, fileLogger, mode, - shouldRestore: consoleManager.isRedirected() + shouldRestore: consoleManager.isRedirected(), }; } @@ -45,19 +45,19 @@ export function createConsoleSetup(config: ConsoleSetupConfig): ConsoleSetupResu */ export function cleanupConsoleSetup(setup: ConsoleSetupResult): string | null { let logPath: string | null = null; - + // Restore console if it was redirected if (setup.shouldRestore) { setup.consoleManager.restoreConsole(); } - + // Finalize log file try { logPath = setup.fileLogger.finalize(); } catch (error) { - console.error('Error finalizing log file:', error); + console.error("Error finalizing log file:", error); } - + return logPath; } @@ -69,7 +69,7 @@ export function cleanupConsoleSetup(setup: ConsoleSetupResult): string | null { export function createHeadlessConsoleSetup(config: ConsoleSetupConfig): ConsoleSetupResult { return createConsoleSetup({ ...config, - forceMode: 'headless' + forceMode: "headless", }); } @@ -79,7 +79,7 @@ export function createHeadlessConsoleSetup(config: ConsoleSetupConfig): ConsoleS export function createVerboseConsoleSetup(config: ConsoleSetupConfig): ConsoleSetupResult { return createConsoleSetup({ ...config, - forceMode: 'verbose' + forceMode: "verbose", }); } @@ -93,22 +93,22 @@ export function validateConsoleSetup(config: ConsoleSetupConfig): { } { const errors: string[] = []; const warnings: string[] = []; - + // Validate operation type - if (!['pull', 'push', 'sync'].includes(config.operationType)) { + if (!["pull", "push", "sync"].includes(config.operationType)) { errors.push(`Invalid operation type: ${config.operationType}`); } - + // Validate logging state const stateValidation = LoggingModes.validateLoggingState(); if (!stateValidation.isValid) { errors.push(...stateValidation.errors); } warnings.push(...stateValidation.warnings); - + return { isValid: errors.length === 0, errors, - warnings + warnings, }; -} \ No newline at end of file +} diff --git a/src/lib/ui/console/file-logger.ts b/src/lib/ui/console/file-logger.ts index fdf5ad5..b9258ff 100644 --- a/src/lib/ui/console/file-logger.ts +++ b/src/lib/ui/console/file-logger.ts @@ -1,18 +1,18 @@ -import { fileOperations } from '../../../core/fileOperations'; -import { getState } from '../../../core/state'; -import ansiColors from 'ansi-colors'; +import { fileOperations } from "../../../core/fileOperations"; +import { getState } from "../../../core/state"; +import ansiColors from "ansi-colors"; export interface FileLoggerConfig { rootPath: string; guid: string; locale: string; preview: boolean; - operationType: 'pull' | 'push' | 'sync'; + operationType: "pull" | "push" | "sync"; } export interface LogEntry { timestamp: string; - level: 'INFO' | 'ERROR' | 'WARNING' | 'SUCCESS'; + level: "INFO" | "ERROR" | "WARNING" | "SUCCESS"; message: string; context?: string; } @@ -24,41 +24,38 @@ export class FileLogger { constructor(config: FileLoggerConfig) { this.config = config; - this.fileOps = new fileOperations( - config.guid, - config.locale - ); + this.fileOps = new fileOperations(config.guid, config.locale); } /** * Create FileLogger from current state */ - static fromState(operationType: 'pull' | 'push' | 'sync', guid?: string): FileLogger { + static fromState(operationType: "pull" | "push" | "sync", guid?: string): FileLogger { const state = getState(); const targetGuid = guid || state.sourceGuid; - + return new FileLogger({ rootPath: state.rootPath, guid: targetGuid[0], locale: state.locale[0], preview: state.preview, - operationType + operationType, }); } /** * Log a message with specific level */ - log(level: LogEntry['level'], message: string, context?: string): void { + log(level: LogEntry["level"], message: string, context?: string): void { const entry: LogEntry = { timestamp: new Date().toISOString(), level, message, - context + context, }; this.logEntries.push(entry); - + // Use existing fileOperations.appendLogFile (handles ANSI stripping) const formattedMessage = this.formatLogEntry(entry); this.fileOps.appendLogFile(formattedMessage); @@ -68,7 +65,7 @@ export class FileLogger { * Format log entry for file output */ private formatLogEntry(entry: LogEntry): string { - const contextPart = entry.context ? ` [${entry.context}]` : ''; + const contextPart = entry.context ? ` [${entry.context}]` : ""; return `[${entry.timestamp}] [${entry.level}]${contextPart} ${entry.message}\n`; } @@ -76,28 +73,28 @@ export class FileLogger { * Log info message */ logInfo(message: string, context?: string): void { - this.log('INFO', message, context); + this.log("INFO", message, context); } /** * Log error message */ logError(message: string, context?: string): void { - this.log('ERROR', message, context); + this.log("ERROR", message, context); } /** * Log warning message */ logWarning(message: string, context?: string): void { - this.log('WARNING', message, context); + this.log("WARNING", message, context); } /** * Log success message */ logSuccess(message: string, context?: string): void { - this.log('SUCCESS', message, context); + this.log("SUCCESS", message, context); } /** @@ -105,7 +102,7 @@ export class FileLogger { */ logStepStart(stepName: string, details?: string): void { const message = details ? `Starting ${stepName} - ${details}` : `Starting ${stepName}`; - this.logInfo(message, 'STEP'); + this.logInfo(message, "STEP"); } /** @@ -113,14 +110,14 @@ export class FileLogger { */ logStepComplete(stepName: string, details?: string): void { const message = details ? `Completed ${stepName} - ${details}` : `Completed ${stepName}`; - this.logSuccess(message, 'STEP'); + this.logSuccess(message, "STEP"); } /** * Log step error */ logStepError(stepName: string, error: string): void { - this.logError(`Failed ${stepName}: ${error}`, 'STEP'); + this.logError(`Failed ${stepName}: ${error}`, "STEP"); } /** @@ -128,85 +125,97 @@ export class FileLogger { */ logProgress(stepName: string, progress: { current: number; total: number; details?: string }): void { const percentage = Math.round((progress.current / progress.total) * 100); - const details = progress.details ? ` - ${progress.details}` : ''; + const details = progress.details ? ` - ${progress.details}` : ""; const message = `${stepName}: ${progress.current}/${progress.total} (${percentage}%)${details}`; - this.logInfo(message, 'PROGRESS'); + this.logInfo(message, "PROGRESS"); } /** * Log download statistics */ - logDownloadStats(stepName: string, stats: { - total: number; - successful: number; - failed: number; - skipped: number; - duration?: number; - }): void { + logDownloadStats( + stepName: string, + stats: { + total: number; + successful: number; + failed: number; + skipped: number; + duration?: number; + } + ): void { const { total, successful, failed, skipped, duration } = stats; - const durationText = duration ? ` in ${(duration / 1000).toFixed(1)}s` : ''; + const durationText = duration ? ` in ${(duration / 1000).toFixed(1)}s` : ""; const message = `${stepName} completed: ${successful}/${total} successful, ${failed} failed, ${skipped} skipped${durationText}`; - this.logInfo(message, 'STATS'); + this.logInfo(message, "STATS"); } /** * Log upload statistics */ - logUploadStats(stepName: string, stats: { - total: number; - successful: number; - failed: number; - skipped: number; - duration?: number; - }): void { + logUploadStats( + stepName: string, + stats: { + total: number; + successful: number; + failed: number; + skipped: number; + duration?: number; + } + ): void { const { total, successful, failed, skipped, duration } = stats; - const durationText = duration ? ` in ${(duration / 1000).toFixed(1)}s` : ''; + const durationText = duration ? ` in ${(duration / 1000).toFixed(1)}s` : ""; const message = `${stepName} uploaded: ${successful}/${total} successful, ${failed} failed, ${skipped} skipped${durationText}`; - this.logInfo(message, 'STATS'); + this.logInfo(message, "STATS"); } /** * Log summary information */ - logSummary(operation: string, summary: { - startTime: Date; - endTime: Date; - totalSteps: number; - successfulSteps: number; - failedSteps: number; - entityCounts?: Record; - }): void { + logSummary( + operation: string, + summary: { + startTime: Date; + endTime: Date; + totalSteps: number; + successfulSteps: number; + failedSteps: number; + entityCounts?: Record; + } + ): void { const duration = (summary.endTime.getTime() - summary.startTime.getTime()) / 1000; const message = `${operation} Summary: ${summary.successfulSteps}/${summary.totalSteps} steps completed in ${duration.toFixed(1)}s`; - this.logInfo(message, 'SUMMARY'); + this.logInfo(message, "SUMMARY"); if (summary.entityCounts) { const entitySummary = Object.entries(summary.entityCounts) .map(([type, count]) => `${type}: ${count}`) - .join(', '); - this.logInfo(`Entity counts: ${entitySummary}`, 'SUMMARY'); + .join(", "); + this.logInfo(`Entity counts: ${entitySummary}`, "SUMMARY"); } } /** * Log API operation */ - logApiOperation(operation: string, details: { - method: string; - endpoint?: string; - success: boolean; - duration?: number; - error?: string; - }): void { + logApiOperation( + operation: string, + details: { + method: string; + endpoint?: string; + success: boolean; + duration?: number; + error?: string; + } + ): void { const { method, endpoint, success, duration, error } = details; - const endpointText = endpoint ? ` ${endpoint}` : ''; - const durationText = duration ? ` (${duration}ms)` : ''; - const level = success ? 'SUCCESS' : 'ERROR'; - const statusText = success ? 'succeeded' : 'failed'; - const errorText = error ? `: ${error}` : ''; - + const endpointText = endpoint ? ` ${endpoint}` : ""; + const durationText = duration ? ` (${duration}ms)` : ""; + const level = success ? "SUCCESS" : "ERROR"; + const statusText = success ? "succeeded" : "failed"; + const errorText = error ? `: ${error}` : ""; + const message = `${operation} ${method}${endpointText} ${statusText}${durationText}${errorText}`; - this.log(level, message, 'API'); + this.log(level, message, "API"); } /** @@ -215,8 +224,8 @@ export class FileLogger { logConfig(config: Record): void { const configEntries = Object.entries(config) .map(([key, value]) => `${key}: ${value}`) - .join(', '); - this.logInfo(`Configuration: ${configEntries}`, 'CONFIG'); + .join(", "); + this.logInfo(`Configuration: ${configEntries}`, "CONFIG"); } /** @@ -230,12 +239,12 @@ export class FileLogger { timestamp: new Date().toISOString(), guid: this.config.guid, locale: this.config.locale, - operationType: this.config.operationType + operationType: this.config.operationType, }; - this.logInfo('System Information:', 'SYSTEM'); + this.logInfo("System Information:", "SYSTEM"); Object.entries(info).forEach(([key, value]) => { - this.logInfo(` ${key}: ${value}`, 'SYSTEM'); + this.logInfo(` ${key}: ${value}`, "SYSTEM"); }); } @@ -249,29 +258,29 @@ export class FileLogger { /** * Get log entries by level */ - getLogEntriesByLevel(level: LogEntry['level']): LogEntry[] { - return this.logEntries.filter(entry => entry.level === level); + getLogEntriesByLevel(level: LogEntry["level"]): LogEntry[] { + return this.logEntries.filter((entry) => entry.level === level); } /** * Get log entries by context */ getLogEntriesByContext(context: string): LogEntry[] { - return this.logEntries.filter(entry => entry.context === context); + return this.logEntries.filter((entry) => entry.context === context); } /** * Get log statistics */ - getLogStats(): Record { + getLogStats(): Record { const stats = { INFO: 0, ERROR: 0, WARNING: 0, - SUCCESS: 0 + SUCCESS: 0, }; - this.logEntries.forEach(entry => { + this.logEntries.forEach((entry) => { stats[entry.level]++; }); @@ -290,7 +299,7 @@ export class FileLogger { */ finalize(): string { const finalStats = this.getLogStats(); - this.logInfo(`Log finalized with ${this.logEntries.length} entries: ${JSON.stringify(finalStats)}`, 'FINALIZE'); + this.logInfo(`Log finalized with ${this.logEntries.length} entries: ${JSON.stringify(finalStats)}`, "FINALIZE"); return this.fileOps.finalizeLogFile(this.config.operationType); } @@ -300,4 +309,4 @@ export class FileLogger { getFileOps(): fileOperations { return this.fileOps; } -} \ No newline at end of file +} diff --git a/src/lib/ui/console/index.ts b/src/lib/ui/console/index.ts index b59a1a0..af24a4b 100644 --- a/src/lib/ui/console/index.ts +++ b/src/lib/ui/console/index.ts @@ -3,21 +3,14 @@ export { ConsoleManager, type ConsoleMode, type ConsoleState, - type ConsoleRedirectionHandlers -} from './console-manager'; + type ConsoleRedirectionHandlers, +} from "./console-manager"; // File Logger - Enhanced file logging with structured logging -export { - FileLogger, - type FileLoggerConfig, - type LogEntry -} from './file-logger'; +export { FileLogger, type FileLoggerConfig, type LogEntry } from "./file-logger"; // Logging Modes - Mode determination and configuration -export { - LoggingModes, - type LoggingModeConfig -} from './logging-modes'; +export { LoggingModes, type LoggingModeConfig } from "./logging-modes"; // Utility functions for common console operations export { @@ -28,5 +21,5 @@ export { createVerboseConsoleSetup, validateConsoleSetup, type ConsoleSetupConfig, - type ConsoleSetupResult -} from './console-setup-utils'; \ No newline at end of file + type ConsoleSetupResult, +} from "./console-setup-utils"; diff --git a/src/lib/ui/console/logging-modes.ts b/src/lib/ui/console/logging-modes.ts index e90cda0..e0f33a1 100644 --- a/src/lib/ui/console/logging-modes.ts +++ b/src/lib/ui/console/logging-modes.ts @@ -1,5 +1,5 @@ -import { getState } from '../../../core/state'; -import { ConsoleMode } from './console-manager'; +import { getState } from "../../../core/state"; +import { ConsoleMode } from "./console-manager"; export interface LoggingModeConfig { mode: ConsoleMode; @@ -19,16 +19,16 @@ export class LoggingModes { // Priority order: useHeadless > useVerbose > default (plain) // Remove blessed from priority order - + if (state.useHeadless) { - return 'headless'; + return "headless"; } - + if (state.useVerbose) { - return 'verbose'; + return "verbose"; } - - return 'plain'; + + return "plain"; } /** @@ -36,24 +36,24 @@ export class LoggingModes { */ static getConfig(mode: ConsoleMode): LoggingModeConfig { switch (mode) { - case 'headless': + case "headless": return { - mode: 'headless', + mode: "headless", shouldLogToFile: true, shouldLogToConsole: false, shouldRedirectToUI: false, shouldShowProgress: false, - shouldShowVerboseOutput: false + shouldShowVerboseOutput: false, }; - case 'verbose': + case "verbose": return { - mode: 'verbose', + mode: "verbose", shouldLogToFile: true, shouldLogToConsole: true, shouldRedirectToUI: false, shouldShowProgress: true, - shouldShowVerboseOutput: true + shouldShowVerboseOutput: true, }; // Remove blessed case: @@ -67,15 +67,15 @@ export class LoggingModes { // shouldShowVerboseOutput: false // }; - case 'plain': + case "plain": default: return { - mode: 'plain', + mode: "plain", shouldLogToFile: true, shouldLogToConsole: true, shouldRedirectToUI: false, shouldShowProgress: true, - shouldShowVerboseOutput: false + shouldShowVerboseOutput: false, }; } } @@ -124,19 +124,19 @@ export class LoggingModes { /** * Conditional logging based on mode */ - static shouldLog(logType: 'console' | 'file' | 'ui' | 'progress' | 'verbose'): boolean { + static shouldLog(logType: "console" | "file" | "ui" | "progress" | "verbose"): boolean { const config = this.getCurrentConfig(); switch (logType) { - case 'console': + case "console": return config.shouldLogToConsole; - case 'file': + case "file": return config.shouldLogToFile; - case 'ui': + case "ui": return config.shouldRedirectToUI; - case 'progress': + case "progress": return config.shouldShowProgress; - case 'verbose': + case "verbose": return config.shouldShowVerboseOutput; default: return true; @@ -153,20 +153,20 @@ export class LoggingModes { includeProgressBars: boolean; } { switch (mode) { - case 'headless': + case "headless": return { includeTimestamp: true, includeLevel: true, includeColors: false, - includeProgressBars: false + includeProgressBars: false, }; - case 'verbose': + case "verbose": return { includeTimestamp: false, includeLevel: false, includeColors: true, - includeProgressBars: true + includeProgressBars: true, }; // Remove blessed case: @@ -178,13 +178,13 @@ export class LoggingModes { // includeProgressBars: true // }; - case 'plain': + case "plain": default: return { includeTimestamp: false, includeLevel: false, includeColors: true, - includeProgressBars: true + includeProgressBars: true, }; } } @@ -200,20 +200,20 @@ export class LoggingModes { /** * Check if we should show specific content based on mode */ - static shouldShowContent(contentType: 'errors' | 'warnings' | 'info' | 'debug' | 'stats'): boolean { + static shouldShowContent(contentType: "errors" | "warnings" | "info" | "debug" | "stats"): boolean { const config = this.getCurrentConfig(); const format = this.getCurrentLogFormat(); switch (contentType) { - case 'errors': + case "errors": return true; // Always show errors - case 'warnings': + case "warnings": return true; // Always show warnings - case 'info': + case "info": return config.shouldLogToConsole || config.shouldRedirectToUI; - case 'debug': + case "debug": return config.shouldShowVerboseOutput; - case 'stats': + case "stats": return config.shouldShowProgress; default: return true; @@ -230,20 +230,20 @@ export class LoggingModes { bufferedOutput: boolean; } { switch (mode) { - case 'headless': + case "headless": return { redirectConsole: true, showInlineProgress: false, enableColors: false, - bufferedOutput: false + bufferedOutput: false, }; - case 'verbose': + case "verbose": return { redirectConsole: false, showInlineProgress: true, enableColors: true, - bufferedOutput: false + bufferedOutput: false, }; // Remove blessed case: @@ -255,13 +255,13 @@ export class LoggingModes { // bufferedOutput: true // }; - case 'plain': + case "plain": default: return { redirectConsole: false, showInlineProgress: true, enableColors: true, - bufferedOutput: false + bufferedOutput: false, }; } } @@ -287,32 +287,29 @@ export class LoggingModes { const errors: string[] = []; // Check for conflicting modes - const modeCount = [ - state.useHeadless, - state.useVerbose - ].filter(Boolean).length; + const modeCount = [state.useHeadless, state.useVerbose].filter(Boolean).length; if (modeCount > 1) { - warnings.push('Multiple console modes specified, using priority order: headless > verbose'); + warnings.push("Multiple console modes specified, using priority order: headless > verbose"); } // Check for required state values if (!state.rootPath) { - errors.push('rootPath is required for file logging'); + errors.push("rootPath is required for file logging"); } if (!state.sourceGuid?.length) { - errors.push('sourceGuid is required for logging operations'); + errors.push("sourceGuid is required for logging operations"); } if (!state.locale?.length) { - errors.push('locale is required for logging operations'); + errors.push("locale is required for logging operations"); } return { isValid: errors.length === 0, warnings, - errors + errors, }; } @@ -321,15 +318,15 @@ export class LoggingModes { */ static getModeDescription(mode: ConsoleMode): string { switch (mode) { - case 'headless': - return 'Headless mode - All output redirected to log file only'; - case 'verbose': - return 'Verbose mode - Full console output with detailed progress information'; + case "headless": + return "Headless mode - All output redirected to log file only"; + case "verbose": + return "Verbose mode - Full console output with detailed progress information"; // Remove blessed case - no longer supported - case 'plain': - return 'Plain mode - Standard console output with basic progress information'; + case "plain": + return "Plain mode - Standard console output with basic progress information"; default: - return 'Unknown mode'; + return "Unknown mode"; } } @@ -340,4 +337,4 @@ export class LoggingModes { const mode = this.determineMode(); return this.getModeDescription(mode); } -} \ No newline at end of file +} diff --git a/src/lib/ui/console/tests/console-manager.test.ts b/src/lib/ui/console/tests/console-manager.test.ts index a4c5045..5111015 100644 --- a/src/lib/ui/console/tests/console-manager.test.ts +++ b/src/lib/ui/console/tests/console-manager.test.ts @@ -1,11 +1,11 @@ -import { resetState } from 'core/state'; -import { ConsoleManager } from 'lib/ui/console/console-manager'; +import { resetState } from "core/state"; +import { ConsoleManager } from "lib/ui/console/console-manager"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -14,16 +14,16 @@ afterEach(() => { // ─── formatMessage (pure, via getConsoleState round-trip) ───────────────────── -describe('ConsoleManager', () => { +describe("ConsoleManager", () => { it('starts with mode "plain" and isRedirected false', () => { const mgr = new ConsoleManager(); const s = mgr.getConsoleState(); - expect(s.mode).toBe('plain'); + expect(s.mode).toBe("plain"); expect(s.isRedirected).toBe(false); }); - it('getMode returns the current mode', () => { + it("getMode returns the current mode", () => { const mgr = new ConsoleManager(); - expect(mgr.getMode()).toBe('plain'); + expect(mgr.getMode()).toBe("plain"); }); }); diff --git a/src/lib/ui/console/tests/console-setup-utils.test.ts b/src/lib/ui/console/tests/console-setup-utils.test.ts index f3127aa..8eb1a16 100644 --- a/src/lib/ui/console/tests/console-setup-utils.test.ts +++ b/src/lib/ui/console/tests/console-setup-utils.test.ts @@ -1,21 +1,21 @@ -import { resetState } from 'core/state'; -import { state } from 'core/state'; -import { validateConsoleSetup, ConsoleSetupConfig } from 'lib/ui/console/console-setup-utils'; +import { resetState } from "core/state"; +import { state } from "core/state"; +import { validateConsoleSetup, ConsoleSetupConfig } from "lib/ui/console/console-setup-utils"; // Mock heavy dependencies that touch the filesystem or console internals -jest.mock('core/fileOperations'); -jest.mock('lib/ui/console/console-manager'); -jest.mock('lib/ui/console/file-logger'); +jest.mock("core/fileOperations"); +jest.mock("lib/ui/console/console-manager"); +jest.mock("lib/ui/console/file-logger"); beforeEach(() => { resetState(); // Provide valid state so validateLoggingState passes by default - state.rootPath = 'agility-files'; - state.sourceGuid = ['test-guid']; - state.locale = ['en-us']; - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + state.rootPath = "agility-files"; + state.sourceGuid = ["test-guid"]; + state.locale = ["en-us"]; + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -24,61 +24,61 @@ afterEach(() => { // ─── validateConsoleSetup ───────────────────────────────────────────────────── -describe('validateConsoleSetup', () => { +describe("validateConsoleSetup", () => { it('is valid for "pull" operation with complete state', () => { - const config: ConsoleSetupConfig = { operationType: 'pull' }; + const config: ConsoleSetupConfig = { operationType: "pull" }; const result = validateConsoleSetup(config); expect(result.isValid).toBe(true); expect(result.errors).toHaveLength(0); }); it('is valid for "push" operation', () => { - const result = validateConsoleSetup({ operationType: 'push' }); + const result = validateConsoleSetup({ operationType: "push" }); expect(result.isValid).toBe(true); }); it('is valid for "sync" operation', () => { - const result = validateConsoleSetup({ operationType: 'sync' }); + const result = validateConsoleSetup({ operationType: "sync" }); expect(result.isValid).toBe(true); }); - it('is invalid for an unknown operation type', () => { - const config = { operationType: 'delete' as any }; + it("is invalid for an unknown operation type", () => { + const config = { operationType: "delete" as any }; const result = validateConsoleSetup(config); expect(result.isValid).toBe(false); - expect(result.errors.some(e => e.includes('delete'))).toBe(true); + expect(result.errors.some((e) => e.includes("delete"))).toBe(true); }); - it('surfaces logging-state errors when rootPath is empty', () => { - state.rootPath = ''; - const result = validateConsoleSetup({ operationType: 'pull' }); + it("surfaces logging-state errors when rootPath is empty", () => { + state.rootPath = ""; + const result = validateConsoleSetup({ operationType: "pull" }); expect(result.isValid).toBe(false); - expect(result.errors.some(e => e.toLowerCase().includes('rootpath'))).toBe(true); + expect(result.errors.some((e) => e.toLowerCase().includes("rootpath"))).toBe(true); }); - it('reports error when sourceGuid is an empty array', () => { + it("reports error when sourceGuid is an empty array", () => { state.sourceGuid = []; - const result = validateConsoleSetup({ operationType: 'pull' }); - expect(result.errors.some(e => e.toLowerCase().includes('sourceguid'))).toBe(true); + const result = validateConsoleSetup({ operationType: "pull" }); + expect(result.errors.some((e) => e.toLowerCase().includes("sourceguid"))).toBe(true); }); - it('reports error when locale is an empty array', () => { + it("reports error when locale is an empty array", () => { state.locale = []; - const result = validateConsoleSetup({ operationType: 'pull' }); - expect(result.errors.some(e => e.toLowerCase().includes('locale'))).toBe(true); + const result = validateConsoleSetup({ operationType: "pull" }); + expect(result.errors.some((e) => e.toLowerCase().includes("locale"))).toBe(true); }); - it('returns warnings (not errors) when both headless and verbose are set', () => { + it("returns warnings (not errors) when both headless and verbose are set", () => { state.useHeadless = true; state.useVerbose = true; - const result = validateConsoleSetup({ operationType: 'sync' }); + const result = validateConsoleSetup({ operationType: "sync" }); // Should still be valid (warnings, not errors) expect(result.isValid).toBe(true); expect(result.warnings.length).toBeGreaterThan(0); }); - it('returns an errors array and warnings array in the result shape', () => { - const result = validateConsoleSetup({ operationType: 'pull' }); + it("returns an errors array and warnings array in the result shape", () => { + const result = validateConsoleSetup({ operationType: "pull" }); expect(Array.isArray(result.errors)).toBe(true); expect(Array.isArray(result.warnings)).toBe(true); }); diff --git a/src/lib/ui/console/tests/file-logger.test.ts b/src/lib/ui/console/tests/file-logger.test.ts index 995d11d..f2dfffb 100644 --- a/src/lib/ui/console/tests/file-logger.test.ts +++ b/src/lib/ui/console/tests/file-logger.test.ts @@ -1,20 +1,20 @@ -import { resetState } from 'core/state'; -import { state } from 'core/state'; -import { FileLogger, FileLoggerConfig, LogEntry } from 'lib/ui/console/file-logger'; -import { fileOperations } from 'core/fileOperations'; +import { resetState } from "core/state"; +import { state } from "core/state"; +import { FileLogger, FileLoggerConfig, LogEntry } from "lib/ui/console/file-logger"; +import { fileOperations } from "core/fileOperations"; // Mock fileOperations so no file I/O occurs -jest.mock('core/fileOperations'); +jest.mock("core/fileOperations"); const MockFileOps = fileOperations as jest.MockedClass; function makeLogger(overrides: Partial = {}): FileLogger { const config: FileLoggerConfig = { - rootPath: 'agility-files', - guid: 'test-guid', - locale: 'en-us', + rootPath: "agility-files", + guid: "test-guid", + locale: "en-us", preview: false, - operationType: 'pull', + operationType: "pull", ...overrides, }; return new FileLogger(config); @@ -24,10 +24,10 @@ beforeEach(() => { resetState(); MockFileOps.mockClear(); MockFileOps.prototype.appendLogFile = jest.fn(); - MockFileOps.prototype.finalizeLogFile = jest.fn().mockReturnValue('/path/to/log.txt'); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + MockFileOps.prototype.finalizeLogFile = jest.fn().mockReturnValue("/path/to/log.txt"); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -36,10 +36,10 @@ afterEach(() => { // ─── formatLogEntry (via log) ────────────────────────────────────────────────── -describe('FileLogger formatLogEntry', () => { - it('calls appendLogFile with a line containing timestamp, level, and message', () => { +describe("FileLogger formatLogEntry", () => { + it("calls appendLogFile with a line containing timestamp, level, and message", () => { const logger = makeLogger(); - logger.logInfo('hello world'); + logger.logInfo("hello world"); const call = MockFileOps.prototype.appendLogFile as jest.Mock; expect(call).toHaveBeenCalledTimes(1); @@ -47,18 +47,18 @@ describe('FileLogger formatLogEntry', () => { expect(written).toMatch(/\[.*\] \[INFO\] hello world\n/); }); - it('includes context in square brackets when provided', () => { + it("includes context in square brackets when provided", () => { const logger = makeLogger(); - logger.log('WARNING', 'watch out', 'MY_CTX'); + logger.log("WARNING", "watch out", "MY_CTX"); const call = MockFileOps.prototype.appendLogFile as jest.Mock; const written: string = call.mock.calls[0][0]; - expect(written).toContain('[MY_CTX]'); + expect(written).toContain("[MY_CTX]"); }); - it('omits context brackets when context is undefined', () => { + it("omits context brackets when context is undefined", () => { const logger = makeLogger(); - logger.logError('something failed'); + logger.logError("something failed"); const call = MockFileOps.prototype.appendLogFile as jest.Mock; const written: string = call.mock.calls[0][0]; @@ -69,21 +69,21 @@ describe('FileLogger formatLogEntry', () => { // ─── getLogStats ─────────────────────────────────────────────────────────────── -describe('FileLogger.getLogStats', () => { - it('returns all zeros when no entries have been logged', () => { +describe("FileLogger.getLogStats", () => { + it("returns all zeros when no entries have been logged", () => { const logger = makeLogger(); const stats = logger.getLogStats(); expect(stats).toEqual({ INFO: 0, ERROR: 0, WARNING: 0, SUCCESS: 0 }); }); - it('counts each level correctly', () => { + it("counts each level correctly", () => { const logger = makeLogger(); - logger.logInfo('a'); - logger.logInfo('b'); - logger.logError('c'); - logger.logWarning('d'); - logger.logSuccess('e'); - logger.logSuccess('f'); + logger.logInfo("a"); + logger.logInfo("b"); + logger.logError("c"); + logger.logWarning("d"); + logger.logSuccess("e"); + logger.logSuccess("f"); const stats = logger.getLogStats(); expect(stats.INFO).toBe(2); @@ -95,89 +95,89 @@ describe('FileLogger.getLogStats', () => { // ─── getLogEntriesByLevel ────────────────────────────────────────────────────── -describe('FileLogger.getLogEntriesByLevel', () => { - it('returns empty array when no entries match', () => { +describe("FileLogger.getLogEntriesByLevel", () => { + it("returns empty array when no entries match", () => { const logger = makeLogger(); - logger.logInfo('msg'); - expect(logger.getLogEntriesByLevel('ERROR')).toHaveLength(0); + logger.logInfo("msg"); + expect(logger.getLogEntriesByLevel("ERROR")).toHaveLength(0); }); - it('returns only entries with the requested level', () => { + it("returns only entries with the requested level", () => { const logger = makeLogger(); - logger.logInfo('info1'); - logger.logError('err1'); - logger.logError('err2'); + logger.logInfo("info1"); + logger.logError("err1"); + logger.logError("err2"); - const errors = logger.getLogEntriesByLevel('ERROR'); + const errors = logger.getLogEntriesByLevel("ERROR"); expect(errors).toHaveLength(2); - errors.forEach(e => expect(e.level).toBe('ERROR')); + errors.forEach((e) => expect(e.level).toBe("ERROR")); }); - it('returns LogEntry objects with the correct shape', () => { + it("returns LogEntry objects with the correct shape", () => { const logger = makeLogger(); - logger.logInfo('msg'); - const entries = logger.getLogEntriesByLevel('INFO'); - expect(entries[0]).toHaveProperty('level', 'INFO'); - expect(entries[0]).toHaveProperty('message', 'msg'); - expect(entries[0]).toHaveProperty('timestamp'); + logger.logInfo("msg"); + const entries = logger.getLogEntriesByLevel("INFO"); + expect(entries[0]).toHaveProperty("level", "INFO"); + expect(entries[0]).toHaveProperty("message", "msg"); + expect(entries[0]).toHaveProperty("timestamp"); }); }); // ─── getLogEntries ───────────────────────────────────────────────────────────── -describe('FileLogger.getLogEntries', () => { - it('returns all logged entries in order', () => { +describe("FileLogger.getLogEntries", () => { + it("returns all logged entries in order", () => { const logger = makeLogger(); - logger.logInfo('first'); - logger.logError('second'); + logger.logInfo("first"); + logger.logError("second"); const entries = logger.getLogEntries(); expect(entries).toHaveLength(2); - expect(entries[0].message).toBe('first'); - expect(entries[1].message).toBe('second'); + expect(entries[0].message).toBe("first"); + expect(entries[1].message).toBe("second"); }); - it('each entry has timestamp, level, and message', () => { + it("each entry has timestamp, level, and message", () => { const logger = makeLogger(); - logger.logWarning('test'); + logger.logWarning("test"); const [entry] = logger.getLogEntries(); expect(entry.timestamp).toBeDefined(); - expect(entry.level).toBe('WARNING'); - expect(entry.message).toBe('test'); + expect(entry.level).toBe("WARNING"); + expect(entry.message).toBe("test"); }); }); // ─── getLogEntriesByContext ──────────────────────────────────────────────────── -describe('FileLogger.getLogEntriesByContext', () => { - it('returns entries matching context', () => { +describe("FileLogger.getLogEntriesByContext", () => { + it("returns entries matching context", () => { const logger = makeLogger(); - logger.logStepStart('step-one', 'details'); - logger.logInfo('plain info'); + logger.logStepStart("step-one", "details"); + logger.logInfo("plain info"); - const stepEntries = logger.getLogEntriesByContext('STEP'); + const stepEntries = logger.getLogEntriesByContext("STEP"); expect(stepEntries.length).toBeGreaterThan(0); - stepEntries.forEach(e => expect(e.context).toBe('STEP')); + stepEntries.forEach((e) => expect(e.context).toBe("STEP")); }); }); // ─── clearLogEntries ─────────────────────────────────────────────────────────── -describe('FileLogger.clearLogEntries', () => { - it('empties the in-memory log after clearing', () => { +describe("FileLogger.clearLogEntries", () => { + it("empties the in-memory log after clearing", () => { const logger = makeLogger(); - logger.logInfo('a'); - logger.logError('b'); + logger.logInfo("a"); + logger.logError("b"); expect(logger.getLogEntries()).toHaveLength(2); logger.clearLogEntries(); expect(logger.getLogEntries()).toHaveLength(0); }); - it('getLogStats returns zeros after clear', () => { + it("getLogStats returns zeros after clear", () => { const logger = makeLogger(); - logger.logInfo('a'); + logger.logInfo("a"); logger.clearLogEntries(); expect(logger.getLogStats()).toEqual({ INFO: 0, ERROR: 0, WARNING: 0, SUCCESS: 0 }); }); @@ -185,75 +185,75 @@ describe('FileLogger.clearLogEntries', () => { // ─── logProgress ─────────────────────────────────────────────────────────────── -describe('FileLogger.logProgress', () => { - it('creates an INFO entry with percentage in PROGRESS context', () => { +describe("FileLogger.logProgress", () => { + it("creates an INFO entry with percentage in PROGRESS context", () => { const logger = makeLogger(); - logger.logProgress('Download', { current: 50, total: 100 }); + logger.logProgress("Download", { current: 50, total: 100 }); - const entries = logger.getLogEntriesByContext('PROGRESS'); + const entries = logger.getLogEntriesByContext("PROGRESS"); expect(entries).toHaveLength(1); - expect(entries[0].level).toBe('INFO'); - expect(entries[0].message).toContain('50%'); + expect(entries[0].level).toBe("INFO"); + expect(entries[0].message).toContain("50%"); }); }); // ─── logDownloadStats / logUploadStats ──────────────────────────────────────── -describe('FileLogger.logDownloadStats', () => { - it('logs STATS context entry with successful/failed/skipped counts', () => { +describe("FileLogger.logDownloadStats", () => { + it("logs STATS context entry with successful/failed/skipped counts", () => { const logger = makeLogger(); - logger.logDownloadStats('Photos', { total: 10, successful: 8, failed: 1, skipped: 1 }); + logger.logDownloadStats("Photos", { total: 10, successful: 8, failed: 1, skipped: 1 }); - const entries = logger.getLogEntriesByContext('STATS'); + const entries = logger.getLogEntriesByContext("STATS"); expect(entries).toHaveLength(1); - expect(entries[0].message).toContain('8/10'); + expect(entries[0].message).toContain("8/10"); }); - it('includes duration when provided', () => { + it("includes duration when provided", () => { const logger = makeLogger(); - logger.logDownloadStats('Photos', { total: 10, successful: 10, failed: 0, skipped: 0, duration: 2000 }); - const [entry] = logger.getLogEntriesByContext('STATS'); - expect(entry.message).toContain('2.0s'); + logger.logDownloadStats("Photos", { total: 10, successful: 10, failed: 0, skipped: 0, duration: 2000 }); + const [entry] = logger.getLogEntriesByContext("STATS"); + expect(entry.message).toContain("2.0s"); }); }); -describe('FileLogger.logUploadStats', () => { +describe("FileLogger.logUploadStats", () => { it('logs STATS context entry mentioning "uploaded"', () => { const logger = makeLogger(); - logger.logUploadStats('Articles', { total: 5, successful: 5, failed: 0, skipped: 0 }); - const [entry] = logger.getLogEntriesByContext('STATS'); - expect(entry.message).toContain('uploaded'); + logger.logUploadStats("Articles", { total: 5, successful: 5, failed: 0, skipped: 0 }); + const [entry] = logger.getLogEntriesByContext("STATS"); + expect(entry.message).toContain("uploaded"); }); }); // ─── finalize ───────────────────────────────────────────────────────────────── -describe('FileLogger.finalize', () => { - it('calls finalizeLogFile on the underlying fileOps', () => { +describe("FileLogger.finalize", () => { + it("calls finalizeLogFile on the underlying fileOps", () => { const logger = makeLogger(); logger.finalize(); - expect(MockFileOps.prototype.finalizeLogFile).toHaveBeenCalledWith('pull'); + expect(MockFileOps.prototype.finalizeLogFile).toHaveBeenCalledWith("pull"); }); - it('adds a FINALIZE context entry before finalizing', () => { + it("adds a FINALIZE context entry before finalizing", () => { const logger = makeLogger(); logger.finalize(); - const entries = logger.getLogEntriesByContext('FINALIZE'); + const entries = logger.getLogEntriesByContext("FINALIZE"); expect(entries).toHaveLength(1); }); }); // ─── fromState ──────────────────────────────────────────────────────────────── -describe('FileLogger.fromState', () => { - it('creates a logger using sourceGuid and locale from state', () => { - state.sourceGuid = ['from-state-guid']; - state.locale = ['fr-ca']; - state.rootPath = 'agility-files'; +describe("FileLogger.fromState", () => { + it("creates a logger using sourceGuid and locale from state", () => { + state.sourceGuid = ["from-state-guid"]; + state.locale = ["fr-ca"]; + state.rootPath = "agility-files"; - const logger = FileLogger.fromState('push'); + const logger = FileLogger.fromState("push"); expect(logger).toBeInstanceOf(FileLogger); // Constructor was called with the state-derived guid/locale - expect(MockFileOps).toHaveBeenCalledWith('from-state-guid', 'fr-ca'); + expect(MockFileOps).toHaveBeenCalledWith("from-state-guid", "fr-ca"); }); }); diff --git a/src/lib/ui/console/tests/logging-modes.test.ts b/src/lib/ui/console/tests/logging-modes.test.ts index 1a4b7a0..85be7d2 100644 --- a/src/lib/ui/console/tests/logging-modes.test.ts +++ b/src/lib/ui/console/tests/logging-modes.test.ts @@ -1,12 +1,12 @@ -import { resetState } from 'core/state'; -import { state } from 'core/state'; -import { LoggingModes } from 'lib/ui/console/logging-modes'; +import { resetState } from "core/state"; +import { state } from "core/state"; +import { LoggingModes } from "lib/ui/console/logging-modes"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -15,34 +15,34 @@ afterEach(() => { // ─── determineMode ───────────────────────────────────────────────────────────── -describe('LoggingModes.determineMode', () => { +describe("LoggingModes.determineMode", () => { it('returns "plain" by default', () => { - expect(LoggingModes.determineMode()).toBe('plain'); + expect(LoggingModes.determineMode()).toBe("plain"); }); it('returns "headless" when useHeadless is true', () => { state.useHeadless = true; - expect(LoggingModes.determineMode()).toBe('headless'); + expect(LoggingModes.determineMode()).toBe("headless"); }); it('returns "verbose" when useVerbose is true', () => { state.useVerbose = true; - expect(LoggingModes.determineMode()).toBe('verbose'); + expect(LoggingModes.determineMode()).toBe("verbose"); }); it('prioritises "headless" over "verbose" when both are set', () => { state.useHeadless = true; state.useVerbose = true; - expect(LoggingModes.determineMode()).toBe('headless'); + expect(LoggingModes.determineMode()).toBe("headless"); }); }); // ─── getConfig ───────────────────────────────────────────────────────────────── -describe('LoggingModes.getConfig', () => { +describe("LoggingModes.getConfig", () => { it('returns correct config for "headless"', () => { - const config = LoggingModes.getConfig('headless'); - expect(config.mode).toBe('headless'); + const config = LoggingModes.getConfig("headless"); + expect(config.mode).toBe("headless"); expect(config.shouldLogToFile).toBe(true); expect(config.shouldLogToConsole).toBe(false); expect(config.shouldRedirectToUI).toBe(false); @@ -51,8 +51,8 @@ describe('LoggingModes.getConfig', () => { }); it('returns correct config for "verbose"', () => { - const config = LoggingModes.getConfig('verbose'); - expect(config.mode).toBe('verbose'); + const config = LoggingModes.getConfig("verbose"); + expect(config.mode).toBe("verbose"); expect(config.shouldLogToFile).toBe(true); expect(config.shouldLogToConsole).toBe(true); expect(config.shouldShowProgress).toBe(true); @@ -60,8 +60,8 @@ describe('LoggingModes.getConfig', () => { }); it('returns correct config for "plain"', () => { - const config = LoggingModes.getConfig('plain'); - expect(config.mode).toBe('plain'); + const config = LoggingModes.getConfig("plain"); + expect(config.mode).toBe("plain"); expect(config.shouldLogToFile).toBe(true); expect(config.shouldLogToConsole).toBe(true); expect(config.shouldShowProgress).toBe(true); @@ -71,58 +71,58 @@ describe('LoggingModes.getConfig', () => { // ─── getCurrentConfig ────────────────────────────────────────────────────────── -describe('LoggingModes.getCurrentConfig', () => { - it('reflects state change to headless', () => { +describe("LoggingModes.getCurrentConfig", () => { + it("reflects state change to headless", () => { state.useHeadless = true; const config = LoggingModes.getCurrentConfig(); - expect(config.mode).toBe('headless'); + expect(config.mode).toBe("headless"); expect(config.shouldLogToConsole).toBe(false); }); - it('reflects state change to verbose', () => { + it("reflects state change to verbose", () => { state.useVerbose = true; const config = LoggingModes.getCurrentConfig(); - expect(config.mode).toBe('verbose'); + expect(config.mode).toBe("verbose"); expect(config.shouldShowVerboseOutput).toBe(true); }); }); // ─── supports* / requires* helpers ──────────────────────────────────────────── -describe('LoggingModes support helpers', () => { - it('supportsInteractiveUI always returns false', () => { +describe("LoggingModes support helpers", () => { + it("supportsInteractiveUI always returns false", () => { expect(LoggingModes.supportsInteractiveUI()).toBe(false); state.useHeadless = true; expect(LoggingModes.supportsInteractiveUI()).toBe(false); }); - it('supportsProgressBars returns false in headless mode', () => { + it("supportsProgressBars returns false in headless mode", () => { state.useHeadless = true; expect(LoggingModes.supportsProgressBars()).toBe(false); }); - it('supportsProgressBars returns true in plain mode', () => { + it("supportsProgressBars returns true in plain mode", () => { expect(LoggingModes.supportsProgressBars()).toBe(true); }); - it('supportsVerboseOutput returns true only in verbose mode', () => { + it("supportsVerboseOutput returns true only in verbose mode", () => { expect(LoggingModes.supportsVerboseOutput()).toBe(false); state.useVerbose = true; expect(LoggingModes.supportsVerboseOutput()).toBe(true); }); - it('supportsConsoleOutput returns false in headless mode', () => { + it("supportsConsoleOutput returns false in headless mode", () => { state.useHeadless = true; expect(LoggingModes.supportsConsoleOutput()).toBe(false); }); - it('requiresFileLogging returns true for all modes', () => { + it("requiresFileLogging returns true for all modes", () => { expect(LoggingModes.requiresFileLogging()).toBe(true); state.useHeadless = true; expect(LoggingModes.requiresFileLogging()).toBe(true); }); - it('requiresUIRedirection returns false for all modes', () => { + it("requiresUIRedirection returns false for all modes", () => { expect(LoggingModes.requiresUIRedirection()).toBe(false); state.useVerbose = true; expect(LoggingModes.requiresUIRedirection()).toBe(false); @@ -131,57 +131,57 @@ describe('LoggingModes support helpers', () => { // ─── shouldLog ───────────────────────────────────────────────────────────────── -describe('LoggingModes.shouldLog', () => { +describe("LoggingModes.shouldLog", () => { it('shouldLog("console") returns false in headless mode', () => { state.useHeadless = true; - expect(LoggingModes.shouldLog('console')).toBe(false); + expect(LoggingModes.shouldLog("console")).toBe(false); }); it('shouldLog("file") returns true in all modes', () => { - expect(LoggingModes.shouldLog('file')).toBe(true); + expect(LoggingModes.shouldLog("file")).toBe(true); state.useHeadless = true; - expect(LoggingModes.shouldLog('file')).toBe(true); + expect(LoggingModes.shouldLog("file")).toBe(true); }); it('shouldLog("verbose") returns true only in verbose mode', () => { - expect(LoggingModes.shouldLog('verbose')).toBe(false); + expect(LoggingModes.shouldLog("verbose")).toBe(false); state.useVerbose = true; - expect(LoggingModes.shouldLog('verbose')).toBe(true); + expect(LoggingModes.shouldLog("verbose")).toBe(true); }); it('shouldLog("progress") returns false in headless mode', () => { state.useHeadless = true; - expect(LoggingModes.shouldLog('progress')).toBe(false); + expect(LoggingModes.shouldLog("progress")).toBe(false); }); it('shouldLog("ui") returns false for all modes', () => { - expect(LoggingModes.shouldLog('ui')).toBe(false); + expect(LoggingModes.shouldLog("ui")).toBe(false); state.useHeadless = true; - expect(LoggingModes.shouldLog('ui')).toBe(false); + expect(LoggingModes.shouldLog("ui")).toBe(false); }); }); // ─── getLogFormat ────────────────────────────────────────────────────────────── -describe('LoggingModes.getLogFormat', () => { - it('headless format includes timestamp and level, no colors', () => { - const fmt = LoggingModes.getLogFormat('headless'); +describe("LoggingModes.getLogFormat", () => { + it("headless format includes timestamp and level, no colors", () => { + const fmt = LoggingModes.getLogFormat("headless"); expect(fmt.includeTimestamp).toBe(true); expect(fmt.includeLevel).toBe(true); expect(fmt.includeColors).toBe(false); expect(fmt.includeProgressBars).toBe(false); }); - it('verbose format has no timestamp/level but has colors and progress bars', () => { - const fmt = LoggingModes.getLogFormat('verbose'); + it("verbose format has no timestamp/level but has colors and progress bars", () => { + const fmt = LoggingModes.getLogFormat("verbose"); expect(fmt.includeTimestamp).toBe(false); expect(fmt.includeLevel).toBe(false); expect(fmt.includeColors).toBe(true); expect(fmt.includeProgressBars).toBe(true); }); - it('plain format has no timestamp/level but has colors and progress bars', () => { - const fmt = LoggingModes.getLogFormat('plain'); + it("plain format has no timestamp/level but has colors and progress bars", () => { + const fmt = LoggingModes.getLogFormat("plain"); expect(fmt.includeTimestamp).toBe(false); expect(fmt.includeColors).toBe(true); expect(fmt.includeProgressBars).toBe(true); @@ -190,99 +190,99 @@ describe('LoggingModes.getLogFormat', () => { // ─── getModeSpecificBehavior ─────────────────────────────────────────────────── -describe('LoggingModes.getModeSpecificBehavior', () => { - it('headless redirects console, disables inline progress', () => { - const b = LoggingModes.getModeSpecificBehavior('headless'); +describe("LoggingModes.getModeSpecificBehavior", () => { + it("headless redirects console, disables inline progress", () => { + const b = LoggingModes.getModeSpecificBehavior("headless"); expect(b.redirectConsole).toBe(true); expect(b.showInlineProgress).toBe(false); expect(b.enableColors).toBe(false); }); - it('verbose does not redirect console and enables inline progress', () => { - const b = LoggingModes.getModeSpecificBehavior('verbose'); + it("verbose does not redirect console and enables inline progress", () => { + const b = LoggingModes.getModeSpecificBehavior("verbose"); expect(b.redirectConsole).toBe(false); expect(b.showInlineProgress).toBe(true); expect(b.enableColors).toBe(true); }); - it('plain does not redirect console', () => { - const b = LoggingModes.getModeSpecificBehavior('plain'); + it("plain does not redirect console", () => { + const b = LoggingModes.getModeSpecificBehavior("plain"); expect(b.redirectConsole).toBe(false); }); }); // ─── shouldShowContent ───────────────────────────────────────────────────────── -describe('LoggingModes.shouldShowContent', () => { - it('always shows errors', () => { - expect(LoggingModes.shouldShowContent('errors')).toBe(true); +describe("LoggingModes.shouldShowContent", () => { + it("always shows errors", () => { + expect(LoggingModes.shouldShowContent("errors")).toBe(true); state.useHeadless = true; - expect(LoggingModes.shouldShowContent('errors')).toBe(true); + expect(LoggingModes.shouldShowContent("errors")).toBe(true); }); - it('always shows warnings', () => { - expect(LoggingModes.shouldShowContent('warnings')).toBe(true); + it("always shows warnings", () => { + expect(LoggingModes.shouldShowContent("warnings")).toBe(true); }); - it('shows debug only in verbose mode', () => { - expect(LoggingModes.shouldShowContent('debug')).toBe(false); + it("shows debug only in verbose mode", () => { + expect(LoggingModes.shouldShowContent("debug")).toBe(false); state.useVerbose = true; - expect(LoggingModes.shouldShowContent('debug')).toBe(true); + expect(LoggingModes.shouldShowContent("debug")).toBe(true); }); - it('shows stats when progress is enabled (plain mode)', () => { - expect(LoggingModes.shouldShowContent('stats')).toBe(true); + it("shows stats when progress is enabled (plain mode)", () => { + expect(LoggingModes.shouldShowContent("stats")).toBe(true); }); - it('does not show stats in headless mode (no progress)', () => { + it("does not show stats in headless mode (no progress)", () => { state.useHeadless = true; - expect(LoggingModes.shouldShowContent('stats')).toBe(false); + expect(LoggingModes.shouldShowContent("stats")).toBe(false); }); }); // ─── validateLoggingState ────────────────────────────────────────────────────── -describe('LoggingModes.validateLoggingState', () => { - it('is valid with default state (rootPath, sourceGuid, locale populated)', () => { - state.rootPath = 'agility-files'; - state.sourceGuid = ['test-guid']; - state.locale = ['en-us']; +describe("LoggingModes.validateLoggingState", () => { + it("is valid with default state (rootPath, sourceGuid, locale populated)", () => { + state.rootPath = "agility-files"; + state.sourceGuid = ["test-guid"]; + state.locale = ["en-us"]; const result = LoggingModes.validateLoggingState(); expect(result.isValid).toBe(true); expect(result.errors).toHaveLength(0); }); - it('reports error when rootPath is missing', () => { - state.rootPath = ''; - state.sourceGuid = ['test-guid']; - state.locale = ['en-us']; + it("reports error when rootPath is missing", () => { + state.rootPath = ""; + state.sourceGuid = ["test-guid"]; + state.locale = ["en-us"]; const result = LoggingModes.validateLoggingState(); expect(result.isValid).toBe(false); - expect(result.errors.some(e => e.includes('rootPath'))).toBe(true); + expect(result.errors.some((e) => e.includes("rootPath"))).toBe(true); }); - it('reports error when sourceGuid is empty array', () => { - state.rootPath = 'agility-files'; + it("reports error when sourceGuid is empty array", () => { + state.rootPath = "agility-files"; state.sourceGuid = []; - state.locale = ['en-us']; + state.locale = ["en-us"]; const result = LoggingModes.validateLoggingState(); expect(result.isValid).toBe(false); - expect(result.errors.some(e => e.includes('sourceGuid'))).toBe(true); + expect(result.errors.some((e) => e.includes("sourceGuid"))).toBe(true); }); - it('reports error when locale is empty array', () => { - state.rootPath = 'agility-files'; - state.sourceGuid = ['test-guid']; + it("reports error when locale is empty array", () => { + state.rootPath = "agility-files"; + state.sourceGuid = ["test-guid"]; state.locale = []; const result = LoggingModes.validateLoggingState(); expect(result.isValid).toBe(false); - expect(result.errors.some(e => e.includes('locale'))).toBe(true); + expect(result.errors.some((e) => e.includes("locale"))).toBe(true); }); - it('warns when both headless and verbose are set', () => { - state.rootPath = 'agility-files'; - state.sourceGuid = ['test-guid']; - state.locale = ['en-us']; + it("warns when both headless and verbose are set", () => { + state.rootPath = "agility-files"; + state.sourceGuid = ["test-guid"]; + state.locale = ["en-us"]; state.useHeadless = true; state.useVerbose = true; const result = LoggingModes.validateLoggingState(); @@ -292,11 +292,11 @@ describe('LoggingModes.validateLoggingState', () => { // ─── getModeDescription ──────────────────────────────────────────────────────── -describe('LoggingModes.getModeDescription', () => { +describe("LoggingModes.getModeDescription", () => { it.each([ - ['headless', 'Headless'], - ['verbose', 'Verbose'], - ['plain', 'Plain'], + ["headless", "Headless"], + ["verbose", "Verbose"], + ["plain", "Plain"], ] as const)('includes mode keyword for "%s"', (mode, keyword) => { expect(LoggingModes.getModeDescription(mode)).toContain(keyword); }); diff --git a/src/lib/ui/progress/index.ts b/src/lib/ui/progress/index.ts index 198b8f5..8f5b59f 100644 --- a/src/lib/ui/progress/index.ts +++ b/src/lib/ui/progress/index.ts @@ -5,13 +5,8 @@ export { type ProgressCallbackType, type StepStatus, type ProgressSummary, - type ProgressCallbacks -} from './progress-tracker'; + type ProgressCallbacks, +} from "./progress-tracker"; // Progress Calculator - Mathematical progress calculations and utilities -export { - ProgressCalculator, - type ProgressStats, - type ProgressWindow -} from './progress-calculator'; - +export { ProgressCalculator, type ProgressStats, type ProgressWindow } from "./progress-calculator"; diff --git a/src/lib/ui/progress/progress-calculator.ts b/src/lib/ui/progress/progress-calculator.ts index bd1f40a..6c551ab 100644 --- a/src/lib/ui/progress/progress-calculator.ts +++ b/src/lib/ui/progress/progress-calculator.ts @@ -61,7 +61,7 @@ export class ProgressCalculator { elapsedTime, estimatedTotalTime, estimatedRemainingTime, - itemsPerSecond + itemsPerSecond, }; } @@ -121,7 +121,7 @@ export class ProgressCalculator { } else if (itemsPerSecond > 0) { return `${(itemsPerSecond * 60).toFixed(1)}/min`; } else { - return '0/sec'; + return "0/sec"; } } @@ -130,19 +130,19 @@ export class ProgressCalculator { */ static formatProgressSummary(stats: ProgressStats): string { const parts: string[] = []; - + parts.push(`${stats.processed}/${stats.total} (${stats.percentage}%)`); - + if (stats.itemsPerSecond !== undefined) { parts.push(ProgressCalculator.formatRate(stats.itemsPerSecond)); } - + if (stats.estimatedRemainingTime !== undefined) { const eta = ProgressCalculator.formatDuration(stats.estimatedRemainingTime); parts.push(`ETA: ${eta}`); } - return parts.join(' - '); + return parts.join(" - "); } /** @@ -150,7 +150,7 @@ export class ProgressCalculator { */ static calculateOverallProgress(stepProgresses: number[]): number { if (stepProgresses.length === 0) return 0; - + const totalProgress = stepProgresses.reduce((sum, progress) => sum + progress, 0); return Math.floor(totalProgress / stepProgresses.length); } @@ -160,14 +160,14 @@ export class ProgressCalculator { */ static calculateWeightedProgress(stepProgresses: number[], weights: number[]): number { if (stepProgresses.length !== weights.length || stepProgresses.length === 0) return 0; - + const totalWeight = weights.reduce((sum, weight) => sum + weight, 0); if (totalWeight === 0) return 0; - + const weightedSum = stepProgresses.reduce((sum, progress, index) => { - return sum + (progress * weights[index]); + return sum + progress * weights[index]; }, 0); - + return Math.floor(weightedSum / totalWeight); } @@ -206,7 +206,7 @@ export class ProgressCalculator { return (processed: number, total: number) => { const now = Date.now(); - + // Always report completion (100%) if (processed >= total) { const stats = calculator.calculateProgress(processed, total); @@ -242,7 +242,7 @@ export class ProgressCalculator { return calculator.calculateProgress(totalProcessed, totalItems); }, - reset: () => calculator.reset() + reset: () => calculator.reset(), }; } @@ -257,13 +257,12 @@ export class ProgressCalculator { return (processed: number, total: number) => { const actualPercentage = ProgressCalculator.calculatePercentage(processed, total); - + // Use exponential smoothing to reduce jitter - const smoothedPercentage = lastReportedPercentage + - smoothingFactor * (actualPercentage - lastReportedPercentage); - + const smoothedPercentage = lastReportedPercentage + smoothingFactor * (actualPercentage - lastReportedPercentage); + const roundedPercentage = Math.floor(smoothedPercentage); - + // Only update if there's a meaningful change or completion if (roundedPercentage !== lastReportedPercentage || actualPercentage === 100) { updateCallback(actualPercentage === 100 ? 100 : roundedPercentage); @@ -291,7 +290,7 @@ export class ProgressCalculator { return { historySize: this.progressHistory.length, currentRate: this.calculateItemsPerSecond(), - elapsedTime: Date.now() - this.startTime.getTime() + elapsedTime: Date.now() - this.startTime.getTime(), }; } -} \ No newline at end of file +} diff --git a/src/lib/ui/progress/progress-tracker.ts b/src/lib/ui/progress/progress-tracker.ts index e608b86..7062366 100644 --- a/src/lib/ui/progress/progress-tracker.ts +++ b/src/lib/ui/progress/progress-tracker.ts @@ -1,7 +1,11 @@ -import { getState } from '../../../core/state'; +import { getState } from "../../../core/state"; -export type ProgressStatus = 'pending' | 'success' | 'error' | 'progress'; -export type ProgressCallbackType = (processed: number, total: number, status?: "success" | "error" | "progress") => void; +export type ProgressStatus = "pending" | "success" | "error" | "progress"; +export type ProgressCallbackType = ( + processed: number, + total: number, + status?: "success" | "error" | "progress" +) => void; export interface StepStatus { name: string; @@ -33,9 +37,9 @@ export class ProgressTracker { private steps: StepStatus[] = []; private callbacks: ProgressCallbacks = {}; private startTime: Date = new Date(); - private operationName: string = 'Operation'; + private operationName: string = "Operation"; - constructor(operationName: string = 'Operation') { + constructor(operationName: string = "Operation") { this.operationName = operationName; this.startTime = new Date(); } @@ -44,10 +48,10 @@ export class ProgressTracker { * Initialize steps for tracking */ initializeSteps(stepNames: string[]): void { - this.steps = stepNames.map(name => ({ + this.steps = stepNames.map((name) => ({ name, - status: 'pending', - percentage: 0 + status: "pending", + percentage: 0, })); this.startTime = new Date(); } @@ -65,7 +69,7 @@ export class ProgressTracker { startStep(stepIndex: number): void { if (stepIndex < 0 || stepIndex >= this.steps.length) return; - this.steps[stepIndex].status = 'progress'; + this.steps[stepIndex].status = "progress"; this.steps[stepIndex].percentage = 0; this.steps[stepIndex].startTime = new Date(); this.steps[stepIndex].error = undefined; @@ -76,20 +80,20 @@ export class ProgressTracker { /** * Update step progress */ - updateStepProgress(stepIndex: number, percentage: number, status: ProgressStatus = 'progress'): void { + updateStepProgress(stepIndex: number, percentage: number, status: ProgressStatus = "progress"): void { if (stepIndex < 0 || stepIndex >= this.steps.length) return; this.steps[stepIndex].percentage = Math.min(100, Math.max(0, percentage)); this.steps[stepIndex].status = status; - if (status === 'success' || status === 'error') { + if (status === "success" || status === "error") { this.steps[stepIndex].endTime = new Date(); this.steps[stepIndex].percentage = 100; } this.callbacks.onStepProgress?.(stepIndex, this.steps[stepIndex].name, this.steps[stepIndex].percentage); - if (status === 'success' || status === 'error') { + if (status === "success" || status === "error") { this.callbacks.onStepComplete?.(stepIndex, this.steps[stepIndex].name, status); } } @@ -98,7 +102,7 @@ export class ProgressTracker { * Mark step as successful */ completeStep(stepIndex: number): void { - this.updateStepProgress(stepIndex, 100, 'success'); + this.updateStepProgress(stepIndex, 100, "success"); } /** @@ -108,7 +112,7 @@ export class ProgressTracker { if (stepIndex < 0 || stepIndex >= this.steps.length) return; this.steps[stepIndex].error = error; - this.updateStepProgress(stepIndex, this.steps[stepIndex].percentage, 'error'); + this.updateStepProgress(stepIndex, this.steps[stepIndex].percentage, "error"); } /** @@ -117,13 +121,13 @@ export class ProgressTracker { createStepProgressCallback(stepIndex: number): ProgressCallbackType { return (processed: number, total: number, status = "progress") => { const percentage = total > 0 ? Math.floor((processed / total) * 100) : 0; - + if (status === "error") { this.failStep(stepIndex); } else if (status === "success") { this.completeStep(stepIndex); } else { - this.updateStepProgress(stepIndex, percentage, 'progress'); + this.updateStepProgress(stepIndex, percentage, "progress"); } }; } @@ -133,9 +137,9 @@ export class ProgressTracker { */ getSummary(): ProgressSummary { const totalSteps = this.steps.length; - const successfulSteps = this.steps.filter(step => step.status === 'success').length; - const errorSteps = this.steps.filter(step => step.status === 'error').length; - const pendingSteps = this.steps.filter(step => step.status === 'pending').length; + const successfulSteps = this.steps.filter((step) => step.status === "success").length; + const errorSteps = this.steps.filter((step) => step.status === "error").length; + const pendingSteps = this.steps.filter((step) => step.status === "pending").length; const overallSuccess = errorSteps === 0 && successfulSteps === totalSteps; const now = new Date(); @@ -152,7 +156,7 @@ export class ProgressTracker { pendingSteps, overallSuccess, totalDuration, - durationFormatted + durationFormatted, }; this.callbacks.onOverallProgress?.(summary); @@ -171,7 +175,7 @@ export class ProgressTracker { * Get step status by name */ getStepByName(stepName: string): StepStatus | null { - const step = this.steps.find(s => s.name === stepName); + const step = this.steps.find((s) => s.name === stepName); return step ? { ...step } : null; } @@ -179,49 +183,49 @@ export class ProgressTracker { * Get all steps */ getAllSteps(): StepStatus[] { - return this.steps.map(step => ({ ...step })); + return this.steps.map((step) => ({ ...step })); } /** * Get step index by name */ getStepIndex(stepName: string): number { - return this.steps.findIndex(s => s.name === stepName); + return this.steps.findIndex((s) => s.name === stepName); } /** * Check if all steps are complete */ isComplete(): boolean { - return this.steps.every(step => step.status === 'success' || step.status === 'error'); + return this.steps.every((step) => step.status === "success" || step.status === "error"); } /** * Check if any steps have errors */ hasErrors(): boolean { - return this.steps.some(step => step.status === 'error'); + return this.steps.some((step) => step.status === "error"); } /** * Get steps with errors */ getFailedSteps(): StepStatus[] { - return this.steps.filter(step => step.status === 'error').map(step => ({ ...step })); + return this.steps.filter((step) => step.status === "error").map((step) => ({ ...step })); } /** * Get completed steps */ getCompletedSteps(): StepStatus[] { - return this.steps.filter(step => step.status === 'success').map(step => ({ ...step })); + return this.steps.filter((step) => step.status === "success").map((step) => ({ ...step })); } /** * Get pending steps */ getPendingSteps(): StepStatus[] { - return this.steps.filter(step => step.status === 'pending').map(step => ({ ...step })); + return this.steps.filter((step) => step.status === "pending").map((step) => ({ ...step })); } /** @@ -229,7 +233,7 @@ export class ProgressTracker { */ getOverallProgress(): number { if (this.steps.length === 0) return 0; - + const totalProgress = this.steps.reduce((sum, step) => sum + step.percentage, 0); return Math.floor(totalProgress / this.steps.length); } @@ -238,13 +242,13 @@ export class ProgressTracker { * Reset all steps to pending */ reset(): void { - this.steps = this.steps.map(step => ({ + this.steps = this.steps.map((step) => ({ ...step, - status: 'pending', + status: "pending", percentage: 0, startTime: undefined, endTime: undefined, - error: undefined + error: undefined, })); this.startTime = new Date(); } @@ -256,22 +260,25 @@ export class ProgressTracker { const summary = this.getSummary(); const lines: string[] = []; - lines.push(`${this.operationName} completed: ${summary.successfulSteps}/${summary.totalSteps} steps successful, ${summary.errorSteps} errors, ${summary.durationFormatted}`); + lines.push( + `${this.operationName} completed: ${summary.successfulSteps}/${summary.totalSteps} steps successful, ${summary.errorSteps} errors, ${summary.durationFormatted}` + ); if (includeDetails) { if (summary.errorSteps > 0) { - lines.push('Failed steps:'); - this.getFailedSteps().forEach(step => { - lines.push(` ✗ ${step.name}${step.error ? `: ${step.error}` : ''}`); + lines.push("Failed steps:"); + this.getFailedSteps().forEach((step) => { + lines.push(` ✗ ${step.name}${step.error ? `: ${step.error}` : ""}`); }); } if (summary.successfulSteps > 0) { - lines.push('Successful steps:'); - this.getCompletedSteps().forEach(step => { - const duration = step.startTime && step.endTime - ? `(${Math.floor((step.endTime.getTime() - step.startTime.getTime()) / 1000)}s)` - : ''; + lines.push("Successful steps:"); + this.getCompletedSteps().forEach((step) => { + const duration = + step.startTime && step.endTime + ? `(${Math.floor((step.endTime.getTime() - step.startTime.getTime()) / 1000)}s)` + : ""; lines.push(` ✓ ${step.name} ${duration}`); }); } @@ -283,15 +290,12 @@ export class ProgressTracker { /** * Create a throttled progress callback for memory optimization */ - createThrottledProgressCallback( - stepIndex: number, - updateInterval: number = 500 - ): ProgressCallbackType { + createThrottledProgressCallback(stepIndex: number, updateInterval: number = 500): ProgressCallbackType { let lastUpdate = 0; return (processed: number, total: number, status = "progress") => { const now = Date.now(); - + // Always process success/error status immediately if (status === "success" || status === "error") { this.createStepProgressCallback(stepIndex)(processed, total, status); @@ -326,4 +330,4 @@ export class ProgressTracker { getStartTime(): Date { return new Date(this.startTime); } -} \ No newline at end of file +} diff --git a/src/lib/ui/progress/tests/progress-calculator.test.ts b/src/lib/ui/progress/tests/progress-calculator.test.ts index ab2af45..7040bb3 100644 --- a/src/lib/ui/progress/tests/progress-calculator.test.ts +++ b/src/lib/ui/progress/tests/progress-calculator.test.ts @@ -1,11 +1,11 @@ -import { resetState } from 'core/state'; -import { ProgressCalculator, ProgressStats } from 'lib/ui/progress/progress-calculator'; +import { resetState } from "core/state"; +import { ProgressCalculator, ProgressStats } from "lib/ui/progress/progress-calculator"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -14,46 +14,46 @@ afterEach(() => { // ─── calculatePercentage ─────────────────────────────────────────────────────── -describe('ProgressCalculator.calculatePercentage', () => { +describe("ProgressCalculator.calculatePercentage", () => { it.each([ [0, 100, 0], [50, 100, 50], [100, 100, 100], [1, 3, 33], [2, 3, 66], - ])('returns %i%% for %i/%i', (processed, total, expected) => { + ])("returns %i%% for %i/%i", (processed, total, expected) => { expect(ProgressCalculator.calculatePercentage(processed, total)).toBe(expected); }); - it('returns 0 when total is 0 (division-by-zero guard)', () => { + it("returns 0 when total is 0 (division-by-zero guard)", () => { expect(ProgressCalculator.calculatePercentage(5, 0)).toBe(0); }); - it('returns 0 when total is negative (division-by-zero guard)', () => { + it("returns 0 when total is negative (division-by-zero guard)", () => { expect(ProgressCalculator.calculatePercentage(5, -1)).toBe(0); }); - it('clamps to 100 when processed exceeds total', () => { + it("clamps to 100 when processed exceeds total", () => { expect(ProgressCalculator.calculatePercentage(200, 100)).toBe(100); }); - it('clamps to 0 when processed is negative', () => { + it("clamps to 0 when processed is negative", () => { expect(ProgressCalculator.calculatePercentage(-10, 100)).toBe(0); }); }); // ─── formatDuration ──────────────────────────────────────────────────────────── -describe('ProgressCalculator.formatDuration', () => { +describe("ProgressCalculator.formatDuration", () => { it.each([ - [0, '0s'], - [500, '0s'], - [1000, '1s'], - [59000, '59s'], - [60000, '1m 0s'], - [90000, '1m 30s'], - [3600000, '1h 0m 0s'], - [3661000, '1h 1m 1s'], + [0, "0s"], + [500, "0s"], + [1000, "1s"], + [59000, "59s"], + [60000, "1m 0s"], + [90000, "1m 30s"], + [3600000, "1h 0m 0s"], + [3661000, "1h 1m 1s"], ])('formats %ims as "%s"', (ms, expected) => { expect(ProgressCalculator.formatDuration(ms)).toBe(expected); }); @@ -61,36 +61,36 @@ describe('ProgressCalculator.formatDuration', () => { // ─── formatRate ──────────────────────────────────────────────────────────────── -describe('ProgressCalculator.formatRate', () => { +describe("ProgressCalculator.formatRate", () => { it('returns "0/sec" for zero rate', () => { - expect(ProgressCalculator.formatRate(0)).toBe('0/sec'); + expect(ProgressCalculator.formatRate(0)).toBe("0/sec"); }); - it('returns per-minute rate for very slow rates (< 1/sec)', () => { + it("returns per-minute rate for very slow rates (< 1/sec)", () => { const result = ProgressCalculator.formatRate(0.5); - expect(result).toContain('/min'); + expect(result).toContain("/min"); }); - it('returns per-second rate for rates between 1 and 1000', () => { + it("returns per-second rate for rates between 1 and 1000", () => { const result = ProgressCalculator.formatRate(42.5); - expect(result).toContain('/sec'); - expect(result).not.toContain('k'); + expect(result).toContain("/sec"); + expect(result).not.toContain("k"); }); - it('returns k/sec notation for rates > 1000', () => { + it("returns k/sec notation for rates > 1000", () => { const result = ProgressCalculator.formatRate(1500); - expect(result).toContain('k/sec'); + expect(result).toContain("k/sec"); }); - it('correctly formats exact 1000 boundary', () => { + it("correctly formats exact 1000 boundary", () => { const result = ProgressCalculator.formatRate(1001); - expect(result).toContain('k/sec'); + expect(result).toContain("k/sec"); }); }); // ─── formatProgressSummary ───────────────────────────────────────────────────── -describe('ProgressCalculator.formatProgressSummary', () => { +describe("ProgressCalculator.formatProgressSummary", () => { const baseStats: ProgressStats = { processed: 50, total: 100, @@ -100,72 +100,72 @@ describe('ProgressCalculator.formatProgressSummary', () => { elapsedTime: 5000, }; - it('includes processed/total and percentage', () => { + it("includes processed/total and percentage", () => { const result = ProgressCalculator.formatProgressSummary(baseStats); - expect(result).toContain('50/100'); - expect(result).toContain('50%'); + expect(result).toContain("50/100"); + expect(result).toContain("50%"); }); - it('includes rate when itemsPerSecond is defined', () => { + it("includes rate when itemsPerSecond is defined", () => { const stats = { ...baseStats, itemsPerSecond: 10 }; const result = ProgressCalculator.formatProgressSummary(stats); - expect(result).toContain('/sec'); + expect(result).toContain("/sec"); }); - it('includes ETA when estimatedRemainingTime is defined', () => { + it("includes ETA when estimatedRemainingTime is defined", () => { const stats = { ...baseStats, estimatedRemainingTime: 5000 }; const result = ProgressCalculator.formatProgressSummary(stats); - expect(result).toContain('ETA:'); + expect(result).toContain("ETA:"); }); - it('omits rate and ETA when not provided', () => { + it("omits rate and ETA when not provided", () => { const result = ProgressCalculator.formatProgressSummary(baseStats); - expect(result).not.toContain('ETA:'); - expect(result).not.toContain('/sec'); + expect(result).not.toContain("ETA:"); + expect(result).not.toContain("/sec"); }); }); // ─── calculateOverallProgress ────────────────────────────────────────────────── -describe('ProgressCalculator.calculateOverallProgress', () => { - it('returns 0 for empty array', () => { +describe("ProgressCalculator.calculateOverallProgress", () => { + it("returns 0 for empty array", () => { expect(ProgressCalculator.calculateOverallProgress([])).toBe(0); }); - it('returns the single value for one-element array', () => { + it("returns the single value for one-element array", () => { expect(ProgressCalculator.calculateOverallProgress([70])).toBe(70); }); - it('returns floor of average for multiple steps', () => { + it("returns floor of average for multiple steps", () => { expect(ProgressCalculator.calculateOverallProgress([100, 50])).toBe(75); }); - it('returns 0 when all steps are at 0', () => { + it("returns 0 when all steps are at 0", () => { expect(ProgressCalculator.calculateOverallProgress([0, 0, 0])).toBe(0); }); }); // ─── calculateWeightedProgress ──────────────────────────────────────────────── -describe('ProgressCalculator.calculateWeightedProgress', () => { - it('returns 0 for empty arrays', () => { +describe("ProgressCalculator.calculateWeightedProgress", () => { + it("returns 0 for empty arrays", () => { expect(ProgressCalculator.calculateWeightedProgress([], [])).toBe(0); }); - it('returns 0 when array lengths differ', () => { + it("returns 0 when array lengths differ", () => { expect(ProgressCalculator.calculateWeightedProgress([50, 100], [1])).toBe(0); }); - it('returns 0 when all weights are zero', () => { + it("returns 0 when all weights are zero", () => { expect(ProgressCalculator.calculateWeightedProgress([50, 100], [0, 0])).toBe(0); }); - it('calculates correct weighted average', () => { + it("calculates correct weighted average", () => { // step1: 100% weight 1, step2: 0% weight 3 → (100*1 + 0*3) / 4 = 25 expect(ProgressCalculator.calculateWeightedProgress([100, 0], [1, 3])).toBe(25); }); - it('floors the result', () => { + it("floors the result", () => { // (50*1 + 100*1) / 2 = 75 (exact, no flooring needed but verifies) expect(ProgressCalculator.calculateWeightedProgress([50, 100], [1, 1])).toBe(75); }); @@ -173,24 +173,24 @@ describe('ProgressCalculator.calculateWeightedProgress', () => { // ─── calculateConservativeProgress ──────────────────────────────────────────── -describe('ProgressCalculator.calculateConservativeProgress', () => { - it('uses default divisor of 20', () => { +describe("ProgressCalculator.calculateConservativeProgress", () => { + it("uses default divisor of 20", () => { expect(ProgressCalculator.calculateConservativeProgress(100)).toBe(5); }); - it('uses custom divisor', () => { + it("uses custom divisor", () => { expect(ProgressCalculator.calculateConservativeProgress(100, 10)).toBe(10); }); - it('caps at 95', () => { + it("caps at 95", () => { expect(ProgressCalculator.calculateConservativeProgress(10000)).toBe(95); }); }); // ─── instance: calculateProgress and getCurrentRate ─────────────────────────── -describe('ProgressCalculator instance', () => { - it('calculateProgress returns correct processed/total/percentage', () => { +describe("ProgressCalculator instance", () => { + it("calculateProgress returns correct processed/total/percentage", () => { const calc = new ProgressCalculator(); const stats = calc.calculateProgress(25, 100); expect(stats.processed).toBe(25); @@ -198,20 +198,20 @@ describe('ProgressCalculator instance', () => { expect(stats.percentage).toBe(25); }); - it('calculateProgress returns elapsedTime >= 0', () => { + it("calculateProgress returns elapsedTime >= 0", () => { const calc = new ProgressCalculator(); const stats = calc.calculateProgress(10, 100); expect(stats.elapsedTime).toBeGreaterThanOrEqual(0); }); - it('getCurrentRate returns 0 with only one measurement', () => { + it("getCurrentRate returns 0 with only one measurement", () => { const calc = new ProgressCalculator(); calc.calculateProgress(10, 100); // One measurement → history length < 2 → rate 0 expect(calc.getCurrentRate()).toBe(0); }); - it('reset clears history and resets start time', () => { + it("reset clears history and resets start time", () => { const calc = new ProgressCalculator(); calc.calculateProgress(50, 100); const before = calc.getStats().historySize; @@ -220,17 +220,17 @@ describe('ProgressCalculator instance', () => { expect(before).toBeGreaterThan(0); }); - it('getStats returns historySize, currentRate, and elapsedTime', () => { + it("getStats returns historySize, currentRate, and elapsedTime", () => { const calc = new ProgressCalculator(); calc.calculateProgress(10, 100); const stats = calc.getStats(); - expect(stats).toHaveProperty('historySize'); - expect(stats).toHaveProperty('currentRate'); - expect(stats).toHaveProperty('elapsedTime'); + expect(stats).toHaveProperty("historySize"); + expect(stats).toHaveProperty("currentRate"); + expect(stats).toHaveProperty("elapsedTime"); expect(stats.historySize).toBe(1); }); - it('respects windowSize constructor argument', () => { + it("respects windowSize constructor argument", () => { const calc = new ProgressCalculator(3); for (let i = 0; i <= 5; i++) { calc.calculateProgress(i * 10, 100); @@ -238,7 +238,7 @@ describe('ProgressCalculator instance', () => { expect(calc.getStats().historySize).toBeLessThanOrEqual(3); }); - it('getEstimatedTimeRemaining returns null when rate is zero', () => { + it("getEstimatedTimeRemaining returns null when rate is zero", () => { const calc = new ProgressCalculator(); // With one measurement there is no rate, so remaining should be null const result = calc.getEstimatedTimeRemaining(10, 100); diff --git a/src/lib/ui/progress/tests/progress-tracker.test.ts b/src/lib/ui/progress/tests/progress-tracker.test.ts index 391c720..aac3477 100644 --- a/src/lib/ui/progress/tests/progress-tracker.test.ts +++ b/src/lib/ui/progress/tests/progress-tracker.test.ts @@ -1,11 +1,11 @@ -import { resetState } from 'core/state'; -import { ProgressTracker } from 'lib/ui/progress/progress-tracker'; +import { resetState } from "core/state"; +import { ProgressTracker } from "lib/ui/progress/progress-tracker"; beforeEach(() => { resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { @@ -14,48 +14,48 @@ afterEach(() => { // ─── initializeSteps ────────────────────────────────────────────────────────── -describe('ProgressTracker.initializeSteps', () => { - it('creates steps with pending status and 0 percentage', () => { +describe("ProgressTracker.initializeSteps", () => { + it("creates steps with pending status and 0 percentage", () => { const tracker = new ProgressTracker(); - tracker.initializeSteps(['Step A', 'Step B']); + tracker.initializeSteps(["Step A", "Step B"]); const all = tracker.getAllSteps(); expect(all).toHaveLength(2); - all.forEach(s => { - expect(s.status).toBe('pending'); + all.forEach((s) => { + expect(s.status).toBe("pending"); expect(s.percentage).toBe(0); }); }); - it('sets step names correctly', () => { + it("sets step names correctly", () => { const tracker = new ProgressTracker(); - tracker.initializeSteps(['Alpha', 'Beta', 'Gamma']); - expect(tracker.getStepByName('Beta')).not.toBeNull(); + tracker.initializeSteps(["Alpha", "Beta", "Gamma"]); + expect(tracker.getStepByName("Beta")).not.toBeNull(); }); }); // ─── startStep / updateStepProgress ─────────────────────────────────────────── -describe('ProgressTracker.startStep', () => { +describe("ProgressTracker.startStep", () => { it('sets step status to "progress"', () => { const tracker = new ProgressTracker(); - tracker.initializeSteps(['Step 1']); + tracker.initializeSteps(["Step 1"]); tracker.startStep(0); - expect(tracker.getStep(0)?.status).toBe('progress'); + expect(tracker.getStep(0)?.status).toBe("progress"); }); - it('is a no-op for out-of-bounds index', () => { + it("is a no-op for out-of-bounds index", () => { const tracker = new ProgressTracker(); - tracker.initializeSteps(['Step 1']); + tracker.initializeSteps(["Step 1"]); expect(() => tracker.startStep(99)).not.toThrow(); }); }); -describe('ProgressTracker.updateStepProgress', () => { - it('clamps percentage to 0–100', () => { +describe("ProgressTracker.updateStepProgress", () => { + it("clamps percentage to 0–100", () => { const tracker = new ProgressTracker(); - tracker.initializeSteps(['S1']); + tracker.initializeSteps(["S1"]); tracker.updateStepProgress(0, 150); expect(tracker.getStep(0)?.percentage).toBe(100); @@ -66,19 +66,19 @@ describe('ProgressTracker.updateStepProgress', () => { it('sets endTime and percentage=100 when status is "success"', () => { const tracker = new ProgressTracker(); - tracker.initializeSteps(['S1']); - tracker.updateStepProgress(0, 50, 'success'); + tracker.initializeSteps(["S1"]); + tracker.updateStepProgress(0, 50, "success"); const step = tracker.getStep(0)!; - expect(step.status).toBe('success'); + expect(step.status).toBe("success"); expect(step.percentage).toBe(100); expect(step.endTime).toBeDefined(); }); it('sets endTime when status is "error"', () => { const tracker = new ProgressTracker(); - tracker.initializeSteps(['S1']); - tracker.updateStepProgress(0, 30, 'error'); + tracker.initializeSteps(["S1"]); + tracker.updateStepProgress(0, 30, "error"); expect(tracker.getStep(0)?.endTime).toBeDefined(); }); @@ -86,63 +86,63 @@ describe('ProgressTracker.updateStepProgress', () => { // ─── completeStep / failStep ─────────────────────────────────────────────────── -describe('ProgressTracker.completeStep', () => { - it('marks step as success with 100%', () => { +describe("ProgressTracker.completeStep", () => { + it("marks step as success with 100%", () => { const tracker = new ProgressTracker(); - tracker.initializeSteps(['S1']); + tracker.initializeSteps(["S1"]); tracker.completeStep(0); const step = tracker.getStep(0)!; - expect(step.status).toBe('success'); + expect(step.status).toBe("success"); expect(step.percentage).toBe(100); }); }); -describe('ProgressTracker.failStep', () => { - it('marks step as error and stores error message', () => { +describe("ProgressTracker.failStep", () => { + it("marks step as error and stores error message", () => { const tracker = new ProgressTracker(); - tracker.initializeSteps(['S1']); - tracker.failStep(0, 'something went wrong'); + tracker.initializeSteps(["S1"]); + tracker.failStep(0, "something went wrong"); const step = tracker.getStep(0)!; - expect(step.status).toBe('error'); - expect(step.error).toBe('something went wrong'); + expect(step.status).toBe("error"); + expect(step.error).toBe("something went wrong"); }); - it('is a no-op for out-of-bounds index', () => { + it("is a no-op for out-of-bounds index", () => { const tracker = new ProgressTracker(); - tracker.initializeSteps(['S1']); + tracker.initializeSteps(["S1"]); expect(() => tracker.failStep(99)).not.toThrow(); }); }); // ─── getOverallProgress ──────────────────────────────────────────────────────── -describe('ProgressTracker.getOverallProgress', () => { - it('returns 0 when no steps are initialised', () => { +describe("ProgressTracker.getOverallProgress", () => { + it("returns 0 when no steps are initialised", () => { const tracker = new ProgressTracker(); expect(tracker.getOverallProgress()).toBe(0); }); - it('returns 0 when all steps are at 0%', () => { + it("returns 0 when all steps are at 0%", () => { const tracker = new ProgressTracker(); - tracker.initializeSteps(['A', 'B']); + tracker.initializeSteps(["A", "B"]); expect(tracker.getOverallProgress()).toBe(0); }); - it('returns 100 when all steps complete', () => { + it("returns 100 when all steps complete", () => { const tracker = new ProgressTracker(); - tracker.initializeSteps(['A', 'B']); + tracker.initializeSteps(["A", "B"]); tracker.completeStep(0); tracker.completeStep(1); expect(tracker.getOverallProgress()).toBe(100); }); - it('returns floor of average across steps', () => { + it("returns floor of average across steps", () => { const tracker = new ProgressTracker(); - tracker.initializeSteps(['A', 'B']); + tracker.initializeSteps(["A", "B"]); tracker.updateStepProgress(0, 50); - tracker.updateStepProgress(1, 100, 'success'); + tracker.updateStepProgress(1, 100, "success"); // average of 50 and 100 = 75 expect(tracker.getOverallProgress()).toBe(75); }); @@ -150,10 +150,10 @@ describe('ProgressTracker.getOverallProgress', () => { // ─── getSummary ──────────────────────────────────────────────────────────────── -describe('ProgressTracker.getSummary', () => { - it('returns correct counts for mixed states', () => { +describe("ProgressTracker.getSummary", () => { + it("returns correct counts for mixed states", () => { const tracker = new ProgressTracker(); - tracker.initializeSteps(['A', 'B', 'C', 'D']); + tracker.initializeSteps(["A", "B", "C", "D"]); tracker.completeStep(0); tracker.completeStep(1); tracker.failStep(2); @@ -166,128 +166,128 @@ describe('ProgressTracker.getSummary', () => { expect(summary.pendingSteps).toBe(1); }); - it('overallSuccess is true only when all steps succeed and none pending/error', () => { + it("overallSuccess is true only when all steps succeed and none pending/error", () => { const tracker = new ProgressTracker(); - tracker.initializeSteps(['A', 'B']); + tracker.initializeSteps(["A", "B"]); tracker.completeStep(0); tracker.completeStep(1); expect(tracker.getSummary().overallSuccess).toBe(true); }); - it('overallSuccess is false when any step fails', () => { + it("overallSuccess is false when any step fails", () => { const tracker = new ProgressTracker(); - tracker.initializeSteps(['A', 'B']); + tracker.initializeSteps(["A", "B"]); tracker.completeStep(0); tracker.failStep(1); expect(tracker.getSummary().overallSuccess).toBe(false); }); - it('overallSuccess is false when some steps are still pending', () => { + it("overallSuccess is false when some steps are still pending", () => { const tracker = new ProgressTracker(); - tracker.initializeSteps(['A', 'B']); + tracker.initializeSteps(["A", "B"]); tracker.completeStep(0); expect(tracker.getSummary().overallSuccess).toBe(false); }); - it('includes totalDuration and durationFormatted', () => { + it("includes totalDuration and durationFormatted", () => { const tracker = new ProgressTracker(); - tracker.initializeSteps(['A']); + tracker.initializeSteps(["A"]); const summary = tracker.getSummary(); expect(summary.totalDuration).toBeGreaterThanOrEqual(0); - expect(typeof summary.durationFormatted).toBe('string'); + expect(typeof summary.durationFormatted).toBe("string"); expect(summary.durationFormatted.length).toBeGreaterThan(0); }); }); // ─── getStep / getStepByName / getStepIndex ─────────────────────────────────── -describe('ProgressTracker step accessors', () => { - it('getStep returns null for out-of-bounds index', () => { +describe("ProgressTracker step accessors", () => { + it("getStep returns null for out-of-bounds index", () => { const tracker = new ProgressTracker(); - tracker.initializeSteps(['A']); + tracker.initializeSteps(["A"]); expect(tracker.getStep(-1)).toBeNull(); expect(tracker.getStep(99)).toBeNull(); }); - it('getStepByName returns null when name not found', () => { + it("getStepByName returns null when name not found", () => { const tracker = new ProgressTracker(); - tracker.initializeSteps(['A']); - expect(tracker.getStepByName('NonExistent')).toBeNull(); + tracker.initializeSteps(["A"]); + expect(tracker.getStepByName("NonExistent")).toBeNull(); }); - it('getStepIndex returns -1 for unknown name', () => { + it("getStepIndex returns -1 for unknown name", () => { const tracker = new ProgressTracker(); - tracker.initializeSteps(['A']); - expect(tracker.getStepIndex('Unknown')).toBe(-1); + tracker.initializeSteps(["A"]); + expect(tracker.getStepIndex("Unknown")).toBe(-1); }); - it('getStepIndex returns correct index', () => { + it("getStepIndex returns correct index", () => { const tracker = new ProgressTracker(); - tracker.initializeSteps(['First', 'Second', 'Third']); - expect(tracker.getStepIndex('Second')).toBe(1); + tracker.initializeSteps(["First", "Second", "Third"]); + expect(tracker.getStepIndex("Second")).toBe(1); }); }); // ─── isComplete / hasErrors / getFailedSteps / getCompletedSteps ────────────── -describe('ProgressTracker state queries', () => { - it('isComplete returns false while some steps are pending', () => { +describe("ProgressTracker state queries", () => { + it("isComplete returns false while some steps are pending", () => { const tracker = new ProgressTracker(); - tracker.initializeSteps(['A', 'B']); + tracker.initializeSteps(["A", "B"]); tracker.completeStep(0); expect(tracker.isComplete()).toBe(false); }); - it('isComplete returns true when all steps are success or error', () => { + it("isComplete returns true when all steps are success or error", () => { const tracker = new ProgressTracker(); - tracker.initializeSteps(['A', 'B']); + tracker.initializeSteps(["A", "B"]); tracker.completeStep(0); tracker.failStep(1); expect(tracker.isComplete()).toBe(true); }); - it('hasErrors returns false when no errors', () => { + it("hasErrors returns false when no errors", () => { const tracker = new ProgressTracker(); - tracker.initializeSteps(['A']); + tracker.initializeSteps(["A"]); tracker.completeStep(0); expect(tracker.hasErrors()).toBe(false); }); - it('hasErrors returns true when a step failed', () => { + it("hasErrors returns true when a step failed", () => { const tracker = new ProgressTracker(); - tracker.initializeSteps(['A', 'B']); + tracker.initializeSteps(["A", "B"]); tracker.completeStep(0); tracker.failStep(1); expect(tracker.hasErrors()).toBe(true); }); - it('getFailedSteps returns only errored steps', () => { + it("getFailedSteps returns only errored steps", () => { const tracker = new ProgressTracker(); - tracker.initializeSteps(['A', 'B', 'C']); + tracker.initializeSteps(["A", "B", "C"]); tracker.completeStep(0); tracker.failStep(1); const failed = tracker.getFailedSteps(); expect(failed).toHaveLength(1); - expect(failed[0].name).toBe('B'); + expect(failed[0].name).toBe("B"); }); - it('getCompletedSteps returns only successful steps', () => { + it("getCompletedSteps returns only successful steps", () => { const tracker = new ProgressTracker(); - tracker.initializeSteps(['A', 'B']); + tracker.initializeSteps(["A", "B"]); tracker.completeStep(0); tracker.failStep(1); const completed = tracker.getCompletedSteps(); expect(completed).toHaveLength(1); - expect(completed[0].name).toBe('A'); + expect(completed[0].name).toBe("A"); }); - it('getPendingSteps returns only pending steps', () => { + it("getPendingSteps returns only pending steps", () => { const tracker = new ProgressTracker(); - tracker.initializeSteps(['A', 'B', 'C']); + tracker.initializeSteps(["A", "B", "C"]); tracker.completeStep(0); const pending = tracker.getPendingSteps(); expect(pending).toHaveLength(2); @@ -296,17 +296,17 @@ describe('ProgressTracker state queries', () => { // ─── reset ──────────────────────────────────────────────────────────────────── -describe('ProgressTracker.reset', () => { - it('resets all steps to pending with 0 percentage', () => { +describe("ProgressTracker.reset", () => { + it("resets all steps to pending with 0 percentage", () => { const tracker = new ProgressTracker(); - tracker.initializeSteps(['A', 'B']); + tracker.initializeSteps(["A", "B"]); tracker.completeStep(0); - tracker.failStep(1, 'oops'); + tracker.failStep(1, "oops"); tracker.reset(); - tracker.getAllSteps().forEach(s => { - expect(s.status).toBe('pending'); + tracker.getAllSteps().forEach((s) => { + expect(s.status).toBe("pending"); expect(s.percentage).toBe(0); expect(s.error).toBeUndefined(); }); @@ -315,45 +315,45 @@ describe('ProgressTracker.reset', () => { // ─── operationName ──────────────────────────────────────────────────────────── -describe('ProgressTracker operation name', () => { - it('uses provided operation name', () => { - const tracker = new ProgressTracker('MyOp'); - expect(tracker.getOperationName()).toBe('MyOp'); +describe("ProgressTracker operation name", () => { + it("uses provided operation name", () => { + const tracker = new ProgressTracker("MyOp"); + expect(tracker.getOperationName()).toBe("MyOp"); }); it('defaults to "Operation"', () => { const tracker = new ProgressTracker(); - expect(tracker.getOperationName()).toBe('Operation'); + expect(tracker.getOperationName()).toBe("Operation"); }); - it('setOperationName updates the name', () => { + it("setOperationName updates the name", () => { const tracker = new ProgressTracker(); - tracker.setOperationName('NewName'); - expect(tracker.getOperationName()).toBe('NewName'); + tracker.setOperationName("NewName"); + expect(tracker.getOperationName()).toBe("NewName"); }); }); // ─── formatSummary ──────────────────────────────────────────────────────────── -describe('ProgressTracker.formatSummary', () => { - it('returns at least one line', () => { - const tracker = new ProgressTracker('Push'); - tracker.initializeSteps(['A']); +describe("ProgressTracker.formatSummary", () => { + it("returns at least one line", () => { + const tracker = new ProgressTracker("Push"); + tracker.initializeSteps(["A"]); tracker.completeStep(0); const lines = tracker.formatSummary(); expect(lines.length).toBeGreaterThan(0); - expect(lines[0]).toContain('Push'); + expect(lines[0]).toContain("Push"); }); - it('includeDetails adds failed/successful sections', () => { - const tracker = new ProgressTracker('Sync'); - tracker.initializeSteps(['A', 'B']); + it("includeDetails adds failed/successful sections", () => { + const tracker = new ProgressTracker("Sync"); + tracker.initializeSteps(["A", "B"]); tracker.completeStep(0); - tracker.failStep(1, 'boom'); + tracker.failStep(1, "boom"); const lines = tracker.formatSummary(true); - const text = lines.join('\n'); - expect(text).toContain('Failed'); - expect(text).toContain('Successful'); + const text = lines.join("\n"); + expect(text).toContain("Failed"); + expect(text).toContain("Successful"); }); }); diff --git a/src/lib/workflows/index.ts b/src/lib/workflows/index.ts index 676ef01..d49ab31 100644 --- a/src/lib/workflows/index.ts +++ b/src/lib/workflows/index.ts @@ -1,24 +1,24 @@ /** * Workflows Module - * + * * Central exports for all workflow-related functionality. */ // Core workflow operation class -export { WorkflowOperation, WorkflowOperationResult } from './workflow-operation'; +export { WorkflowOperation, WorkflowOperationResult } from "./workflow-operation"; // Workflow orchestrator -export { workflowOrchestrator } from './workflow-orchestrator'; +export { workflowOrchestrator } from "./workflow-orchestrator"; // Batch processing -export { processBatches, type BatchProcessingResult } from './process-batches'; +export { processBatches, type BatchProcessingResult } from "./process-batches"; // Workflow options parsing -export { parseWorkflowOptions, parseOperationType } from './workflow-options'; +export { parseWorkflowOptions, parseOperationType } from "./workflow-options"; // Workflow helpers (operation names, verbs, icons) -export { getOperationName, getOperationVerb, getOperationIcon } from './workflow-helpers'; +export { getOperationName, getOperationVerb, getOperationIcon } from "./workflow-helpers"; // Mapping utilities -export { listMappings } from './list-mappings'; -export { refreshAndUpdateMappings } from './refresh-mappings'; +export { listMappings } from "./list-mappings"; +export { refreshAndUpdateMappings } from "./refresh-mappings"; diff --git a/src/lib/workflows/list-mappings.ts b/src/lib/workflows/list-mappings.ts index d701d94..c87033c 100644 --- a/src/lib/workflows/list-mappings.ts +++ b/src/lib/workflows/list-mappings.ts @@ -1,38 +1,40 @@ /** * List Mappings - * + * * Display available mapping pairs for workflow operations. */ -import ansiColors from 'ansi-colors'; -import { listAvailableMappingPairs, getMappingSummary } from '../mappers/mapping-reader'; +import ansiColors from "ansi-colors"; +import { listAvailableMappingPairs, getMappingSummary } from "../mappers/mapping-reader"; /** * List available mapping pairs for workflow operations */ export function listMappings(): void { - console.log(ansiColors.cyan('\n' + '═'.repeat(50))); - console.log(ansiColors.cyan('📋 AVAILABLE MAPPINGS')); - console.log(ansiColors.cyan('═'.repeat(50))); + console.log(ansiColors.cyan("\n" + "═".repeat(50))); + console.log(ansiColors.cyan("📋 AVAILABLE MAPPINGS")); + console.log(ansiColors.cyan("═".repeat(50))); - const pairs = listAvailableMappingPairs(); + const pairs = listAvailableMappingPairs(); - if (pairs.length === 0) { - console.log(ansiColors.yellow('\nNo mappings found.')); - console.log(ansiColors.gray('Run a sync operation first to create mappings.')); - return; - } + if (pairs.length === 0) { + console.log(ansiColors.yellow("\nNo mappings found.")); + console.log(ansiColors.gray("Run a sync operation first to create mappings.")); + return; + } - for (const pair of pairs) { - const summary = getMappingSummary(pair.sourceGuid, pair.targetGuid, pair.locales); - - console.log(ansiColors.white(`\n${pair.sourceGuid} → ${pair.targetGuid}`)); - console.log(ansiColors.gray(`Locales: ${pair.locales.join(', ')}`)); - console.log(ansiColors.gray(`Content items: ${summary.totalContent}`)); - console.log(ansiColors.gray(`Pages: ${summary.totalPages}`)); - } + for (const pair of pairs) { + const summary = getMappingSummary(pair.sourceGuid, pair.targetGuid, pair.locales); - console.log(ansiColors.cyan('\n' + '─'.repeat(50))); - console.log(ansiColors.gray('To run a workflow operation:')); - console.log(ansiColors.white(' node dist/index.js workflows --sourceGuid --targetGuid --type publish')); + console.log(ansiColors.white(`\n${pair.sourceGuid} → ${pair.targetGuid}`)); + console.log(ansiColors.gray(`Locales: ${pair.locales.join(", ")}`)); + console.log(ansiColors.gray(`Content items: ${summary.totalContent}`)); + console.log(ansiColors.gray(`Pages: ${summary.totalPages}`)); + } + + console.log(ansiColors.cyan("\n" + "─".repeat(50))); + console.log(ansiColors.gray("To run a workflow operation:")); + console.log( + ansiColors.white(" node dist/index.js workflows --sourceGuid --targetGuid --type publish") + ); } diff --git a/src/lib/workflows/process-batches.ts b/src/lib/workflows/process-batches.ts index c8f7033..e28c900 100644 --- a/src/lib/workflows/process-batches.ts +++ b/src/lib/workflows/process-batches.ts @@ -1,101 +1,99 @@ /** * Process Batches - * + * * Processes items in batches with progress reporting and error handling. */ -import ansiColors from 'ansi-colors'; -import { batchWorkflow, createBatches, type BatchItemType } from '../../core/batch-workflows'; -import { getOperationName, getOperationVerb } from './workflow-helpers'; -import { WorkflowOperationType } from '../../types'; -import { state, fileOperations } from '../../core'; -import { getLogger } from '../../core/state'; -import { getContentItemsFromFileSystem } from '../getters/filesystem/get-content-items'; -import { getPagesFromFileSystem } from '../getters/filesystem/get-pages'; +import ansiColors from "ansi-colors"; +import { batchWorkflow, createBatches, type BatchItemType } from "../../core/batch-workflows"; +import { getOperationName, getOperationVerb } from "./workflow-helpers"; +import { WorkflowOperationType } from "../../types"; +import { state, fileOperations } from "../../core"; +import { getLogger } from "../../core/state"; +import { getContentItemsFromFileSystem } from "../getters/filesystem/get-content-items"; +import { getPagesFromFileSystem } from "../getters/filesystem/get-pages"; /** * Item info for display purposes */ interface ItemDisplayInfo { - id: number; - name: string; - type?: string; + id: number; + name: string; + type?: string; } /** * Get content item display info from filesystem */ function getContentDisplayInfo(ids: number[], targetGuid: string, locale: string): Map { - const displayMap = new Map(); - - try { - const fileOps = new fileOperations(targetGuid, locale); - const contentItems = getContentItemsFromFileSystem(fileOps); - - for (const item of contentItems) { - if (ids.includes(item.contentID)) { - // Try to get a display name from fields.title, properties.referenceName, or definitionName - const displayName = item.fields?.title - || item.fields?.name - || item.properties?.referenceName - || `Item ${item.contentID}`; - const modelName = item.properties?.definitionName || ''; - - displayMap.set(item.contentID, { - id: item.contentID, - name: displayName, - type: modelName - }); - } - } - } catch (error) { - // Silently fail - we'll just show IDs without names + const displayMap = new Map(); + + try { + const fileOps = new fileOperations(targetGuid, locale); + const contentItems = getContentItemsFromFileSystem(fileOps); + + for (const item of contentItems) { + if (ids.includes(item.contentID)) { + // Try to get a display name from fields.title, properties.referenceName, or definitionName + const displayName = + item.fields?.title || item.fields?.name || item.properties?.referenceName || `Item ${item.contentID}`; + const modelName = item.properties?.definitionName || ""; + + displayMap.set(item.contentID, { + id: item.contentID, + name: displayName, + type: modelName, + }); + } } - - return displayMap; + } catch (error) { + // Silently fail - we'll just show IDs without names + } + + return displayMap; } /** * Get page display info from filesystem */ function getPageDisplayInfo(ids: number[], targetGuid: string, locale: string): Map { - const displayMap = new Map(); - - try { - const fileOps = new fileOperations(targetGuid, locale); - const pages = getPagesFromFileSystem(fileOps); - - for (const page of pages) { - if (ids.includes(page.pageID)) { - // Use title, name, or pageID as display - const displayName = page.title || page.name || `Page ${page.pageID}`; - const pagePath = page.name ? `/${page.name}` : ''; - - displayMap.set(page.pageID, { - id: page.pageID, - name: displayName, - type: pagePath - }); - } - } - } catch (error) { - // Silently fail - we'll just show IDs without names + const displayMap = new Map(); + + try { + const fileOps = new fileOperations(targetGuid, locale); + const pages = getPagesFromFileSystem(fileOps); + + for (const page of pages) { + if (ids.includes(page.pageID)) { + // Use title, name, or pageID as display + const displayName = page.title || page.name || `Page ${page.pageID}`; + const pagePath = page.name ? `/${page.name}` : ""; + + displayMap.set(page.pageID, { + id: page.pageID, + name: displayName, + type: pagePath, + }); + } } - - return displayMap; + } catch (error) { + // Silently fail - we'll just show IDs without names + } + + return displayMap; } /** * Helper to log to both console (via logger) and capture lines */ function logLine(line: string, logLines: string[]): void { - const logger = getLogger(); - if (logger) { - logger.info(line); - } else { - console.log(line); - } - logLines.push(line); + const logger = getLogger(); + if (logger) { + logger.info(line); + } else { + console.log(line); + } + logLines.push(line); } /** @@ -103,157 +101,172 @@ function logLine(line: string, logLines: string[]): void { * Format: ● [guid][locale] content ID: {id} - Name (Type) - publishing */ function displayItemBreakdown( - ids: number[], - type: BatchItemType, - targetGuid: string, - locale: string, - operationName: string, - displayMap: Map, - logLines: string[] + ids: number[], + type: BatchItemType, + targetGuid: string, + locale: string, + operationName: string, + displayMap: Map, + logLines: string[] ): void { - const entityType = type === 'content' ? 'content' : 'page'; - - // Show ALL items - no truncation - for (const id of ids) { - const info = displayMap.get(id); - const guidDisplay = ansiColors.green(`[${targetGuid}]`); - const localeDisplay = ansiColors.gray(`[${locale}]`); - const symbol = ansiColors.green('●'); - - let line: string; - if (info) { - const typeDisplay = info.type ? ansiColors.gray(` (${info.type})`) : ''; - // Format: ● [guid][locale] content ID: {id} - Name (Type) - publishing - line = `${symbol} ${guidDisplay}${localeDisplay} ${ansiColors.white(entityType)} ID: ${ansiColors.cyan.underline(String(id))} - ${ansiColors.white(info.name)}${typeDisplay} - ${ansiColors.gray(operationName.toLowerCase())}`; - } else { - line = `${symbol} ${guidDisplay}${localeDisplay} ${ansiColors.white(entityType)} ID: ${ansiColors.cyan.underline(String(id))} - ${ansiColors.gray(operationName.toLowerCase())}`; - } - logLine(line, logLines); + const entityType = type === "content" ? "content" : "page"; + + // Show ALL items - no truncation + for (const id of ids) { + const info = displayMap.get(id); + const guidDisplay = ansiColors.green(`[${targetGuid}]`); + const localeDisplay = ansiColors.gray(`[${locale}]`); + const symbol = ansiColors.green("●"); + + let line: string; + if (info) { + const typeDisplay = info.type ? ansiColors.gray(` (${info.type})`) : ""; + // Format: ● [guid][locale] content ID: {id} - Name (Type) - publishing + line = `${symbol} ${guidDisplay}${localeDisplay} ${ansiColors.white(entityType)} ID: ${ansiColors.cyan.underline(String(id))} - ${ansiColors.white(info.name)}${typeDisplay} - ${ansiColors.gray(operationName.toLowerCase())}`; + } else { + line = `${symbol} ${guidDisplay}${localeDisplay} ${ansiColors.white(entityType)} ID: ${ansiColors.cyan.underline(String(id))} - ${ansiColors.gray(operationName.toLowerCase())}`; } + logLine(line, logLines); + } } /** * Batch processing result */ export interface BatchProcessingResult { - total: number; - processed: number; - failed: number; - batches: number; - processedIds: number[]; - logLines: string[]; + total: number; + processed: number; + failed: number; + batches: number; + processedIds: number[]; + logLines: string[]; } /** * Process batches for a specific item type (content or pages) */ export async function processBatches( - ids: number[], - type: BatchItemType, - locale: string, - operation: WorkflowOperationType, - errors: string[] + ids: number[], + type: BatchItemType, + locale: string, + operation: WorkflowOperationType, + errors: string[] ): Promise { - const logLines: string[] = []; - - // CRITICAL: Deduplicate IDs to prevent "item already in batch" API errors - // This is a defensive measure in case upstream code doesn't dedupe properly - const uniqueIds = Array.from(new Set(ids)); - const duplicatesRemoved = ids.length - uniqueIds.length; - - const results: BatchProcessingResult = { - total: uniqueIds.length, - processed: 0, - failed: 0, - batches: 0, - processedIds: [], - logLines: [] - }; - - if (uniqueIds.length === 0) return results; - - const label = type === 'content' ? 'Content' : 'Page'; - const operationName = getOperationName(operation); - const operationVerb = getOperationVerb(operation); - - // Log deduplication if any duplicates were removed - if (duplicatesRemoved > 0) { - logLine(ansiColors.gray(` 📋 Deduplicated ${label.toLowerCase()} IDs: ${ids.length} → ${uniqueIds.length} (${duplicatesRemoved} duplicates removed)`), logLines); - } - - logLine(ansiColors.cyan(`\n${operationName}ing ${uniqueIds.length} ${label.toLowerCase()} items...`), logLines); - - // Get item display info and show breakdown (ALL items, no truncation) - const targetGuid = state.targetGuid?.[0]; - if (targetGuid) { - const displayMap = type === 'content' - ? getContentDisplayInfo(uniqueIds, targetGuid, locale) - : getPageDisplayInfo(uniqueIds, targetGuid, locale); - - if (displayMap.size > 0) { - displayItemBreakdown(uniqueIds, type, targetGuid, locale, operationName, displayMap, logLines); - } + const logLines: string[] = []; + + // CRITICAL: Deduplicate IDs to prevent "item already in batch" API errors + // This is a defensive measure in case upstream code doesn't dedupe properly + const uniqueIds = Array.from(new Set(ids)); + const duplicatesRemoved = ids.length - uniqueIds.length; + + const results: BatchProcessingResult = { + total: uniqueIds.length, + processed: 0, + failed: 0, + batches: 0, + processedIds: [], + logLines: [], + }; + + if (uniqueIds.length === 0) return results; + + const label = type === "content" ? "Content" : "Page"; + const operationName = getOperationName(operation); + const operationVerb = getOperationVerb(operation); + + // Log deduplication if any duplicates were removed + if (duplicatesRemoved > 0) { + logLine( + ansiColors.gray( + ` 📋 Deduplicated ${label.toLowerCase()} IDs: ${ids.length} → ${uniqueIds.length} (${duplicatesRemoved} duplicates removed)` + ), + logLines + ); + } + + logLine(ansiColors.cyan(`\n${operationName}ing ${uniqueIds.length} ${label.toLowerCase()} items...`), logLines); + + // Get item display info and show breakdown (ALL items, no truncation) + const targetGuid = state.targetGuid?.[0]; + if (targetGuid) { + const displayMap = + type === "content" + ? getContentDisplayInfo(uniqueIds, targetGuid, locale) + : getPageDisplayInfo(uniqueIds, targetGuid, locale); + + if (displayMap.size > 0) { + displayItemBreakdown(uniqueIds, type, targetGuid, locale, operationName, displayMap, logLines); } - - const batches = createBatches(uniqueIds); - results.batches = batches.length; - - for (let i = 0; i < batches.length; i++) { - const batch = batches[i]; - const batchNum = i + 1; - const progress = Math.round((batchNum / batches.length) * 100); - - // Initial log - batch ID will be shown in the progress display from batchWorkflow - logLine(ansiColors.gray(`[${progress}%] ${label} batch ${batchNum}/${batches.length}: ${operationName}ing ${batch.length} items...`), logLines); - - try { - const batchResult = await batchWorkflow(batch, locale, operation, type); - const batchIdStr = batchResult.batchId ? ` (batch ID: ${batchResult.batchId})` : ''; - - if (batchResult.success) { - results.processed += batchResult.processedIds.length; - results.processedIds.push(...batchResult.processedIds); - - // Handle partial success - some items succeeded, some failed - if (batchResult.partialSuccess) { - const { successCount, failureCount, batchId } = batchResult.partialSuccess; - results.failed += failureCount; - - // Add a "completed with errors" message instead of failure message - errors.push(`${label} batch ${batchNum} (ID: ${batchId}): Completed with errors - ${successCount} succeeded, ${failureCount} failed`); - } - } else { - results.failed += batch.length; - errors.push(`${label} batch ${batchNum}${batchIdStr}: ${batchResult.error}`); - } - } catch (error: any) { - results.failed += batch.length; - errors.push(`${label} batch ${batchNum}: ${error.message}`); - } + } + + const batches = createBatches(uniqueIds); + results.batches = batches.length; - // Small delay between batches to prevent throttling - if (i < batches.length - 1) { - await new Promise(resolve => setTimeout(resolve, 100)); + for (let i = 0; i < batches.length; i++) { + const batch = batches[i]; + const batchNum = i + 1; + const progress = Math.round((batchNum / batches.length) * 100); + + // Initial log - batch ID will be shown in the progress display from batchWorkflow + logLine( + ansiColors.gray( + `[${progress}%] ${label} batch ${batchNum}/${batches.length}: ${operationName}ing ${batch.length} items...` + ), + logLines + ); + + try { + const batchResult = await batchWorkflow(batch, locale, operation, type); + const batchIdStr = batchResult.batchId ? ` (batch ID: ${batchResult.batchId})` : ""; + + if (batchResult.success) { + results.processed += batchResult.processedIds.length; + results.processedIds.push(...batchResult.processedIds); + + // Handle partial success - some items succeeded, some failed + if (batchResult.partialSuccess) { + const { successCount, failureCount, batchId } = batchResult.partialSuccess; + results.failed += failureCount; + + // Add a "completed with errors" message instead of failure message + errors.push( + `${label} batch ${batchNum} (ID: ${batchId}): Completed with errors - ${successCount} succeeded, ${failureCount} failed` + ); } + } else { + results.failed += batch.length; + errors.push(`${label} batch ${batchNum}${batchIdStr}: ${batchResult.error}`); + } + } catch (error: any) { + results.failed += batch.length; + errors.push(`${label} batch ${batchNum}: ${error.message}`); } - // Display count - clarify when API processes more items than requested (nested content) - let summaryLine: string; - if (results.processed > results.total) { - // API processed additional nested items - summaryLine = ansiColors.green(`✓ ${label} ${operationVerb}: ${results.processed} items (${results.total} requested + ${results.processed - results.total} nested)`); - } else if (results.processed === results.total) { - summaryLine = ansiColors.green(`✓ ${label} ${operationVerb}: ${results.processed} items`); - } else { - // Some items failed or were skipped - summaryLine = ansiColors.green(`✓ ${label} ${operationVerb}: ${results.processed}/${results.total} items`); - } - logLine(summaryLine, logLines); - - if (results.failed > 0) { - logLine(ansiColors.red(`✗ ${results.failed} ${label.toLowerCase()} items failed`), logLines); + // Small delay between batches to prevent throttling + if (i < batches.length - 1) { + await new Promise((resolve) => setTimeout(resolve, 100)); } + } + + // Display count - clarify when API processes more items than requested (nested content) + let summaryLine: string; + if (results.processed > results.total) { + // API processed additional nested items + summaryLine = ansiColors.green( + `✓ ${label} ${operationVerb}: ${results.processed} items (${results.total} requested + ${results.processed - results.total} nested)` + ); + } else if (results.processed === results.total) { + summaryLine = ansiColors.green(`✓ ${label} ${operationVerb}: ${results.processed} items`); + } else { + // Some items failed or were skipped + summaryLine = ansiColors.green(`✓ ${label} ${operationVerb}: ${results.processed}/${results.total} items`); + } + logLine(summaryLine, logLines); + + if (results.failed > 0) { + logLine(ansiColors.red(`✗ ${results.failed} ${label.toLowerCase()} items failed`), logLines); + } - results.logLines = logLines; - return results; + results.logLines = logLines; + return results; } diff --git a/src/lib/workflows/refresh-mappings.ts b/src/lib/workflows/refresh-mappings.ts index a80779d..68acaea 100644 --- a/src/lib/workflows/refresh-mappings.ts +++ b/src/lib/workflows/refresh-mappings.ts @@ -1,65 +1,65 @@ /** * Refresh Mappings - * + * * Refresh target instance data and update mappings after publishing. */ -import ansiColors from 'ansi-colors'; -import * as fs from 'fs'; -import * as path from 'path'; -import { Pull } from '../../core/pull'; -import { getAllApiKeys, getState } from '../../core/state'; -import { updateMappingsAfterPublish } from '../mappers/mapping-version-updater'; -import { waitForFetchApiSync } from '../shared/get-fetch-api-status'; -import { generateLogHeader } from '../shared'; +import ansiColors from "ansi-colors"; +import * as fs from "fs"; +import * as path from "path"; +import { Pull } from "../../core/pull"; +import { getAllApiKeys, getState } from "../../core/state"; +import { updateMappingsAfterPublish } from "../mappers/mapping-version-updater"; +import { waitForFetchApiSync } from "../shared/get-fetch-api-status"; +import { generateLogHeader } from "../shared"; /** * Check if we have valid API keys for the target GUID */ function hasValidTargetKeys(targetGuid: string): boolean { - const apiKeys = getAllApiKeys(); - return apiKeys.some(key => key.guid === targetGuid); + const apiKeys = getAllApiKeys(); + return apiKeys.some((key) => key.guid === targetGuid); } /** * Write log lines to a file */ function writeLogFile(logLines: string[], targetGuid: string, locale: string): string | null { - try { - const state = getState(); - const logDir = path.resolve(state.rootPath, targetGuid, 'logs'); - - // Create logs directory if it doesn't exist - if (!fs.existsSync(logDir)) { - fs.mkdirSync(logDir, { recursive: true }); - } - - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const logFileName = `publish-${locale}-${timestamp}.log`; - const logFilePath = path.join(logDir, logFileName); - - // Add header - const header = generateLogHeader('Publish', { - 'Target GUID': targetGuid, - 'Locale': locale - }); - - // Strip ANSI colors for file output - const stripAnsi = (str: string) => str.replace(/\x1B\[[0-9;]*[mK]/g, ''); - const cleanLines = logLines.map(line => stripAnsi(line)); - - const content = header + cleanLines.join('\n') + '\n'; - fs.writeFileSync(logFilePath, content, 'utf8'); - - return logFilePath; - } catch (error) { - return null; + try { + const state = getState(); + const logDir = path.resolve(state.rootPath, targetGuid, "logs"); + + // Create logs directory if it doesn't exist + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }); } + + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const logFileName = `publish-${locale}-${timestamp}.log`; + const logFilePath = path.join(logDir, logFileName); + + // Add header + const header = generateLogHeader("Publish", { + "Target GUID": targetGuid, + Locale: locale, + }); + + // Strip ANSI colors for file output + const stripAnsi = (str: string) => str.replace(/\x1B\[[0-9;]*[mK]/g, ""); + const cleanLines = logLines.map((line) => stripAnsi(line)); + + const content = header + cleanLines.join("\n") + "\n"; + fs.writeFileSync(logFilePath, content, "utf8"); + + return logFilePath; + } catch (error) { + return null; + } } /** * Refresh target instance data and update mappings with new versionIDs after publishing - * + * * @param publishedContentIds - Content IDs that were published * @param publishedPageIds - Page IDs that were published * @param sourceGuid - Source instance GUID @@ -68,106 +68,109 @@ function writeLogFile(logLines: string[], targetGuid: string, locale: string): s * @param publishLogLines - Log lines from the publish operation to include in log file */ export async function refreshAndUpdateMappings( - publishedContentIds: number[], - publishedPageIds: number[], - sourceGuid: string, - targetGuid: string, - locale: string, - publishLogLines: string[] = [] + publishedContentIds: number[], + publishedPageIds: number[], + sourceGuid: string, + targetGuid: string, + locale: string, + publishLogLines: string[] = [] ): Promise { - // Start with publish log lines if provided - const logLines: string[] = [...publishLogLines]; - - const headerLine = ansiColors.cyan('\nRefreshing target instance data...'); - logLines.push(headerLine); - console.log(headerLine); - - // Check if we have API keys for the target - if not, key fetch failed earlier - if (!hasValidTargetKeys(targetGuid)) { - const warnLine = ansiColors.yellow(` ⚠️ No API keys available for target ${targetGuid} - skipping refresh and mapping updates`); - const infoLine1 = ansiColors.gray(' This typically indicates an API connection issue (503, timeout, etc.)'); - const infoLine2 = ansiColors.gray(' Mappings will be updated on next successful sync'); - logLines.push(warnLine, infoLine1, infoLine2); - console.log(warnLine); - console.log(infoLine1); - console.log(infoLine2); - - // Still write log file even if we can't refresh - const logFilePath = writeLogFile(logLines, targetGuid, locale); - if (logFilePath) { - console.log(ansiColors.gray(`\n📄 Log file: ${logFilePath}`)); - } - return; + // Start with publish log lines if provided + const logLines: string[] = [...publishLogLines]; + + const headerLine = ansiColors.cyan("\nRefreshing target instance data..."); + logLines.push(headerLine); + console.log(headerLine); + + // Check if we have API keys for the target - if not, key fetch failed earlier + if (!hasValidTargetKeys(targetGuid)) { + const warnLine = ansiColors.yellow( + ` ⚠️ No API keys available for target ${targetGuid} - skipping refresh and mapping updates` + ); + const infoLine1 = ansiColors.gray(" This typically indicates an API connection issue (503, timeout, etc.)"); + const infoLine2 = ansiColors.gray(" Mappings will be updated on next successful sync"); + logLines.push(warnLine, infoLine1, infoLine2); + console.log(warnLine); + console.log(infoLine1); + console.log(infoLine2); + + // Still write log file even if we can't refresh + const logFilePath = writeLogFile(logLines, targetGuid, locale); + if (logFilePath) { + console.log(ansiColors.gray(`\n📄 Log file: ${logFilePath}`)); } - + return; + } + + try { + // Wait for Fetch API sync to complete before refreshing + // This ensures we're pulling the latest published data from the CDN try { - // Wait for Fetch API sync to complete before refreshing - // This ensures we're pulling the latest published data from the CDN - try { - const syncResult = await waitForFetchApiSync(targetGuid, 'fetch', false); - logLines.push(...syncResult.logLines); - } catch (error: any) { - const warnLine = ansiColors.yellow(` ⚠️ Could not check Fetch API status: ${error.message}`); - logLines.push(warnLine); - console.log(warnLine); - // Continue with refresh anyway - the status check is best-effort - } - - const pull = new Pull(); - - // Run an incremental pull on the target instance - const pullResult = await pull.pullInstances(true); - - // Check if the pull was successful before updating mappings - if (!pullResult.success) { - const warnLine = ansiColors.yellow(' ⚠️ Target refresh failed - skipping mapping version updates'); - const infoLine = ansiColors.gray(' Run a manual pull to refresh data and update mappings'); - logLines.push(warnLine, infoLine); - console.log(warnLine); - console.log(infoLine); - - // Still write log file on failure - const logFilePath = writeLogFile(logLines, targetGuid, locale); - if (logFilePath) { - console.log(ansiColors.gray(`\n📄 Log file: ${logFilePath}`)); - } - return; - } - - const successLine = ansiColors.green('✓ Target instance data refreshed'); - logLines.push(successLine); - console.log(successLine); - - // Update the mappings with the new versionIDs - const mappingResult = await updateMappingsAfterPublish( - publishedContentIds, - publishedPageIds, - sourceGuid, - targetGuid, - locale - ); - - // Add mapping update log lines - logLines.push(...mappingResult.logLines); - - // Write log file - const logFilePath = writeLogFile(logLines, targetGuid, locale); - if (logFilePath) { - const logPathLine = ansiColors.gray(`\n📄 Log file: ${logFilePath}`); - console.log(logPathLine); - } - + const syncResult = await waitForFetchApiSync(targetGuid, "fetch", false); + logLines.push(...syncResult.logLines); } catch (error: any) { - const errorLine = ansiColors.yellow(` ⚠️ Warning: Could not refresh/update mappings after publish: ${error.message}`); - const infoLine = ansiColors.gray(' Mappings may be stale until next sync'); - logLines.push(errorLine, infoLine); - console.error(errorLine); - console.log(infoLine); - - // Still write log file on error - const logFilePath = writeLogFile(logLines, targetGuid, locale); - if (logFilePath) { - console.log(ansiColors.gray(`\n📄 Log file: ${logFilePath}`)); - } + const warnLine = ansiColors.yellow(` ⚠️ Could not check Fetch API status: ${error.message}`); + logLines.push(warnLine); + console.log(warnLine); + // Continue with refresh anyway - the status check is best-effort + } + + const pull = new Pull(); + + // Run an incremental pull on the target instance + const pullResult = await pull.pullInstances(true); + + // Check if the pull was successful before updating mappings + if (!pullResult.success) { + const warnLine = ansiColors.yellow(" ⚠️ Target refresh failed - skipping mapping version updates"); + const infoLine = ansiColors.gray(" Run a manual pull to refresh data and update mappings"); + logLines.push(warnLine, infoLine); + console.log(warnLine); + console.log(infoLine); + + // Still write log file on failure + const logFilePath = writeLogFile(logLines, targetGuid, locale); + if (logFilePath) { + console.log(ansiColors.gray(`\n📄 Log file: ${logFilePath}`)); + } + return; + } + + const successLine = ansiColors.green("✓ Target instance data refreshed"); + logLines.push(successLine); + console.log(successLine); + + // Update the mappings with the new versionIDs + const mappingResult = await updateMappingsAfterPublish( + publishedContentIds, + publishedPageIds, + sourceGuid, + targetGuid, + locale + ); + + // Add mapping update log lines + logLines.push(...mappingResult.logLines); + + // Write log file + const logFilePath = writeLogFile(logLines, targetGuid, locale); + if (logFilePath) { + const logPathLine = ansiColors.gray(`\n📄 Log file: ${logFilePath}`); + console.log(logPathLine); + } + } catch (error: any) { + const errorLine = ansiColors.yellow( + ` ⚠️ Warning: Could not refresh/update mappings after publish: ${error.message}` + ); + const infoLine = ansiColors.gray(" Mappings may be stale until next sync"); + logLines.push(errorLine, infoLine); + console.error(errorLine); + console.log(infoLine); + + // Still write log file on error + const logFilePath = writeLogFile(logLines, targetGuid, locale); + if (logFilePath) { + console.log(ansiColors.gray(`\n📄 Log file: ${logFilePath}`)); } + } } diff --git a/src/lib/workflows/tests/list-mappings.test.ts b/src/lib/workflows/tests/list-mappings.test.ts index 68a6b1a..a7b2544 100644 --- a/src/lib/workflows/tests/list-mappings.test.ts +++ b/src/lib/workflows/tests/list-mappings.test.ts @@ -1,86 +1,86 @@ -import { resetState } from 'core/state'; -import { listMappings } from '../list-mappings'; -import * as mappingReader from '../../mappers/mapping-reader'; +import { resetState } from "core/state"; +import { listMappings } from "../list-mappings"; +import * as mappingReader from "../../mappers/mapping-reader"; beforeEach(() => { - resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + resetState(); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { - jest.restoreAllMocks(); + jest.restoreAllMocks(); }); // ─── listMappings ───────────────────────────────────────────────────────────── -describe('listMappings', () => { - it('logs a "No mappings found" message when no pairs are returned', () => { - jest.spyOn(mappingReader, 'listAvailableMappingPairs').mockReturnValue([]); - const logSpy = jest.spyOn(console, 'log'); +describe("listMappings", () => { + it('logs a "No mappings found" message when no pairs are returned', () => { + jest.spyOn(mappingReader, "listAvailableMappingPairs").mockReturnValue([]); + const logSpy = jest.spyOn(console, "log"); - listMappings(); + listMappings(); - const calls = logSpy.mock.calls.map(args => args[0]); - const hasNoMappings = calls.some(c => typeof c === 'string' && c.includes('No mappings found')); - expect(hasNoMappings).toBe(true); - }); + const calls = logSpy.mock.calls.map((args) => args[0]); + const hasNoMappings = calls.some((c) => typeof c === "string" && c.includes("No mappings found")); + expect(hasNoMappings).toBe(true); + }); - it('does not crash and logs pair info when mappings are available', () => { - jest.spyOn(mappingReader, 'listAvailableMappingPairs').mockReturnValue([ - { sourceGuid: 'src-a', targetGuid: 'tgt-b', locales: ['en-us'] }, - ]); - jest.spyOn(mappingReader, 'getMappingSummary').mockReturnValue({ - totalContent: 5, - totalPages: 2, - localesFound: ['en-us'], - } as any); - const logSpy = jest.spyOn(console, 'log'); + it("does not crash and logs pair info when mappings are available", () => { + jest + .spyOn(mappingReader, "listAvailableMappingPairs") + .mockReturnValue([{ sourceGuid: "src-a", targetGuid: "tgt-b", locales: ["en-us"] }]); + jest.spyOn(mappingReader, "getMappingSummary").mockReturnValue({ + totalContent: 5, + totalPages: 2, + localesFound: ["en-us"], + } as any); + const logSpy = jest.spyOn(console, "log"); - expect(() => listMappings()).not.toThrow(); + expect(() => listMappings()).not.toThrow(); - const calls = logSpy.mock.calls.map(args => args[0]); - const mentionsSource = calls.some(c => typeof c === 'string' && c.includes('src-a')); - expect(mentionsSource).toBe(true); - }); + const calls = logSpy.mock.calls.map((args) => args[0]); + const mentionsSource = calls.some((c) => typeof c === "string" && c.includes("src-a")); + expect(mentionsSource).toBe(true); + }); - it('calls getMappingSummary with correct guid pair and locales for each found pair', () => { - const pairs = [ - { sourceGuid: 'src-1', targetGuid: 'tgt-1', locales: ['en-us', 'fr-fr'] }, - { sourceGuid: 'src-2', targetGuid: 'tgt-2', locales: ['de-de'] }, - ]; - jest.spyOn(mappingReader, 'listAvailableMappingPairs').mockReturnValue(pairs); - const summarySpy = jest.spyOn(mappingReader, 'getMappingSummary').mockReturnValue({ - totalContent: 0, - totalPages: 0, - localesFound: [], - } as any); + it("calls getMappingSummary with correct guid pair and locales for each found pair", () => { + const pairs = [ + { sourceGuid: "src-1", targetGuid: "tgt-1", locales: ["en-us", "fr-fr"] }, + { sourceGuid: "src-2", targetGuid: "tgt-2", locales: ["de-de"] }, + ]; + jest.spyOn(mappingReader, "listAvailableMappingPairs").mockReturnValue(pairs); + const summarySpy = jest.spyOn(mappingReader, "getMappingSummary").mockReturnValue({ + totalContent: 0, + totalPages: 0, + localesFound: [], + } as any); - listMappings(); + listMappings(); - expect(summarySpy).toHaveBeenCalledTimes(2); - expect(summarySpy).toHaveBeenCalledWith('src-1', 'tgt-1', ['en-us', 'fr-fr']); - expect(summarySpy).toHaveBeenCalledWith('src-2', 'tgt-2', ['de-de']); - }); + expect(summarySpy).toHaveBeenCalledTimes(2); + expect(summarySpy).toHaveBeenCalledWith("src-1", "tgt-1", ["en-us", "fr-fr"]); + expect(summarySpy).toHaveBeenCalledWith("src-2", "tgt-2", ["de-de"]); + }); - it('logs content and page counts from the summary', () => { - jest.spyOn(mappingReader, 'listAvailableMappingPairs').mockReturnValue([ - { sourceGuid: 'src-x', targetGuid: 'tgt-y', locales: ['en-us'] }, - ]); - jest.spyOn(mappingReader, 'getMappingSummary').mockReturnValue({ - totalContent: 42, - totalPages: 7, - localesFound: ['en-us'], - } as any); - const logSpy = jest.spyOn(console, 'log'); + it("logs content and page counts from the summary", () => { + jest + .spyOn(mappingReader, "listAvailableMappingPairs") + .mockReturnValue([{ sourceGuid: "src-x", targetGuid: "tgt-y", locales: ["en-us"] }]); + jest.spyOn(mappingReader, "getMappingSummary").mockReturnValue({ + totalContent: 42, + totalPages: 7, + localesFound: ["en-us"], + } as any); + const logSpy = jest.spyOn(console, "log"); - listMappings(); + listMappings(); - const calls = logSpy.mock.calls.map(args => args[0]); - const hasContent = calls.some(c => typeof c === 'string' && c.includes('42')); - const hasPages = calls.some(c => typeof c === 'string' && c.includes('7')); - expect(hasContent).toBe(true); - expect(hasPages).toBe(true); - }); + const calls = logSpy.mock.calls.map((args) => args[0]); + const hasContent = calls.some((c) => typeof c === "string" && c.includes("42")); + const hasPages = calls.some((c) => typeof c === "string" && c.includes("7")); + expect(hasContent).toBe(true); + expect(hasPages).toBe(true); + }); }); diff --git a/src/lib/workflows/tests/process-batches.test.ts b/src/lib/workflows/tests/process-batches.test.ts index a0387cd..62ecf80 100644 --- a/src/lib/workflows/tests/process-batches.test.ts +++ b/src/lib/workflows/tests/process-batches.test.ts @@ -1,174 +1,170 @@ -import { resetState, setState } from 'core/state'; -import { WorkflowOperationType } from 'types/workflows'; -import { processBatches, BatchProcessingResult } from '../process-batches'; -import * as batchWorkflowsModule from '../../../core/batch-workflows'; +import { resetState, setState } from "core/state"; +import { WorkflowOperationType } from "types/workflows"; +import { processBatches, BatchProcessingResult } from "../process-batches"; +import * as batchWorkflowsModule from "../../../core/batch-workflows"; beforeEach(() => { - resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + resetState(); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { - jest.restoreAllMocks(); + jest.restoreAllMocks(); }); function makeBatchResult(overrides: Partial>> = {}) { - return { - success: true, - processedIds: [], - failedCount: 0, - batchId: 1, - ...overrides, - }; + return { + success: true, + processedIds: [], + failedCount: 0, + batchId: 1, + ...overrides, + }; } // ─── processBatches — empty input ──────────────────────────────────────────── -describe('processBatches', () => { - describe('when ids array is empty', () => { - it('returns a zero-count result immediately without calling batchWorkflow', async () => { - const batchSpy = jest.spyOn(batchWorkflowsModule, 'batchWorkflow'); +describe("processBatches", () => { + describe("when ids array is empty", () => { + it("returns a zero-count result immediately without calling batchWorkflow", async () => { + const batchSpy = jest.spyOn(batchWorkflowsModule, "batchWorkflow"); - const result = await processBatches([], 'content', 'en-us', WorkflowOperationType.Publish, []); + const result = await processBatches([], "content", "en-us", WorkflowOperationType.Publish, []); - expect(result.total).toBe(0); - expect(result.processed).toBe(0); - expect(result.failed).toBe(0); - expect(result.batches).toBe(0); - expect(result.processedIds).toHaveLength(0); - expect(batchSpy).not.toHaveBeenCalled(); - }); + expect(result.total).toBe(0); + expect(result.processed).toBe(0); + expect(result.failed).toBe(0); + expect(result.batches).toBe(0); + expect(result.processedIds).toHaveLength(0); + expect(batchSpy).not.toHaveBeenCalled(); }); + }); - describe('deduplication', () => { - it('removes duplicate IDs before processing', async () => { - jest.spyOn(batchWorkflowsModule, 'batchWorkflow').mockResolvedValue(makeBatchResult({ processedIds: [1, 2] })); + describe("deduplication", () => { + it("removes duplicate IDs before processing", async () => { + jest.spyOn(batchWorkflowsModule, "batchWorkflow").mockResolvedValue(makeBatchResult({ processedIds: [1, 2] })); - const errors: string[] = []; - const result = await processBatches([1, 2, 1, 2], 'content', 'en-us', WorkflowOperationType.Publish, errors); + const errors: string[] = []; + const result = await processBatches([1, 2, 1, 2], "content", "en-us", WorkflowOperationType.Publish, errors); - expect(result.total).toBe(2); - }); + expect(result.total).toBe(2); + }); - it('logs deduplication message when duplicates are removed', async () => { - jest.spyOn(batchWorkflowsModule, 'batchWorkflow').mockResolvedValue(makeBatchResult({ processedIds: [1] })); - const logSpy = jest.spyOn(console, 'log'); + it("logs deduplication message when duplicates are removed", async () => { + jest.spyOn(batchWorkflowsModule, "batchWorkflow").mockResolvedValue(makeBatchResult({ processedIds: [1] })); + const logSpy = jest.spyOn(console, "log"); - const errors: string[] = []; - await processBatches([1, 1, 1], 'content', 'en-us', WorkflowOperationType.Publish, errors); + const errors: string[] = []; + await processBatches([1, 1, 1], "content", "en-us", WorkflowOperationType.Publish, errors); - const calls = logSpy.mock.calls.map(args => args[0]); - const dedupeLogged = calls.some(c => typeof c === 'string' && c.includes('Deduplicated')); - expect(dedupeLogged).toBe(true); - }); + const calls = logSpy.mock.calls.map((args) => args[0]); + const dedupeLogged = calls.some((c) => typeof c === "string" && c.includes("Deduplicated")); + expect(dedupeLogged).toBe(true); }); + }); - describe('successful batch', () => { - it('accumulates processedIds and increments processed count', async () => { - jest.spyOn(batchWorkflowsModule, 'batchWorkflow').mockResolvedValue( - makeBatchResult({ processedIds: [10, 20, 30] }) - ); + describe("successful batch", () => { + it("accumulates processedIds and increments processed count", async () => { + jest + .spyOn(batchWorkflowsModule, "batchWorkflow") + .mockResolvedValue(makeBatchResult({ processedIds: [10, 20, 30] })); - const errors: string[] = []; - const result = await processBatches([10, 20, 30], 'content', 'en-us', WorkflowOperationType.Publish, errors); + const errors: string[] = []; + const result = await processBatches([10, 20, 30], "content", "en-us", WorkflowOperationType.Publish, errors); - expect(result.processed).toBe(3); - expect(result.processedIds).toEqual(expect.arrayContaining([10, 20, 30])); - expect(result.failed).toBe(0); - expect(errors).toHaveLength(0); - }); + expect(result.processed).toBe(3); + expect(result.processedIds).toEqual(expect.arrayContaining([10, 20, 30])); + expect(result.failed).toBe(0); + expect(errors).toHaveLength(0); + }); - it('returns populated logLines', async () => { - jest.spyOn(batchWorkflowsModule, 'batchWorkflow').mockResolvedValue( - makeBatchResult({ processedIds: [5] }) - ); + it("returns populated logLines", async () => { + jest.spyOn(batchWorkflowsModule, "batchWorkflow").mockResolvedValue(makeBatchResult({ processedIds: [5] })); - const result = await processBatches([5], 'content', 'en-us', WorkflowOperationType.Publish, []); + const result = await processBatches([5], "content", "en-us", WorkflowOperationType.Publish, []); - expect(result.logLines.length).toBeGreaterThan(0); - }); + expect(result.logLines.length).toBeGreaterThan(0); }); + }); - describe('failed batch', () => { - it('increments failed count and pushes to errors array on batch failure', async () => { - jest.spyOn(batchWorkflowsModule, 'batchWorkflow').mockResolvedValue( - makeBatchResult({ success: false, error: 'API timeout', processedIds: [] }) - ); + describe("failed batch", () => { + it("increments failed count and pushes to errors array on batch failure", async () => { + jest + .spyOn(batchWorkflowsModule, "batchWorkflow") + .mockResolvedValue(makeBatchResult({ success: false, error: "API timeout", processedIds: [] })); - const errors: string[] = []; - const result = await processBatches([1, 2], 'content', 'en-us', WorkflowOperationType.Publish, errors); + const errors: string[] = []; + const result = await processBatches([1, 2], "content", "en-us", WorkflowOperationType.Publish, errors); - expect(result.failed).toBe(2); - expect(errors.length).toBeGreaterThan(0); - expect(errors[0]).toContain('API timeout'); - }); + expect(result.failed).toBe(2); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]).toContain("API timeout"); + }); - it('increments failed count and pushes to errors array on thrown exception', async () => { - jest.spyOn(batchWorkflowsModule, 'batchWorkflow').mockRejectedValue(new Error('Network error')); + it("increments failed count and pushes to errors array on thrown exception", async () => { + jest.spyOn(batchWorkflowsModule, "batchWorkflow").mockRejectedValue(new Error("Network error")); - const errors: string[] = []; - const result = await processBatches([1], 'content', 'en-us', WorkflowOperationType.Publish, errors); + const errors: string[] = []; + const result = await processBatches([1], "content", "en-us", WorkflowOperationType.Publish, errors); - expect(result.failed).toBe(1); - expect(errors[0]).toContain('Network error'); - }); + expect(result.failed).toBe(1); + expect(errors[0]).toContain("Network error"); }); - - describe('partial success', () => { - it('tracks partial success: increments failed for the failure portion', async () => { - jest.spyOn(batchWorkflowsModule, 'batchWorkflow').mockResolvedValue( - makeBatchResult({ - success: true, - processedIds: [1, 2], - partialSuccess: { successCount: 2, failureCount: 1, batchId: 99 }, - }) - ); - - const errors: string[] = []; - const result = await processBatches([1, 2, 3], 'content', 'en-us', WorkflowOperationType.Publish, errors); - - expect(result.failed).toBe(1); - expect(errors.length).toBeGreaterThan(0); - expect(errors[0]).toContain('Completed with errors'); - }); + }); + + describe("partial success", () => { + it("tracks partial success: increments failed for the failure portion", async () => { + jest.spyOn(batchWorkflowsModule, "batchWorkflow").mockResolvedValue( + makeBatchResult({ + success: true, + processedIds: [1, 2], + partialSuccess: { successCount: 2, failureCount: 1, batchId: 99 }, + }) + ); + + const errors: string[] = []; + const result = await processBatches([1, 2, 3], "content", "en-us", WorkflowOperationType.Publish, errors); + + expect(result.failed).toBe(1); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]).toContain("Completed with errors"); }); + }); - describe('batch type labeling', () => { - it('uses "Content" label for content type', async () => { - jest.spyOn(batchWorkflowsModule, 'batchWorkflow').mockResolvedValue( - makeBatchResult({ success: false, error: 'err', processedIds: [] }) - ); + describe("batch type labeling", () => { + it('uses "Content" label for content type', async () => { + jest + .spyOn(batchWorkflowsModule, "batchWorkflow") + .mockResolvedValue(makeBatchResult({ success: false, error: "err", processedIds: [] })); - const errors: string[] = []; - await processBatches([1], 'content', 'en-us', WorkflowOperationType.Publish, errors); + const errors: string[] = []; + await processBatches([1], "content", "en-us", WorkflowOperationType.Publish, errors); - expect(errors[0]).toMatch(/^Content/); - }); + expect(errors[0]).toMatch(/^Content/); + }); - it('uses "Page" label for pages type', async () => { - jest.spyOn(batchWorkflowsModule, 'batchWorkflow').mockResolvedValue( - makeBatchResult({ success: false, error: 'err', processedIds: [] }) - ); + it('uses "Page" label for pages type', async () => { + jest + .spyOn(batchWorkflowsModule, "batchWorkflow") + .mockResolvedValue(makeBatchResult({ success: false, error: "err", processedIds: [] })); - const errors: string[] = []; - await processBatches([1], 'pages', 'en-us', WorkflowOperationType.Publish, errors); + const errors: string[] = []; + await processBatches([1], "pages", "en-us", WorkflowOperationType.Publish, errors); - expect(errors[0]).toMatch(/^Page/); - }); + expect(errors[0]).toMatch(/^Page/); }); + }); - describe('result shape', () => { - it('returns the correct batches count', async () => { - jest.spyOn(batchWorkflowsModule, 'createBatches').mockReturnValue([[1, 2], [3, 4], [5]]); - jest.spyOn(batchWorkflowsModule, 'batchWorkflow').mockResolvedValue( - makeBatchResult({ processedIds: [1, 2] }) - ); + describe("result shape", () => { + it("returns the correct batches count", async () => { + jest.spyOn(batchWorkflowsModule, "createBatches").mockReturnValue([[1, 2], [3, 4], [5]]); + jest.spyOn(batchWorkflowsModule, "batchWorkflow").mockResolvedValue(makeBatchResult({ processedIds: [1, 2] })); - const result = await processBatches([1, 2, 3, 4, 5], 'content', 'en-us', WorkflowOperationType.Publish, []); + const result = await processBatches([1, 2, 3, 4, 5], "content", "en-us", WorkflowOperationType.Publish, []); - expect(result.batches).toBe(3); - }); + expect(result.batches).toBe(3); }); + }); }); diff --git a/src/lib/workflows/tests/refresh-mappings.test.ts b/src/lib/workflows/tests/refresh-mappings.test.ts index 6a4d1d0..291267b 100644 --- a/src/lib/workflows/tests/refresh-mappings.test.ts +++ b/src/lib/workflows/tests/refresh-mappings.test.ts @@ -1,176 +1,170 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { resetState, setState } from 'core/state'; -import { state } from 'core/state'; -import * as coreState from 'core/state'; -import { refreshAndUpdateMappings } from '../refresh-mappings'; -import * as fetchApiStatus from '../../shared/get-fetch-api-status'; -import * as mappingVersionUpdater from '../../mappers/mapping-version-updater'; -import { Pull } from '../../../core/pull'; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resetState, setState } from "core/state"; +import { state } from "core/state"; +import * as coreState from "core/state"; +import { refreshAndUpdateMappings } from "../refresh-mappings"; +import * as fetchApiStatus from "../../shared/get-fetch-api-status"; +import * as mappingVersionUpdater from "../../mappers/mapping-version-updater"; +import { Pull } from "../../../core/pull"; let tmpDir: string; beforeAll(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agility-test-refresh-')); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-test-refresh-")); }); afterAll(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); + fs.rmSync(tmpDir, { recursive: true, force: true }); }); beforeEach(() => { - resetState(); - state.rootPath = tmpDir; - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + resetState(); + state.rootPath = tmpDir; + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { - jest.restoreAllMocks(); + jest.restoreAllMocks(); }); function stubFetchApiSync() { - return jest.spyOn(fetchApiStatus, 'waitForFetchApiSync').mockResolvedValue({ - synced: true, - logLines: [], - elapsed: 0, - } as any); + return jest.spyOn(fetchApiStatus, "waitForFetchApiSync").mockResolvedValue({ + synced: true, + logLines: [], + elapsed: 0, + } as any); } function stubMappingUpdate() { - return jest.spyOn(mappingVersionUpdater, 'updateMappingsAfterPublish').mockResolvedValue({ - contentMappingsUpdated: 1, - pageMappingsUpdated: 1, - errors: [], - logLines: [], - } as any); + return jest.spyOn(mappingVersionUpdater, "updateMappingsAfterPublish").mockResolvedValue({ + contentMappingsUpdated: 1, + pageMappingsUpdated: 1, + errors: [], + logLines: [], + } as any); } function stubPullSuccess() { - return jest.spyOn(Pull.prototype, 'pullInstances').mockResolvedValue({ - success: true, - results: [], - elapsedTime: 0, - }); + return jest.spyOn(Pull.prototype, "pullInstances").mockResolvedValue({ + success: true, + results: [], + elapsedTime: 0, + }); } function stubPullFailure() { - return jest.spyOn(Pull.prototype, 'pullInstances').mockResolvedValue({ - success: false, - results: [], - elapsedTime: 0, - }); + return jest.spyOn(Pull.prototype, "pullInstances").mockResolvedValue({ + success: false, + results: [], + elapsedTime: 0, + }); } // ─── refreshAndUpdateMappings ───────────────────────────────────────────────── -describe('refreshAndUpdateMappings', () => { - describe('when no valid API keys exist for the target', () => { - it('skips pull and mapping updates when target has no API keys', async () => { - state.apiKeys = []; // no keys +describe("refreshAndUpdateMappings", () => { + describe("when no valid API keys exist for the target", () => { + it("skips pull and mapping updates when target has no API keys", async () => { + state.apiKeys = []; // no keys - const pullSpy = stubPullSuccess(); - const mappingSpy = stubMappingUpdate(); + const pullSpy = stubPullSuccess(); + const mappingSpy = stubMappingUpdate(); - await refreshAndUpdateMappings([], [], 'src-guid', 'tgt-guid', 'en-us'); + await refreshAndUpdateMappings([], [], "src-guid", "tgt-guid", "en-us"); - expect(pullSpy).not.toHaveBeenCalled(); - expect(mappingSpy).not.toHaveBeenCalled(); - }); + expect(pullSpy).not.toHaveBeenCalled(); + expect(mappingSpy).not.toHaveBeenCalled(); + }); - it('does not throw when no API keys exist', async () => { - state.apiKeys = []; - await expect( - refreshAndUpdateMappings([], [], 'src-guid', 'tgt-guid', 'en-us') - ).resolves.not.toThrow(); - }); + it("does not throw when no API keys exist", async () => { + state.apiKeys = []; + await expect(refreshAndUpdateMappings([], [], "src-guid", "tgt-guid", "en-us")).resolves.not.toThrow(); }); + }); - describe('when valid API keys exist for the target', () => { - beforeEach(() => { - state.apiKeys = [{ guid: 'tgt-guid', previewKey: 'pk', fetchKey: 'fk' }]; - }); + describe("when valid API keys exist for the target", () => { + beforeEach(() => { + state.apiKeys = [{ guid: "tgt-guid", previewKey: "pk", fetchKey: "fk" }]; + }); - it('calls pull.pullInstances on a successful flow', async () => { - stubFetchApiSync(); - const pullSpy = stubPullSuccess(); - stubMappingUpdate(); + it("calls pull.pullInstances on a successful flow", async () => { + stubFetchApiSync(); + const pullSpy = stubPullSuccess(); + stubMappingUpdate(); - await refreshAndUpdateMappings([1], [2], 'src-guid', 'tgt-guid', 'en-us'); + await refreshAndUpdateMappings([1], [2], "src-guid", "tgt-guid", "en-us"); - expect(pullSpy).toHaveBeenCalledWith(true); - }); + expect(pullSpy).toHaveBeenCalledWith(true); + }); - it('calls updateMappingsAfterPublish with correct args on success', async () => { - stubFetchApiSync(); - stubPullSuccess(); - const mappingSpy = stubMappingUpdate(); + it("calls updateMappingsAfterPublish with correct args on success", async () => { + stubFetchApiSync(); + stubPullSuccess(); + const mappingSpy = stubMappingUpdate(); - await refreshAndUpdateMappings([1, 2], [3], 'src-guid', 'tgt-guid', 'en-us'); + await refreshAndUpdateMappings([1, 2], [3], "src-guid", "tgt-guid", "en-us"); - expect(mappingSpy).toHaveBeenCalledWith([1, 2], [3], 'src-guid', 'tgt-guid', 'en-us'); - }); + expect(mappingSpy).toHaveBeenCalledWith([1, 2], [3], "src-guid", "tgt-guid", "en-us"); + }); - it('skips mapping updates when pull fails', async () => { - stubFetchApiSync(); - stubPullFailure(); - const mappingSpy = stubMappingUpdate(); + it("skips mapping updates when pull fails", async () => { + stubFetchApiSync(); + stubPullFailure(); + const mappingSpy = stubMappingUpdate(); - await refreshAndUpdateMappings([1], [], 'src-guid', 'tgt-guid', 'en-us'); + await refreshAndUpdateMappings([1], [], "src-guid", "tgt-guid", "en-us"); - expect(mappingSpy).not.toHaveBeenCalled(); - }); + expect(mappingSpy).not.toHaveBeenCalled(); + }); - it('does not throw when pull fails', async () => { - stubFetchApiSync(); - stubPullFailure(); + it("does not throw when pull fails", async () => { + stubFetchApiSync(); + stubPullFailure(); - await expect( - refreshAndUpdateMappings([1], [], 'src-guid', 'tgt-guid', 'en-us') - ).resolves.not.toThrow(); - }); + await expect(refreshAndUpdateMappings([1], [], "src-guid", "tgt-guid", "en-us")).resolves.not.toThrow(); + }); - it('continues even when waitForFetchApiSync throws', async () => { - jest.spyOn(fetchApiStatus, 'waitForFetchApiSync').mockRejectedValue(new Error('timeout')); - const pullSpy = stubPullSuccess(); - stubMappingUpdate(); + it("continues even when waitForFetchApiSync throws", async () => { + jest.spyOn(fetchApiStatus, "waitForFetchApiSync").mockRejectedValue(new Error("timeout")); + const pullSpy = stubPullSuccess(); + stubMappingUpdate(); - await refreshAndUpdateMappings([1], [], 'src-guid', 'tgt-guid', 'en-us'); + await refreshAndUpdateMappings([1], [], "src-guid", "tgt-guid", "en-us"); - expect(pullSpy).toHaveBeenCalled(); - }); + expect(pullSpy).toHaveBeenCalled(); + }); - it('does not throw when updateMappingsAfterPublish rejects', async () => { - stubFetchApiSync(); - stubPullSuccess(); - jest.spyOn(mappingVersionUpdater, 'updateMappingsAfterPublish').mockRejectedValue( - new Error('mapping update failed') - ); + it("does not throw when updateMappingsAfterPublish rejects", async () => { + stubFetchApiSync(); + stubPullSuccess(); + jest + .spyOn(mappingVersionUpdater, "updateMappingsAfterPublish") + .mockRejectedValue(new Error("mapping update failed")); - await expect( - refreshAndUpdateMappings([1], [], 'src-guid', 'tgt-guid', 'en-us') - ).resolves.not.toThrow(); - }); + await expect(refreshAndUpdateMappings([1], [], "src-guid", "tgt-guid", "en-us")).resolves.not.toThrow(); + }); - it('accepts publishLogLines and does not throw', async () => { - stubFetchApiSync(); - stubPullSuccess(); - stubMappingUpdate(); + it("accepts publishLogLines and does not throw", async () => { + stubFetchApiSync(); + stubPullSuccess(); + stubMappingUpdate(); - await expect( - refreshAndUpdateMappings([1], [2], 'src-guid', 'tgt-guid', 'en-us', ['log line 1', 'log line 2']) - ).resolves.not.toThrow(); - }); + await expect( + refreshAndUpdateMappings([1], [2], "src-guid", "tgt-guid", "en-us", ["log line 1", "log line 2"]) + ).resolves.not.toThrow(); + }); - it('creates the logs directory under rootPath/targetGuid/logs', async () => { - stubFetchApiSync(); - stubPullSuccess(); - stubMappingUpdate(); + it("creates the logs directory under rootPath/targetGuid/logs", async () => { + stubFetchApiSync(); + stubPullSuccess(); + stubMappingUpdate(); - await refreshAndUpdateMappings([], [], 'src-guid', 'tgt-guid', 'en-us'); + await refreshAndUpdateMappings([], [], "src-guid", "tgt-guid", "en-us"); - const expectedLogDir = path.resolve(tmpDir, 'tgt-guid', 'logs'); - expect(fs.existsSync(expectedLogDir)).toBe(true); - }); + const expectedLogDir = path.resolve(tmpDir, "tgt-guid", "logs"); + expect(fs.existsSync(expectedLogDir)).toBe(true); }); + }); }); diff --git a/src/lib/workflows/tests/workflow-helpers.test.ts b/src/lib/workflows/tests/workflow-helpers.test.ts index 3634a69..7dbbb1e 100644 --- a/src/lib/workflows/tests/workflow-helpers.test.ts +++ b/src/lib/workflows/tests/workflow-helpers.test.ts @@ -1,77 +1,75 @@ -import { resetState } from 'core/state'; -import { WorkflowOperationType } from 'types/workflows'; -import { getOperationName, getOperationVerb, getOperationIcon } from '../workflow-helpers'; +import { resetState } from "core/state"; +import { WorkflowOperationType } from "types/workflows"; +import { getOperationName, getOperationVerb, getOperationIcon } from "../workflow-helpers"; beforeEach(() => { - resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + resetState(); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { - jest.restoreAllMocks(); + jest.restoreAllMocks(); }); // ─── getOperationName ───────────────────────────────────────────────────────── -describe('getOperationName', () => { - it.each([ - [WorkflowOperationType.Publish, 'publish'], - [WorkflowOperationType.Unpublish, 'unpublish'], - [WorkflowOperationType.Approve, 'approve'], - [WorkflowOperationType.Decline, 'decline'], - [WorkflowOperationType.RequestApproval, 'request approval'], - ])('returns %s for %i', (operation, expected) => { - expect(getOperationName(operation)).toBe(expected); - }); +describe("getOperationName", () => { + it.each([ + [WorkflowOperationType.Publish, "publish"], + [WorkflowOperationType.Unpublish, "unpublish"], + [WorkflowOperationType.Approve, "approve"], + [WorkflowOperationType.Decline, "decline"], + [WorkflowOperationType.RequestApproval, "request approval"], + ])("returns %s for %i", (operation, expected) => { + expect(getOperationName(operation)).toBe(expected); + }); - it('returns "process" for an unrecognized enum value', () => { - expect(getOperationName(9999 as WorkflowOperationType)).toBe('process'); - }); + it('returns "process" for an unrecognized enum value', () => { + expect(getOperationName(9999 as WorkflowOperationType)).toBe("process"); + }); }); // ─── getOperationVerb ───────────────────────────────────────────────────────── -describe('getOperationVerb', () => { - it.each([ - [WorkflowOperationType.Publish, 'published'], - [WorkflowOperationType.Unpublish, 'unpublished'], - [WorkflowOperationType.Approve, 'approved'], - [WorkflowOperationType.Decline, 'declined'], - [WorkflowOperationType.RequestApproval, 'submitted for approval'], - ])('returns %s for %i', (operation, expected) => { - expect(getOperationVerb(operation)).toBe(expected); - }); +describe("getOperationVerb", () => { + it.each([ + [WorkflowOperationType.Publish, "published"], + [WorkflowOperationType.Unpublish, "unpublished"], + [WorkflowOperationType.Approve, "approved"], + [WorkflowOperationType.Decline, "declined"], + [WorkflowOperationType.RequestApproval, "submitted for approval"], + ])("returns %s for %i", (operation, expected) => { + expect(getOperationVerb(operation)).toBe(expected); + }); - it('returns "processed" for an unrecognized enum value', () => { - expect(getOperationVerb(9999 as WorkflowOperationType)).toBe('processed'); - }); + it('returns "processed" for an unrecognized enum value', () => { + expect(getOperationVerb(9999 as WorkflowOperationType)).toBe("processed"); + }); }); // ─── getOperationIcon ───────────────────────────────────────────────────────── -describe('getOperationIcon', () => { - it('returns a non-empty string for every known operation type', () => { - const knownTypes = [ - WorkflowOperationType.Publish, - WorkflowOperationType.Unpublish, - WorkflowOperationType.Approve, - WorkflowOperationType.Decline, - WorkflowOperationType.RequestApproval, - ]; - for (const op of knownTypes) { - expect(getOperationIcon(op).length).toBeGreaterThan(0); - } - }); +describe("getOperationIcon", () => { + it("returns a non-empty string for every known operation type", () => { + const knownTypes = [ + WorkflowOperationType.Publish, + WorkflowOperationType.Unpublish, + WorkflowOperationType.Approve, + WorkflowOperationType.Decline, + WorkflowOperationType.RequestApproval, + ]; + for (const op of knownTypes) { + expect(getOperationIcon(op).length).toBeGreaterThan(0); + } + }); - it('returns a non-empty string for an unrecognized enum value', () => { - expect(getOperationIcon(9999 as WorkflowOperationType).length).toBeGreaterThan(0); - }); + it("returns a non-empty string for an unrecognized enum value", () => { + expect(getOperationIcon(9999 as WorkflowOperationType).length).toBeGreaterThan(0); + }); - it('returns different icons for Publish vs Unpublish', () => { - expect(getOperationIcon(WorkflowOperationType.Publish)).not.toBe( - getOperationIcon(WorkflowOperationType.Unpublish) - ); - }); + it("returns different icons for Publish vs Unpublish", () => { + expect(getOperationIcon(WorkflowOperationType.Publish)).not.toBe(getOperationIcon(WorkflowOperationType.Unpublish)); + }); }); diff --git a/src/lib/workflows/tests/workflow-operation.test.ts b/src/lib/workflows/tests/workflow-operation.test.ts index 3a43b2b..3bf93de 100644 --- a/src/lib/workflows/tests/workflow-operation.test.ts +++ b/src/lib/workflows/tests/workflow-operation.test.ts @@ -1,270 +1,271 @@ -import { resetState, setState } from 'core/state'; -import { state } from 'core/state'; -import { WorkflowOperationType } from 'types/workflows'; -import { WorkflowOperation } from '../workflow-operation'; -import * as mappingReader from '../../mappers/mapping-reader'; -import * as workflowOrchestratorModule from '../workflow-orchestrator'; -import * as refreshMappingsModule from '../refresh-mappings'; -import * as listMappingsModule from '../list-mappings'; -import * as sourcePublishStatusChecker from '../../shared/source-publish-status-checker'; +import { resetState, setState } from "core/state"; +import { state } from "core/state"; +import { WorkflowOperationType } from "types/workflows"; +import { WorkflowOperation } from "../workflow-operation"; +import * as mappingReader from "../../mappers/mapping-reader"; +import * as workflowOrchestratorModule from "../workflow-orchestrator"; +import * as refreshMappingsModule from "../refresh-mappings"; +import * as listMappingsModule from "../list-mappings"; +import * as sourcePublishStatusChecker from "../../shared/source-publish-status-checker"; beforeEach(() => { - resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + resetState(); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { - jest.restoreAllMocks(); + jest.restoreAllMocks(); }); function makeOrchestratorResult(overrides: any = {}) { - return { - success: true, - contentResults: { total: 0, processed: 0, failed: 0, batches: 0, processedIds: [], logLines: [] }, - pageResults: { total: 0, processed: 0, failed: 0, batches: 0, processedIds: [], logLines: [] }, - errors: [], - logLines: [], - ...overrides, - }; + return { + success: true, + contentResults: { total: 0, processed: 0, failed: 0, batches: 0, processedIds: [], logLines: [] }, + pageResults: { total: 0, processed: 0, failed: 0, batches: 0, processedIds: [], logLines: [] }, + errors: [], + logLines: [], + ...overrides, + }; } function makeMappingResult(overrides: any = {}) { - return { - contentIds: [], - pageIds: [], - contentMappings: [], - pageMappings: [], - errors: [], - ...overrides, - }; + return { + contentIds: [], + pageIds: [], + contentMappings: [], + pageMappings: [], + errors: [], + ...overrides, + }; } function makeMappingSummary(totalContent = 0, totalPages = 0) { - return { totalContent, totalPages, localesFound: ['en-us'] }; + return { totalContent, totalPages, localesFound: ["en-us"] }; } // ─── WorkflowOperation.executeFromMappings — guard clauses ─────────────────── -describe('WorkflowOperation.executeFromMappings', () => { - describe('guard clauses', () => { - it('returns success=false when sourceGuid is missing', async () => { - state.targetGuid = ['tgt-u']; - state.locale = ['en-us']; +describe("WorkflowOperation.executeFromMappings", () => { + describe("guard clauses", () => { + it("returns success=false when sourceGuid is missing", async () => { + state.targetGuid = ["tgt-u"]; + state.locale = ["en-us"]; - const op = new WorkflowOperation(); - const result = await op.executeFromMappings(); + const op = new WorkflowOperation(); + const result = await op.executeFromMappings(); - expect(result.success).toBe(false); - expect(result.errors[0]).toContain('Source GUID'); - }); + expect(result.success).toBe(false); + expect(result.errors[0]).toContain("Source GUID"); + }); - it('returns success=false when targetGuid is missing', async () => { - state.sourceGuid = ['src-u']; - state.locale = ['en-us']; + it("returns success=false when targetGuid is missing", async () => { + state.sourceGuid = ["src-u"]; + state.locale = ["en-us"]; - const op = new WorkflowOperation(); - const result = await op.executeFromMappings(); + const op = new WorkflowOperation(); + const result = await op.executeFromMappings(); - expect(result.success).toBe(false); - expect(result.errors[0]).toContain('Target GUID'); - }); + expect(result.success).toBe(false); + expect(result.errors[0]).toContain("Target GUID"); + }); - it('returns success=false when locale is missing', async () => { - state.sourceGuid = ['src-u']; - state.targetGuid = ['tgt-u']; + it("returns success=false when locale is missing", async () => { + state.sourceGuid = ["src-u"]; + state.targetGuid = ["tgt-u"]; - const op = new WorkflowOperation(); - const result = await op.executeFromMappings(); + const op = new WorkflowOperation(); + const result = await op.executeFromMappings(); - expect(result.success).toBe(false); - expect(result.errors[0]).toContain('locale'); - }); + expect(result.success).toBe(false); + expect(result.errors[0]).toContain("locale"); }); + }); - describe('standard mode — no mappings found', () => { - it('returns early with success=true and zero counts when no mappings exist', async () => { - state.sourceGuid = ['src-u']; - state.targetGuid = ['tgt-u']; - state.locale = ['en-us']; + describe("standard mode — no mappings found", () => { + it("returns early with success=true and zero counts when no mappings exist", async () => { + state.sourceGuid = ["src-u"]; + state.targetGuid = ["tgt-u"]; + state.locale = ["en-us"]; - jest.spyOn(mappingReader, 'getMappingSummary').mockReturnValue(makeMappingSummary(0, 0)); - jest.spyOn(mappingReader, 'readMappingsForGuidPair').mockReturnValue(makeMappingResult()); + jest.spyOn(mappingReader, "getMappingSummary").mockReturnValue(makeMappingSummary(0, 0)); + jest.spyOn(mappingReader, "readMappingsForGuidPair").mockReturnValue(makeMappingResult()); - const op = new WorkflowOperation(); - const result = await op.executeFromMappings(); + const op = new WorkflowOperation(); + const result = await op.executeFromMappings(); - expect(result.success).toBe(true); - expect(result.contentProcessed).toBe(0); - expect(result.pagesProcessed).toBe(0); - }); + expect(result.success).toBe(true); + expect(result.contentProcessed).toBe(0); + expect(result.pagesProcessed).toBe(0); }); - - describe('dry run mode', () => { - it('returns without calling workflowOrchestrator when dryRun=true', async () => { - state.sourceGuid = ['src-u']; - state.targetGuid = ['tgt-u']; - state.locale = ['en-us']; - state.dryRun = true; - state.operationType = 'unpublish'; - - jest.spyOn(mappingReader, 'getMappingSummary').mockReturnValue(makeMappingSummary(3, 2)); - jest.spyOn(mappingReader, 'readMappingsForGuidPair').mockReturnValue( - makeMappingResult({ contentIds: [1, 2, 3], pageIds: [10, 11] }) - ); - - const orchestratorSpy = jest.spyOn(workflowOrchestratorModule, 'workflowOrchestrator'); - - const op = new WorkflowOperation(); - const result = await op.executeFromMappings(); - - expect(orchestratorSpy).not.toHaveBeenCalled(); - expect(result.success).toBe(true); - // dry run reports would-be counts - expect(result.contentProcessed).toBe(3); - expect(result.pagesProcessed).toBe(2); - }); + }); + + describe("dry run mode", () => { + it("returns without calling workflowOrchestrator when dryRun=true", async () => { + state.sourceGuid = ["src-u"]; + state.targetGuid = ["tgt-u"]; + state.locale = ["en-us"]; + state.dryRun = true; + state.operationType = "unpublish"; + + jest.spyOn(mappingReader, "getMappingSummary").mockReturnValue(makeMappingSummary(3, 2)); + jest + .spyOn(mappingReader, "readMappingsForGuidPair") + .mockReturnValue(makeMappingResult({ contentIds: [1, 2, 3], pageIds: [10, 11] })); + + const orchestratorSpy = jest.spyOn(workflowOrchestratorModule, "workflowOrchestrator"); + + const op = new WorkflowOperation(); + const result = await op.executeFromMappings(); + + expect(orchestratorSpy).not.toHaveBeenCalled(); + expect(result.success).toBe(true); + // dry run reports would-be counts + expect(result.contentProcessed).toBe(3); + expect(result.pagesProcessed).toBe(2); }); - - describe('publish operation with source status check', () => { - it('filters content to only published-in-source IDs', async () => { - state.sourceGuid = ['src-u']; - state.targetGuid = ['tgt-u']; - state.locale = ['en-us']; - state.operationType = 'publish'; - - jest.spyOn(mappingReader, 'getMappingSummary').mockReturnValue(makeMappingSummary(2, 0)); - jest.spyOn(mappingReader, 'readMappingsForGuidPair').mockReturnValue( - makeMappingResult({ contentIds: [1, 2], pageIds: [] }) - ); - jest.spyOn(sourcePublishStatusChecker, 'checkSourcePublishStatus').mockReturnValue({ - publishedContentIds: [1], - unpublishedContentIds: [2], - publishedPageIds: [], - unpublishedPageIds: [], - errors: [], - }); - const orchestratorSpy = jest.spyOn(workflowOrchestratorModule, 'workflowOrchestrator') - .mockResolvedValue(makeOrchestratorResult({ contentResults: { total: 1, processed: 1, failed: 0, batches: 1, processedIds: [1], logLines: [] } })); - jest.spyOn(refreshMappingsModule, 'refreshAndUpdateMappings').mockResolvedValue(undefined); - - const op = new WorkflowOperation(); - await op.executeFromMappings(); - - const callArgs = orchestratorSpy.mock.calls[0]; - expect(callArgs[0]).toEqual([1]); // only published ID - }); + }); + + describe("publish operation with source status check", () => { + it("filters content to only published-in-source IDs", async () => { + state.sourceGuid = ["src-u"]; + state.targetGuid = ["tgt-u"]; + state.locale = ["en-us"]; + state.operationType = "publish"; + + jest.spyOn(mappingReader, "getMappingSummary").mockReturnValue(makeMappingSummary(2, 0)); + jest + .spyOn(mappingReader, "readMappingsForGuidPair") + .mockReturnValue(makeMappingResult({ contentIds: [1, 2], pageIds: [] })); + jest.spyOn(sourcePublishStatusChecker, "checkSourcePublishStatus").mockReturnValue({ + publishedContentIds: [1], + unpublishedContentIds: [2], + publishedPageIds: [], + unpublishedPageIds: [], + errors: [], + }); + const orchestratorSpy = jest.spyOn(workflowOrchestratorModule, "workflowOrchestrator").mockResolvedValue( + makeOrchestratorResult({ + contentResults: { total: 1, processed: 1, failed: 0, batches: 1, processedIds: [1], logLines: [] }, + }) + ); + jest.spyOn(refreshMappingsModule, "refreshAndUpdateMappings").mockResolvedValue(undefined); + + const op = new WorkflowOperation(); + await op.executeFromMappings(); + + const callArgs = orchestratorSpy.mock.calls[0]; + expect(callArgs[0]).toEqual([1]); // only published ID }); + }); + + describe("non-publish operation", () => { + it("passes all mapped IDs to workflowOrchestrator without source status check", async () => { + state.sourceGuid = ["src-u"]; + state.targetGuid = ["tgt-u"]; + state.locale = ["en-us"]; + state.operationType = "unpublish"; + + jest.spyOn(mappingReader, "getMappingSummary").mockReturnValue(makeMappingSummary(2, 1)); + jest + .spyOn(mappingReader, "readMappingsForGuidPair") + .mockReturnValue(makeMappingResult({ contentIds: [1, 2], pageIds: [10] })); + const statusCheckSpy = jest.spyOn(sourcePublishStatusChecker, "checkSourcePublishStatus"); + const orchestratorSpy = jest + .spyOn(workflowOrchestratorModule, "workflowOrchestrator") + .mockResolvedValue(makeOrchestratorResult()); + + const op = new WorkflowOperation(); + await op.executeFromMappings(); + + expect(statusCheckSpy).not.toHaveBeenCalled(); + expect(orchestratorSpy).toHaveBeenCalledWith( + [1, 2], + [10], + expect.objectContaining({ operation: WorkflowOperationType.Unpublish }) + ); + }); + }); + + describe("explicit IDs mode", () => { + it("uses explicit contentIDs when provided", async () => { + state.sourceGuid = ["src-u"]; + state.targetGuid = ["tgt-u"]; + state.locale = ["en-us"]; + state.operationType = "unpublish"; + state.explicitContentIDs = [100, 200]; + state.explicitPageIDs = []; - describe('non-publish operation', () => { - it('passes all mapped IDs to workflowOrchestrator without source status check', async () => { - state.sourceGuid = ['src-u']; - state.targetGuid = ['tgt-u']; - state.locale = ['en-us']; - state.operationType = 'unpublish'; - - jest.spyOn(mappingReader, 'getMappingSummary').mockReturnValue(makeMappingSummary(2, 1)); - jest.spyOn(mappingReader, 'readMappingsForGuidPair').mockReturnValue( - makeMappingResult({ contentIds: [1, 2], pageIds: [10] }) - ); - const statusCheckSpy = jest.spyOn(sourcePublishStatusChecker, 'checkSourcePublishStatus'); - const orchestratorSpy = jest.spyOn(workflowOrchestratorModule, 'workflowOrchestrator') - .mockResolvedValue(makeOrchestratorResult()); - - const op = new WorkflowOperation(); - await op.executeFromMappings(); - - expect(statusCheckSpy).not.toHaveBeenCalled(); - expect(orchestratorSpy).toHaveBeenCalledWith( - [1, 2], - [10], - expect.objectContaining({ operation: WorkflowOperationType.Unpublish }) - ); - }); + jest.spyOn(mappingReader, "getMappingSummary").mockReturnValue(makeMappingSummary(0, 0)); + + const orchestratorSpy = jest + .spyOn(workflowOrchestratorModule, "workflowOrchestrator") + .mockResolvedValue(makeOrchestratorResult()); + + const op = new WorkflowOperation(); + await op.executeFromMappings(); + + expect(orchestratorSpy).toHaveBeenCalledWith([100, 200], [], expect.any(Object)); }); - describe('explicit IDs mode', () => { - it('uses explicit contentIDs when provided', async () => { - state.sourceGuid = ['src-u']; - state.targetGuid = ['tgt-u']; - state.locale = ['en-us']; - state.operationType = 'unpublish'; - state.explicitContentIDs = [100, 200]; - state.explicitPageIDs = []; - - jest.spyOn(mappingReader, 'getMappingSummary').mockReturnValue(makeMappingSummary(0, 0)); - - const orchestratorSpy = jest.spyOn(workflowOrchestratorModule, 'workflowOrchestrator') - .mockResolvedValue(makeOrchestratorResult()); - - const op = new WorkflowOperation(); - await op.executeFromMappings(); - - expect(orchestratorSpy).toHaveBeenCalledWith( - [100, 200], - [], - expect.any(Object) - ); - }); - - it('returns early when all explicit IDs are empty', async () => { - state.sourceGuid = ['src-u']; - state.targetGuid = ['tgt-u']; - state.locale = ['en-us']; - state.operationType = 'unpublish'; - state.explicitContentIDs = []; - state.explicitPageIDs = []; - - // Even though summary says 0, we need a getMappingSummary mock since it's called - jest.spyOn(mappingReader, 'getMappingSummary').mockReturnValue(makeMappingSummary(0, 0)); - - const orchestratorSpy = jest.spyOn(workflowOrchestratorModule, 'workflowOrchestrator'); - - const op = new WorkflowOperation(); - const result = await op.executeFromMappings(); - - expect(orchestratorSpy).not.toHaveBeenCalled(); - expect(result.success).toBe(true); - }); + it("returns early when all explicit IDs are empty", async () => { + state.sourceGuid = ["src-u"]; + state.targetGuid = ["tgt-u"]; + state.locale = ["en-us"]; + state.operationType = "unpublish"; + state.explicitContentIDs = []; + state.explicitPageIDs = []; + + // Even though summary says 0, we need a getMappingSummary mock since it's called + jest.spyOn(mappingReader, "getMappingSummary").mockReturnValue(makeMappingSummary(0, 0)); + + const orchestratorSpy = jest.spyOn(workflowOrchestratorModule, "workflowOrchestrator"); + + const op = new WorkflowOperation(); + const result = await op.executeFromMappings(); + + expect(orchestratorSpy).not.toHaveBeenCalled(); + expect(result.success).toBe(true); }); + }); - describe('result fields', () => { - it('returns operation name in the result', async () => { - state.sourceGuid = ['src-u']; - state.targetGuid = ['tgt-u']; - state.locale = ['en-us']; - state.operationType = 'approve'; + describe("result fields", () => { + it("returns operation name in the result", async () => { + state.sourceGuid = ["src-u"]; + state.targetGuid = ["tgt-u"]; + state.locale = ["en-us"]; + state.operationType = "approve"; - jest.spyOn(mappingReader, 'getMappingSummary').mockReturnValue(makeMappingSummary(0, 0)); + jest.spyOn(mappingReader, "getMappingSummary").mockReturnValue(makeMappingSummary(0, 0)); - const op = new WorkflowOperation(); - const result = await op.executeFromMappings(); + const op = new WorkflowOperation(); + const result = await op.executeFromMappings(); - expect(result.operation).toBe('approve'); - }); + expect(result.operation).toBe("approve"); + }); - it('includes elapsedTime in the result', async () => { - state.sourceGuid = ['src-u']; - state.targetGuid = ['tgt-u']; - state.locale = ['en-us']; + it("includes elapsedTime in the result", async () => { + state.sourceGuid = ["src-u"]; + state.targetGuid = ["tgt-u"]; + state.locale = ["en-us"]; - jest.spyOn(mappingReader, 'getMappingSummary').mockReturnValue(makeMappingSummary(0, 0)); + jest.spyOn(mappingReader, "getMappingSummary").mockReturnValue(makeMappingSummary(0, 0)); - const op = new WorkflowOperation(); - const result = await op.executeFromMappings(); + const op = new WorkflowOperation(); + const result = await op.executeFromMappings(); - expect(typeof result.elapsedTime).toBe('number'); - expect(result.elapsedTime).toBeGreaterThanOrEqual(0); - }); + expect(typeof result.elapsedTime).toBe("number"); + expect(result.elapsedTime).toBeGreaterThanOrEqual(0); }); + }); - describe('listMappings method', () => { - it('delegates to the listMappings function without throwing', () => { - jest.spyOn(listMappingsModule, 'listMappings').mockImplementation(() => {}); + describe("listMappings method", () => { + it("delegates to the listMappings function without throwing", () => { + jest.spyOn(listMappingsModule, "listMappings").mockImplementation(() => {}); - const op = new WorkflowOperation(); - expect(() => op.listMappings()).not.toThrow(); - }); + const op = new WorkflowOperation(); + expect(() => op.listMappings()).not.toThrow(); }); + }); }); diff --git a/src/lib/workflows/tests/workflow-options.test.ts b/src/lib/workflows/tests/workflow-options.test.ts index 3d25e10..91e2bfb 100644 --- a/src/lib/workflows/tests/workflow-options.test.ts +++ b/src/lib/workflows/tests/workflow-options.test.ts @@ -1,115 +1,115 @@ -import { resetState } from 'core/state'; -import { WorkflowOperationType } from 'types/workflows'; -import { parseOperationType, parseWorkflowOptions } from '../workflow-options'; +import { resetState } from "core/state"; +import { WorkflowOperationType } from "types/workflows"; +import { parseOperationType, parseWorkflowOptions } from "../workflow-options"; beforeEach(() => { - resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + resetState(); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { - jest.restoreAllMocks(); + jest.restoreAllMocks(); }); // ─── parseOperationType ─────────────────────────────────────────────────────── -describe('parseOperationType', () => { - it('returns Publish when operationType is undefined', () => { - expect(parseOperationType(undefined)).toBe(WorkflowOperationType.Publish); - }); - - it.each([ - ['publish', WorkflowOperationType.Publish], - ['PUBLISH', WorkflowOperationType.Publish], - ['unpublish', WorkflowOperationType.Unpublish], - ['UNPUBLISH', WorkflowOperationType.Unpublish], - ['approve', WorkflowOperationType.Approve], - ['Approve', WorkflowOperationType.Approve], - ['decline', WorkflowOperationType.Decline], - ['requestapproval', WorkflowOperationType.RequestApproval], - ['request-approval', WorkflowOperationType.RequestApproval], - ['request_approval', WorkflowOperationType.RequestApproval], - ])('parses "%s" to the correct enum value', (input, expected) => { - expect(parseOperationType(input)).toBe(expected); - }); - - it('defaults to Publish for an unrecognized string', () => { - expect(parseOperationType('unknown-op')).toBe(WorkflowOperationType.Publish); - }); +describe("parseOperationType", () => { + it("returns Publish when operationType is undefined", () => { + expect(parseOperationType(undefined)).toBe(WorkflowOperationType.Publish); + }); + + it.each([ + ["publish", WorkflowOperationType.Publish], + ["PUBLISH", WorkflowOperationType.Publish], + ["unpublish", WorkflowOperationType.Unpublish], + ["UNPUBLISH", WorkflowOperationType.Unpublish], + ["approve", WorkflowOperationType.Approve], + ["Approve", WorkflowOperationType.Approve], + ["decline", WorkflowOperationType.Decline], + ["requestapproval", WorkflowOperationType.RequestApproval], + ["request-approval", WorkflowOperationType.RequestApproval], + ["request_approval", WorkflowOperationType.RequestApproval], + ])('parses "%s" to the correct enum value', (input, expected) => { + expect(parseOperationType(input)).toBe(expected); + }); + + it("defaults to Publish for an unrecognized string", () => { + expect(parseOperationType("unknown-op")).toBe(WorkflowOperationType.Publish); + }); }); // ─── parseWorkflowOptions ───────────────────────────────────────────────────── -describe('parseWorkflowOptions', () => { - it('returns null when operationType is falsy', () => { - expect(parseWorkflowOptions('', 'en-us')).toBeNull(); - expect(parseWorkflowOptions(false, 'en-us')).toBeNull(); - }); - - it('returns options with both processContent and processPages true by default', () => { - const opts = parseWorkflowOptions(true, 'en-us'); - expect(opts).not.toBeNull(); - expect(opts!.processContent).toBe(true); - expect(opts!.processPages).toBe(true); - expect(opts!.locale).toBe('en-us'); - expect(opts!.operation).toBe(WorkflowOperationType.Publish); - }); - - it.each([ - ['publish', WorkflowOperationType.Publish, true, true], - ['unpublish', WorkflowOperationType.Unpublish, true, true], - ['approve', WorkflowOperationType.Approve, true, true], - ['decline', WorkflowOperationType.Decline, true, true], - ['requestapproval', WorkflowOperationType.RequestApproval, true, true], - ['request-approval', WorkflowOperationType.RequestApproval, true, true], - ['request_approval', WorkflowOperationType.RequestApproval, true, true], - ])('parses string "%s" to correct operation with both content and pages', (input, op, content, pages) => { - const opts = parseWorkflowOptions(input, 'en-us'); - expect(opts).not.toBeNull(); - expect(opts!.operation).toBe(op); - expect(opts!.processContent).toBe(content); - expect(opts!.processPages).toBe(pages); - }); - - it('sets processPages=false when operationType is "content"', () => { - const opts = parseWorkflowOptions('content', 'en-us'); - expect(opts).not.toBeNull(); - expect(opts!.processContent).toBe(true); - expect(opts!.processPages).toBe(false); - }); - - it('sets processContent=false when operationType is "pages"', () => { - const opts = parseWorkflowOptions('pages', 'en-us'); - expect(opts).not.toBeNull(); - expect(opts!.processContent).toBe(false); - expect(opts!.processPages).toBe(true); - }); - - it('accepts a direct WorkflowOperationType enum value', () => { - const opts = parseWorkflowOptions(WorkflowOperationType.Unpublish, 'fr-fr'); - expect(opts).not.toBeNull(); - expect(opts!.operation).toBe(WorkflowOperationType.Unpublish); - expect(opts!.locale).toBe('fr-fr'); - }); - - it('sets locale from the provided locale argument', () => { - const opts = parseWorkflowOptions('publish', 'de-de'); - expect(opts!.locale).toBe('de-de'); - }); - - it('handles "true" string as default publish-both', () => { - const opts = parseWorkflowOptions('true', 'en-us'); - expect(opts!.processContent).toBe(true); - expect(opts!.processPages).toBe(true); - expect(opts!.operation).toBe(WorkflowOperationType.Publish); - }); - - it('handles unrecognized string by defaulting to publish-both', () => { - const opts = parseWorkflowOptions('garbage', 'en-us'); - expect(opts!.processContent).toBe(true); - expect(opts!.processPages).toBe(true); - expect(opts!.operation).toBe(WorkflowOperationType.Publish); - }); +describe("parseWorkflowOptions", () => { + it("returns null when operationType is falsy", () => { + expect(parseWorkflowOptions("", "en-us")).toBeNull(); + expect(parseWorkflowOptions(false, "en-us")).toBeNull(); + }); + + it("returns options with both processContent and processPages true by default", () => { + const opts = parseWorkflowOptions(true, "en-us"); + expect(opts).not.toBeNull(); + expect(opts!.processContent).toBe(true); + expect(opts!.processPages).toBe(true); + expect(opts!.locale).toBe("en-us"); + expect(opts!.operation).toBe(WorkflowOperationType.Publish); + }); + + it.each([ + ["publish", WorkflowOperationType.Publish, true, true], + ["unpublish", WorkflowOperationType.Unpublish, true, true], + ["approve", WorkflowOperationType.Approve, true, true], + ["decline", WorkflowOperationType.Decline, true, true], + ["requestapproval", WorkflowOperationType.RequestApproval, true, true], + ["request-approval", WorkflowOperationType.RequestApproval, true, true], + ["request_approval", WorkflowOperationType.RequestApproval, true, true], + ])('parses string "%s" to correct operation with both content and pages', (input, op, content, pages) => { + const opts = parseWorkflowOptions(input, "en-us"); + expect(opts).not.toBeNull(); + expect(opts!.operation).toBe(op); + expect(opts!.processContent).toBe(content); + expect(opts!.processPages).toBe(pages); + }); + + it('sets processPages=false when operationType is "content"', () => { + const opts = parseWorkflowOptions("content", "en-us"); + expect(opts).not.toBeNull(); + expect(opts!.processContent).toBe(true); + expect(opts!.processPages).toBe(false); + }); + + it('sets processContent=false when operationType is "pages"', () => { + const opts = parseWorkflowOptions("pages", "en-us"); + expect(opts).not.toBeNull(); + expect(opts!.processContent).toBe(false); + expect(opts!.processPages).toBe(true); + }); + + it("accepts a direct WorkflowOperationType enum value", () => { + const opts = parseWorkflowOptions(WorkflowOperationType.Unpublish, "fr-fr"); + expect(opts).not.toBeNull(); + expect(opts!.operation).toBe(WorkflowOperationType.Unpublish); + expect(opts!.locale).toBe("fr-fr"); + }); + + it("sets locale from the provided locale argument", () => { + const opts = parseWorkflowOptions("publish", "de-de"); + expect(opts!.locale).toBe("de-de"); + }); + + it('handles "true" string as default publish-both', () => { + const opts = parseWorkflowOptions("true", "en-us"); + expect(opts!.processContent).toBe(true); + expect(opts!.processPages).toBe(true); + expect(opts!.operation).toBe(WorkflowOperationType.Publish); + }); + + it("handles unrecognized string by defaulting to publish-both", () => { + const opts = parseWorkflowOptions("garbage", "en-us"); + expect(opts!.processContent).toBe(true); + expect(opts!.processPages).toBe(true); + expect(opts!.operation).toBe(WorkflowOperationType.Publish); + }); }); diff --git a/src/lib/workflows/tests/workflow-orchestrator.test.ts b/src/lib/workflows/tests/workflow-orchestrator.test.ts index 7b0f2dc..387fbf1 100644 --- a/src/lib/workflows/tests/workflow-orchestrator.test.ts +++ b/src/lib/workflows/tests/workflow-orchestrator.test.ts @@ -1,208 +1,217 @@ -import { resetState } from 'core/state'; -import { WorkflowOperationType } from 'types/workflows'; -import { workflowOrchestrator } from '../workflow-orchestrator'; -import * as processBatchesModule from '../process-batches'; +import { resetState } from "core/state"; +import { WorkflowOperationType } from "types/workflows"; +import { workflowOrchestrator } from "../workflow-orchestrator"; +import * as processBatchesModule from "../process-batches"; beforeEach(() => { - resetState(); - jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); + resetState(); + jest.spyOn(console, "log").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); }); afterEach(() => { - jest.restoreAllMocks(); + jest.restoreAllMocks(); }); const defaultOptions = { - locale: 'en-us', - processContent: true, - processPages: true, - operation: WorkflowOperationType.Publish, + locale: "en-us", + processContent: true, + processPages: true, + operation: WorkflowOperationType.Publish, }; -function makeProcessResult(overrides: Partial = {}): processBatchesModule.BatchProcessingResult { - return { - total: 0, - processed: 0, - failed: 0, - batches: 0, - processedIds: [], - logLines: [], - ...overrides, - }; +function makeProcessResult( + overrides: Partial = {} +): processBatchesModule.BatchProcessingResult { + return { + total: 0, + processed: 0, + failed: 0, + batches: 0, + processedIds: [], + logLines: [], + ...overrides, + }; } // ─── workflowOrchestrator ───────────────────────────────────────────────────── -describe('workflowOrchestrator', () => { - describe('when no items are provided', () => { - it('returns success=true and zero counts', async () => { - jest.spyOn(processBatchesModule, 'processBatches').mockResolvedValue(makeProcessResult()); +describe("workflowOrchestrator", () => { + describe("when no items are provided", () => { + it("returns success=true and zero counts", async () => { + jest.spyOn(processBatchesModule, "processBatches").mockResolvedValue(makeProcessResult()); - const result = await workflowOrchestrator([], [], defaultOptions); + const result = await workflowOrchestrator([], [], defaultOptions); - expect(result.success).toBe(true); - expect(result.contentResults.total).toBe(0); - expect(result.pageResults.total).toBe(0); - expect(result.errors).toHaveLength(0); - }); - - it('logs a "No items to" message', async () => { - jest.spyOn(processBatchesModule, 'processBatches').mockResolvedValue(makeProcessResult()); - const logSpy = jest.spyOn(console, 'log'); - - await workflowOrchestrator([], [], defaultOptions); - - const calls = logSpy.mock.calls.map(args => args[0]); - const noItemsLogged = calls.some(c => typeof c === 'string' && c.includes('No items')); - expect(noItemsLogged).toBe(true); - }); + expect(result.success).toBe(true); + expect(result.contentResults.total).toBe(0); + expect(result.pageResults.total).toBe(0); + expect(result.errors).toHaveLength(0); }); - describe('when items are provided and succeed', () => { - it('returns success=true with processed counts from batch results', async () => { - jest.spyOn(processBatchesModule, 'processBatches') - .mockResolvedValueOnce(makeProcessResult({ total: 3, processed: 3, processedIds: [1, 2, 3] })) - .mockResolvedValueOnce(makeProcessResult({ total: 2, processed: 2, processedIds: [10, 11] })); + it('logs a "No items to" message', async () => { + jest.spyOn(processBatchesModule, "processBatches").mockResolvedValue(makeProcessResult()); + const logSpy = jest.spyOn(console, "log"); - const result = await workflowOrchestrator([1, 2, 3], [10, 11], defaultOptions); + await workflowOrchestrator([], [], defaultOptions); - expect(result.success).toBe(true); - expect(result.contentResults.processed).toBe(3); - expect(result.pageResults.processed).toBe(2); - }); + const calls = logSpy.mock.calls.map((args) => args[0]); + const noItemsLogged = calls.some((c) => typeof c === "string" && c.includes("No items")); + expect(noItemsLogged).toBe(true); + }); + }); - it('returns populated logLines collected from both content and page results', async () => { - jest.spyOn(processBatchesModule, 'processBatches') - .mockResolvedValueOnce(makeProcessResult({ total: 1, processed: 1, processedIds: [1], logLines: ['content-log'] })) - .mockResolvedValueOnce(makeProcessResult({ total: 1, processed: 1, processedIds: [2], logLines: ['page-log'] })); + describe("when items are provided and succeed", () => { + it("returns success=true with processed counts from batch results", async () => { + jest + .spyOn(processBatchesModule, "processBatches") + .mockResolvedValueOnce(makeProcessResult({ total: 3, processed: 3, processedIds: [1, 2, 3] })) + .mockResolvedValueOnce(makeProcessResult({ total: 2, processed: 2, processedIds: [10, 11] })); - const result = await workflowOrchestrator([1], [2], defaultOptions); + const result = await workflowOrchestrator([1, 2, 3], [10, 11], defaultOptions); - expect(result.logLines).toContain('content-log'); - expect(result.logLines).toContain('page-log'); - }); + expect(result.success).toBe(true); + expect(result.contentResults.processed).toBe(3); + expect(result.pageResults.processed).toBe(2); }); - describe('when processContent is false', () => { - it('skips calling processBatches for content', async () => { - const batchSpy = jest.spyOn(processBatchesModule, 'processBatches').mockResolvedValue( - makeProcessResult({ total: 1, processed: 1, processedIds: [10] }) - ); - - await workflowOrchestrator([1, 2], [10], { - ...defaultOptions, - processContent: false, - }); - - // Should only be called once (for pages) - expect(batchSpy).toHaveBeenCalledTimes(1); - expect(batchSpy).toHaveBeenCalledWith( - expect.any(Array), - 'pages', - expect.any(String), - expect.any(Number), - expect.any(Array) - ); - }); - - it('returns zero content counts when processContent is false', async () => { - jest.spyOn(processBatchesModule, 'processBatches').mockResolvedValue( - makeProcessResult({ total: 1, processed: 1, processedIds: [10] }) - ); + it("returns populated logLines collected from both content and page results", async () => { + jest + .spyOn(processBatchesModule, "processBatches") + .mockResolvedValueOnce( + makeProcessResult({ total: 1, processed: 1, processedIds: [1], logLines: ["content-log"] }) + ) + .mockResolvedValueOnce( + makeProcessResult({ total: 1, processed: 1, processedIds: [2], logLines: ["page-log"] }) + ); - const result = await workflowOrchestrator([1, 2], [10], { - ...defaultOptions, - processContent: false, - }); + const result = await workflowOrchestrator([1], [2], defaultOptions); - expect(result.contentResults.processed).toBe(0); - expect(result.contentResults.total).toBe(0); - }); + expect(result.logLines).toContain("content-log"); + expect(result.logLines).toContain("page-log"); + }); + }); + + describe("when processContent is false", () => { + it("skips calling processBatches for content", async () => { + const batchSpy = jest + .spyOn(processBatchesModule, "processBatches") + .mockResolvedValue(makeProcessResult({ total: 1, processed: 1, processedIds: [10] })); + + await workflowOrchestrator([1, 2], [10], { + ...defaultOptions, + processContent: false, + }); + + // Should only be called once (for pages) + expect(batchSpy).toHaveBeenCalledTimes(1); + expect(batchSpy).toHaveBeenCalledWith( + expect.any(Array), + "pages", + expect.any(String), + expect.any(Number), + expect.any(Array) + ); }); - describe('when processPages is false', () => { - it('skips calling processBatches for pages', async () => { - const batchSpy = jest.spyOn(processBatchesModule, 'processBatches').mockResolvedValue( - makeProcessResult({ total: 1, processed: 1, processedIds: [1] }) - ); - - await workflowOrchestrator([1], [10, 11], { - ...defaultOptions, - processPages: false, - }); - - expect(batchSpy).toHaveBeenCalledTimes(1); - expect(batchSpy).toHaveBeenCalledWith( - expect.any(Array), - 'content', - expect.any(String), - expect.any(Number), - expect.any(Array) - ); - }); - - it('returns zero page counts when processPages is false', async () => { - jest.spyOn(processBatchesModule, 'processBatches').mockResolvedValue( - makeProcessResult({ total: 1, processed: 1, processedIds: [1] }) - ); + it("returns zero content counts when processContent is false", async () => { + jest + .spyOn(processBatchesModule, "processBatches") + .mockResolvedValue(makeProcessResult({ total: 1, processed: 1, processedIds: [10] })); - const result = await workflowOrchestrator([1], [10, 11], { - ...defaultOptions, - processPages: false, - }); + const result = await workflowOrchestrator([1, 2], [10], { + ...defaultOptions, + processContent: false, + }); - expect(result.pageResults.processed).toBe(0); - expect(result.pageResults.total).toBe(0); - }); + expect(result.contentResults.processed).toBe(0); + expect(result.contentResults.total).toBe(0); + }); + }); + + describe("when processPages is false", () => { + it("skips calling processBatches for pages", async () => { + const batchSpy = jest + .spyOn(processBatchesModule, "processBatches") + .mockResolvedValue(makeProcessResult({ total: 1, processed: 1, processedIds: [1] })); + + await workflowOrchestrator([1], [10, 11], { + ...defaultOptions, + processPages: false, + }); + + expect(batchSpy).toHaveBeenCalledTimes(1); + expect(batchSpy).toHaveBeenCalledWith( + expect.any(Array), + "content", + expect.any(String), + expect.any(Number), + expect.any(Array) + ); }); - describe('when batches partially fail', () => { - it('returns success=false when errors accumulate', async () => { - jest.spyOn(processBatchesModule, 'processBatches').mockImplementation( - async (_ids, _type, _locale, _operation, errors) => { - errors.push('Batch failed'); - return makeProcessResult({ total: 1, processed: 0, failed: 1 }); - } - ); + it("returns zero page counts when processPages is false", async () => { + jest + .spyOn(processBatchesModule, "processBatches") + .mockResolvedValue(makeProcessResult({ total: 1, processed: 1, processedIds: [1] })); - const result = await workflowOrchestrator([1], [2], defaultOptions); + const result = await workflowOrchestrator([1], [10, 11], { + ...defaultOptions, + processPages: false, + }); - expect(result.success).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); + expect(result.pageResults.processed).toBe(0); + expect(result.pageResults.total).toBe(0); }); + }); + + describe("when batches partially fail", () => { + it("returns success=false when errors accumulate", async () => { + jest + .spyOn(processBatchesModule, "processBatches") + .mockImplementation(async (_ids, _type, _locale, _operation, errors) => { + errors.push("Batch failed"); + return makeProcessResult({ total: 1, processed: 0, failed: 1 }); + }); - describe('summary logging with nested items', () => { - it('logs nested item count when processed > total', async () => { - // API processed more than requested (nested content) - jest.spyOn(processBatchesModule, 'processBatches') - .mockResolvedValueOnce(makeProcessResult({ total: 2, processed: 5, processedIds: [1, 2, 3, 4, 5] })) - .mockResolvedValueOnce(makeProcessResult()); - const logSpy = jest.spyOn(console, 'log'); - - await workflowOrchestrator([1, 2], [], defaultOptions); + const result = await workflowOrchestrator([1], [2], defaultOptions); - const calls = logSpy.mock.calls.map(args => args[0]); - const nestedLogged = calls.some(c => typeof c === 'string' && c.includes('nested')); - expect(nestedLogged).toBe(true); - }); + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); }); + }); + + describe("summary logging with nested items", () => { + it("logs nested item count when processed > total", async () => { + // API processed more than requested (nested content) + jest + .spyOn(processBatchesModule, "processBatches") + .mockResolvedValueOnce(makeProcessResult({ total: 2, processed: 5, processedIds: [1, 2, 3, 4, 5] })) + .mockResolvedValueOnce(makeProcessResult()); + const logSpy = jest.spyOn(console, "log"); + + await workflowOrchestrator([1, 2], [], defaultOptions); + + const calls = logSpy.mock.calls.map((args) => args[0]); + const nestedLogged = calls.some((c) => typeof c === "string" && c.includes("nested")); + expect(nestedLogged).toBe(true); + }); + }); + + describe("error accumulation", () => { + it("collects errors from both content and page batch processing", async () => { + jest + .spyOn(processBatchesModule, "processBatches") + .mockImplementation(async (_ids, type, _locale, _operation, errors) => { + errors.push(`${type} batch error`); + return makeProcessResult({ total: 1, processed: 0, failed: 1 }); + }); - describe('error accumulation', () => { - it('collects errors from both content and page batch processing', async () => { - jest.spyOn(processBatchesModule, 'processBatches').mockImplementation( - async (_ids, type, _locale, _operation, errors) => { - errors.push(`${type} batch error`); - return makeProcessResult({ total: 1, processed: 0, failed: 1 }); - } - ); - - const result = await workflowOrchestrator([1], [2], defaultOptions); + const result = await workflowOrchestrator([1], [2], defaultOptions); - expect(result.errors).toContain('content batch error'); - expect(result.errors).toContain('pages batch error'); - }); + expect(result.errors).toContain("content batch error"); + expect(result.errors).toContain("pages batch error"); }); + }); }); diff --git a/src/lib/workflows/workflow-helpers.ts b/src/lib/workflows/workflow-helpers.ts index fd8000f..4bfadf0 100644 --- a/src/lib/workflows/workflow-helpers.ts +++ b/src/lib/workflows/workflow-helpers.ts @@ -1,67 +1,67 @@ /** * Workflow Helper Functions - * + * * Utility functions for workflow operations - operation names, verbs, icons. */ -import { WorkflowOperationType } from '../../types'; +import { WorkflowOperationType } from "../../types"; /** * Get human-readable operation name */ export function getOperationName(operation: WorkflowOperationType): string { - switch (operation) { - case WorkflowOperationType.Publish: - return 'publish'; - case WorkflowOperationType.Unpublish: - return 'unpublish'; - case WorkflowOperationType.Approve: - return 'approve'; - case WorkflowOperationType.Decline: - return 'decline'; - case WorkflowOperationType.RequestApproval: - return 'request approval'; - default: - return 'process'; - } + switch (operation) { + case WorkflowOperationType.Publish: + return "publish"; + case WorkflowOperationType.Unpublish: + return "unpublish"; + case WorkflowOperationType.Approve: + return "approve"; + case WorkflowOperationType.Decline: + return "decline"; + case WorkflowOperationType.RequestApproval: + return "request approval"; + default: + return "process"; + } } /** * Get operation verb for logging (past tense) */ export function getOperationVerb(operation: WorkflowOperationType): string { - switch (operation) { - case WorkflowOperationType.Publish: - return 'published'; - case WorkflowOperationType.Unpublish: - return 'unpublished'; - case WorkflowOperationType.Approve: - return 'approved'; - case WorkflowOperationType.Decline: - return 'declined'; - case WorkflowOperationType.RequestApproval: - return 'submitted for approval'; - default: - return 'processed'; - } + switch (operation) { + case WorkflowOperationType.Publish: + return "published"; + case WorkflowOperationType.Unpublish: + return "unpublished"; + case WorkflowOperationType.Approve: + return "approved"; + case WorkflowOperationType.Decline: + return "declined"; + case WorkflowOperationType.RequestApproval: + return "submitted for approval"; + default: + return "processed"; + } } /** * Get operation icon for logging */ export function getOperationIcon(operation: WorkflowOperationType): string { - switch (operation) { - case WorkflowOperationType.Publish: - return '📤'; - case WorkflowOperationType.Unpublish: - return '📥'; - case WorkflowOperationType.Approve: - return '✅'; - case WorkflowOperationType.Decline: - return '❌'; - case WorkflowOperationType.RequestApproval: - return '📝'; - default: - return '⚙️'; - } + switch (operation) { + case WorkflowOperationType.Publish: + return "📤"; + case WorkflowOperationType.Unpublish: + return "📥"; + case WorkflowOperationType.Approve: + return "✅"; + case WorkflowOperationType.Decline: + return "❌"; + case WorkflowOperationType.RequestApproval: + return "📝"; + default: + return "⚙️"; + } } diff --git a/src/lib/workflows/workflow-operation.ts b/src/lib/workflows/workflow-operation.ts index e7d15f8..1f192bb 100644 --- a/src/lib/workflows/workflow-operation.ts +++ b/src/lib/workflows/workflow-operation.ts @@ -1,334 +1,366 @@ /** * Workflow Operation Core Module - * + * * Standalone module that reads mappings from the filesystem and performs * workflow operations (publish, unpublish, approve, decline, requestApproval) * on content and pages in the target instance. */ -import ansiColors from 'ansi-colors'; -import { state, initializeLogger, finalizeLogger, getLogger } from '../../core/state'; -import { readMappingsForGuidPair, getMappingSummary } from '../mappers/mapping-reader'; -import { parseWorkflowOptions, parseOperationType } from './workflow-options'; -import { getOperationName } from './workflow-helpers'; -import { workflowOrchestrator } from './workflow-orchestrator'; -import { listMappings } from './list-mappings'; -import { refreshAndUpdateMappings } from './refresh-mappings'; -import { checkSourcePublishStatus } from '../shared/source-publish-status-checker'; -import { WorkflowOperationResult, WorkflowOperationType } from '../../types'; +import ansiColors from "ansi-colors"; +import { state, initializeLogger, finalizeLogger, getLogger } from "../../core/state"; +import { readMappingsForGuidPair, getMappingSummary } from "../mappers/mapping-reader"; +import { parseWorkflowOptions, parseOperationType } from "./workflow-options"; +import { getOperationName } from "./workflow-helpers"; +import { workflowOrchestrator } from "./workflow-orchestrator"; +import { listMappings } from "./list-mappings"; +import { refreshAndUpdateMappings } from "./refresh-mappings"; +import { checkSourcePublishStatus } from "../shared/source-publish-status-checker"; +import { WorkflowOperationResult, WorkflowOperationType } from "../../types"; // Re-export type for convenience export { WorkflowOperationResult }; export class WorkflowOperation { - /** - * Execute workflow operation from mapping files - */ - async executeFromMappings(): Promise { - const startTime = Date.now(); - - // Initialize logger - initializeLogger('push'); - const logger = getLogger(); - - // Get operation type from state - const operationType = parseOperationType(state.operationType); - const operationName = getOperationName(operationType); - - const result: WorkflowOperationResult = { - success: true, - contentProcessed: 0, - contentFailed: 0, - pagesProcessed: 0, - pagesFailed: 0, - elapsedTime: 0, - errors: [], - operation: operationName - }; - - try { - const { sourceGuid, targetGuid, locale: locales } = state; - - // Validate required parameters - if (!sourceGuid || sourceGuid.length === 0) { - throw new Error('Source GUID is required. Use --sourceGuid flag.'); - } - if (!targetGuid || targetGuid.length === 0) { - throw new Error('Target GUID is required. Use --targetGuid flag.'); - } - if (!locales || locales.length === 0) { - throw new Error('At least one locale is required. Use --locale flag.'); - } - - const source = sourceGuid[0]; - const target = targetGuid[0]; - const primaryLocale = locales[0]; - - console.log(ansiColors.cyan('\n' + '═'.repeat(50))); - console.log(ansiColors.cyan(`📦 WORKFLOW OPERATION: ${operationName.toUpperCase()}`)); - console.log(ansiColors.cyan('═'.repeat(50))); - console.log(ansiColors.gray(`Source: ${source}`)); - console.log(ansiColors.gray(`Target: ${target}`)); - console.log(ansiColors.gray(`Locales: ${locales.join(', ')}`)); - console.log(ansiColors.gray(`Operation: ${operationName}`)); - - // Get mapping summary - const summary = getMappingSummary(source, target, locales); - // Check if explicit IDs are provided (bypasses mappings lookup) - const hasExplicitContentIDs = state.explicitContentIDs && state.explicitContentIDs.length > 0; - const hasExplicitPageIDs = state.explicitPageIDs && state.explicitPageIDs.length > 0; - const useExplicitIDs = hasExplicitContentIDs || hasExplicitPageIDs; - - // Parse workflow options - process both content and pages by default - const options = parseWorkflowOptions(true, primaryLocale); - if (!options) { - throw new Error('Failed to parse workflow options'); - } - - // Override with the actual operation type from state - options.operation = operationType; - - let contentIds: number[]; - let pageIds: number[]; - - if (useExplicitIDs) { - // Explicit IDs mode - bypass mappings lookup but still check source publish status - console.log(ansiColors.cyan('\n🔧 Using explicit IDs (bypassing mappings lookup)')); - - let explicitContentIds = hasExplicitContentIDs ? state.explicitContentIDs : []; - let explicitPageIds = hasExplicitPageIDs ? state.explicitPageIDs : []; - - console.log(ansiColors.gray(` Explicit content IDs: ${explicitContentIds.length > 0 ? explicitContentIds.join(', ') : '(none)'}`)); - console.log(ansiColors.gray(` Explicit page IDs: ${explicitPageIds.length > 0 ? explicitPageIds.join(', ') : '(none)'}`)); - - if (explicitContentIds.length === 0 && explicitPageIds.length === 0) { - console.log(ansiColors.yellow('\n⚠️ No valid IDs provided.')); - result.elapsedTime = Date.now() - startTime; - return result; - } - - // For publish operations, check source publish status even with explicit IDs - if (operationType === WorkflowOperationType.Publish) { - console.log(ansiColors.cyan('\nChecking source instance publish status for explicit IDs...')); - - // Read mappings to get source→target relationships (needed for reverse lookup) - const mappingResult = readMappingsForGuidPair(source, target, locales); - - // Create reverse lookup maps (target ID → source mapping) - const targetToSourceContent = new Map(); - const targetToSourcePage = new Map(); - - for (const mapping of mappingResult.contentMappings) { - targetToSourceContent.set(mapping.targetContentID, mapping); - } - for (const mapping of mappingResult.pageMappings) { - targetToSourcePage.set(mapping.targetPageID, mapping); - } - - // Filter explicit IDs to only those that have mappings - const contentMappingsForExplicit = explicitContentIds - .filter(id => targetToSourceContent.has(id)) - .map(id => targetToSourceContent.get(id)); - const pageMappingsForExplicit = explicitPageIds - .filter(id => targetToSourcePage.has(id)) - .map(id => targetToSourcePage.get(id)); - - // Check source publish status - const publishStatus = checkSourcePublishStatus( - contentMappingsForExplicit, - pageMappingsForExplicit, - source, - locales - ); - - // Report filtering results - const contentPublishedInSource = publishStatus.publishedContentIds.length; - const pagesPublishedInSource = publishStatus.publishedPageIds.length; - const contentSkipped = publishStatus.unpublishedContentIds.length; - const pagesSkipped = publishStatus.unpublishedPageIds.length; - - console.log(ansiColors.gray(`Content: ${contentPublishedInSource}/${explicitContentIds.length} published in source (${contentSkipped} staging/unpublished skipped)`)); - console.log(ansiColors.gray(`Pages: ${pagesPublishedInSource}/${explicitPageIds.length} published in source (${pagesSkipped} staging/unpublished skipped)`)); - - // Use only IDs that are published in source - contentIds = publishStatus.publishedContentIds; - pageIds = publishStatus.publishedPageIds; - } else { - // For non-publish operations, use all explicit IDs - contentIds = explicitContentIds; - pageIds = explicitPageIds; - } - } else { - // Standard mode - use mappings files - console.log(ansiColors.gray(`\nMapping Summary:`)); - console.log(ansiColors.gray(`Content items: ${summary.totalContent}`)); - console.log(ansiColors.gray(`Pages: ${summary.totalPages}`)); - console.log(ansiColors.gray(`Locales with data: ${summary.localesFound.join(', ') || 'none'}`)); - - if (summary.totalContent === 0 && summary.totalPages === 0) { - console.log(ansiColors.yellow('\n⚠️ No mappings found to process.')); - console.log(ansiColors.gray(' Run a sync operation first to create mappings, or use --contentIDs/--pageIDs to specify IDs directly.')); - result.elapsedTime = Date.now() - startTime; - return result; - } - - // Read mappings - const mappingResult = readMappingsForGuidPair(source, target, locales); - - if (mappingResult.errors.length > 0) { - console.log(ansiColors.yellow('\nWarnings during mapping read:')); - mappingResult.errors.forEach(err => console.log(ansiColors.yellow(` - ${err}`))); - } - - // For publish operations, check source publish status to filter only published items - contentIds = mappingResult.contentIds; - pageIds = mappingResult.pageIds; - - if (operationType === WorkflowOperationType.Publish) { - console.log(ansiColors.cyan('\nChecking source instance publish status...')); - const publishStatus = checkSourcePublishStatus( - mappingResult.contentMappings, - mappingResult.pageMappings, - source, - locales - ); - - // Report status check warnings - if (publishStatus.errors.length > 0) { - console.log(ansiColors.yellow(`${publishStatus.errors.length} items not found in source files (will be included)`)); - } - - // Report filtering results - const totalContentMapped = mappingResult.contentIds.length; - const totalPagesMapped = mappingResult.pageIds.length; - const contentPublishedInSource = publishStatus.publishedContentIds.length; - const pagesPublishedInSource = publishStatus.publishedPageIds.length; - const contentSkipped = publishStatus.unpublishedContentIds.length; - const pagesSkipped = publishStatus.unpublishedPageIds.length; - - console.log(ansiColors.gray(`Content: ${contentPublishedInSource}/${totalContentMapped} published in source (${contentSkipped} staging/unpublished skipped)`)); - console.log(ansiColors.gray(`Pages: ${pagesPublishedInSource}/${totalPagesMapped} published in source (${pagesSkipped} staging/unpublished skipped)`)); - - // Filter IDs based on publish mode AND source publish status - contentIds = options.processContent ? publishStatus.publishedContentIds : []; - pageIds = options.processPages ? publishStatus.publishedPageIds : []; - } else { - // For non-publish operations, use all mapped IDs - contentIds = options.processContent ? mappingResult.contentIds : []; - pageIds = options.processPages ? mappingResult.pageIds : []; - } - } - - const modeDescription = options.processContent && options.processPages - ? 'content and pages' - : options.processContent - ? 'content only' - : 'pages only'; - - console.log(ansiColors.cyan(`\n${operationName.charAt(0).toUpperCase() + operationName.slice(1)}ing ${modeDescription}...`)); - console.log(ansiColors.gray(`Content items to ${operationName}: ${contentIds.length}`)); - console.log(ansiColors.gray(`Pages to ${operationName}: ${pageIds.length}`)); - - // DRY RUN: Show preview and exit without executing - if (state.dryRun) { - console.log(ansiColors.yellow('\n' + '═'.repeat(50))); - console.log(ansiColors.yellow(`🔍 DRY RUN PREVIEW - ${operationName.toUpperCase()}`)); - console.log(ansiColors.yellow('═'.repeat(50))); - console.log(ansiColors.gray('\nThe following items would be processed:')); - - if (contentIds.length > 0) { - console.log(ansiColors.cyan(`\n📄 Content Items (${contentIds.length}):`)); - const displayContentIds = contentIds.slice(0, 20); - displayContentIds.forEach(id => console.log(ansiColors.white(` • ID: ${id}`))); - if (contentIds.length > 20) { - console.log(ansiColors.gray(` ... and ${contentIds.length - 20} more content items`)); - } - } - - if (pageIds.length > 0) { - console.log(ansiColors.cyan(`\n📑 Pages (${pageIds.length}):`)); - const displayPageIds = pageIds.slice(0, 20); - displayPageIds.forEach(id => console.log(ansiColors.white(` • ID: ${id}`))); - if (pageIds.length > 20) { - console.log(ansiColors.gray(` ... and ${pageIds.length - 20} more pages`)); - } - } - - console.log(ansiColors.yellow('\n' + '─'.repeat(50))); - console.log(ansiColors.yellow('⚠️ DRY RUN COMPLETE - No changes were made')); - console.log(ansiColors.gray(`Remove --dryRun flag to execute the ${operationName} operation`)); - console.log(ansiColors.yellow('─'.repeat(50))); - - result.contentProcessed = contentIds.length; - result.pagesProcessed = pageIds.length; - result.elapsedTime = Date.now() - startTime; - finalizeLogger(); - return result; - } - - // Execute workflow operation - console.log(ansiColors.cyan('\n' + '─'.repeat(50))); - console.log(ansiColors.cyan(`🚀 ${operationName.toUpperCase()} PHASE (${modeDescription})`)); - console.log(ansiColors.cyan('─'.repeat(50))); - - const workflowResult = await workflowOrchestrator(contentIds, pageIds, options); - - // Update results - result.contentProcessed = workflowResult.contentResults.processed; - result.contentFailed = workflowResult.contentResults.failed; - result.pagesProcessed = workflowResult.pageResults.processed; - result.pagesFailed = workflowResult.pageResults.failed; - result.success = workflowResult.success; - result.errors = workflowResult.errors; - - // If items were published, refresh target instance data and update mappings - if (operationType === WorkflowOperationType.Publish && - (workflowResult.contentResults.processed > 0 || workflowResult.pageResults.processed > 0)) { - await refreshAndUpdateMappings( - workflowResult.contentResults.processedIds, - workflowResult.pageResults.processedIds, - source, - target, - primaryLocale, - workflowResult.logLines // Pass publish log lines to include in log file - ); - } - - // Final summary - result.elapsedTime = Date.now() - startTime; - const totalProcessed = result.contentProcessed + result.pagesProcessed; - const totalFailed = result.contentFailed + result.pagesFailed; - const totalSeconds = Math.floor(result.elapsedTime / 1000); - - console.log(ansiColors.cyan('\n' + '═'.repeat(50))); - console.log(ansiColors.cyan(`📊 ${operationName.toUpperCase()} COMPLETE`)); - console.log(ansiColors.cyan('═'.repeat(50))); - console.log(ansiColors.green(`✓ Processed: ${totalProcessed} items`)); - if (totalFailed > 0) { - console.log(ansiColors.red(`✗ Failed: ${totalFailed} items`)); - } - console.log(ansiColors.gray(`Total time: ${Math.floor(totalSeconds / 60)}m ${totalSeconds % 60}s`)); - - if (result.errors.length > 0) { - console.log(ansiColors.yellow('\nErrors encountered:')); - result.errors.forEach(err => console.log(ansiColors.red(` - ${err}`))); - } - - } catch (error: any) { - result.success = false; - result.errors.push(error.message); - console.error(ansiColors.red(`\n❌ Workflow operation failed: ${error.message}`)); + /** + * Execute workflow operation from mapping files + */ + async executeFromMappings(): Promise { + const startTime = Date.now(); + + // Initialize logger + initializeLogger("push"); + const logger = getLogger(); + + // Get operation type from state + const operationType = parseOperationType(state.operationType); + const operationName = getOperationName(operationType); + + const result: WorkflowOperationResult = { + success: true, + contentProcessed: 0, + contentFailed: 0, + pagesProcessed: 0, + pagesFailed: 0, + elapsedTime: 0, + errors: [], + operation: operationName, + }; + + try { + const { sourceGuid, targetGuid, locale: locales } = state; + + // Validate required parameters + if (!sourceGuid || sourceGuid.length === 0) { + throw new Error("Source GUID is required. Use --sourceGuid flag."); + } + if (!targetGuid || targetGuid.length === 0) { + throw new Error("Target GUID is required. Use --targetGuid flag."); + } + if (!locales || locales.length === 0) { + throw new Error("At least one locale is required. Use --locale flag."); + } + + const source = sourceGuid[0]; + const target = targetGuid[0]; + const primaryLocale = locales[0]; + + console.log(ansiColors.cyan("\n" + "═".repeat(50))); + console.log(ansiColors.cyan(`📦 WORKFLOW OPERATION: ${operationName.toUpperCase()}`)); + console.log(ansiColors.cyan("═".repeat(50))); + console.log(ansiColors.gray(`Source: ${source}`)); + console.log(ansiColors.gray(`Target: ${target}`)); + console.log(ansiColors.gray(`Locales: ${locales.join(", ")}`)); + console.log(ansiColors.gray(`Operation: ${operationName}`)); + + // Get mapping summary + const summary = getMappingSummary(source, target, locales); + // Check if explicit IDs are provided (bypasses mappings lookup) + const hasExplicitContentIDs = state.explicitContentIDs && state.explicitContentIDs.length > 0; + const hasExplicitPageIDs = state.explicitPageIDs && state.explicitPageIDs.length > 0; + const useExplicitIDs = hasExplicitContentIDs || hasExplicitPageIDs; + + // Parse workflow options - process both content and pages by default + const options = parseWorkflowOptions(true, primaryLocale); + if (!options) { + throw new Error("Failed to parse workflow options"); + } + + // Override with the actual operation type from state + options.operation = operationType; + + let contentIds: number[]; + let pageIds: number[]; + + if (useExplicitIDs) { + // Explicit IDs mode - bypass mappings lookup but still check source publish status + console.log(ansiColors.cyan("\n🔧 Using explicit IDs (bypassing mappings lookup)")); + + let explicitContentIds = hasExplicitContentIDs ? state.explicitContentIDs : []; + let explicitPageIds = hasExplicitPageIDs ? state.explicitPageIDs : []; + + console.log( + ansiColors.gray( + ` Explicit content IDs: ${explicitContentIds.length > 0 ? explicitContentIds.join(", ") : "(none)"}` + ) + ); + console.log( + ansiColors.gray(` Explicit page IDs: ${explicitPageIds.length > 0 ? explicitPageIds.join(", ") : "(none)"}`) + ); + + if (explicitContentIds.length === 0 && explicitPageIds.length === 0) { + console.log(ansiColors.yellow("\n⚠️ No valid IDs provided.")); + result.elapsedTime = Date.now() - startTime; + return result; } - finalizeLogger(); + // For publish operations, check source publish status even with explicit IDs + if (operationType === WorkflowOperationType.Publish) { + console.log(ansiColors.cyan("\nChecking source instance publish status for explicit IDs...")); + + // Read mappings to get source→target relationships (needed for reverse lookup) + const mappingResult = readMappingsForGuidPair(source, target, locales); + + // Create reverse lookup maps (target ID → source mapping) + const targetToSourceContent = new Map(); + const targetToSourcePage = new Map(); + + for (const mapping of mappingResult.contentMappings) { + targetToSourceContent.set(mapping.targetContentID, mapping); + } + for (const mapping of mappingResult.pageMappings) { + targetToSourcePage.set(mapping.targetPageID, mapping); + } + + // Filter explicit IDs to only those that have mappings + const contentMappingsForExplicit = explicitContentIds + .filter((id) => targetToSourceContent.has(id)) + .map((id) => targetToSourceContent.get(id)); + const pageMappingsForExplicit = explicitPageIds + .filter((id) => targetToSourcePage.has(id)) + .map((id) => targetToSourcePage.get(id)); + + // Check source publish status + const publishStatus = checkSourcePublishStatus( + contentMappingsForExplicit, + pageMappingsForExplicit, + source, + locales + ); + + // Report filtering results + const contentPublishedInSource = publishStatus.publishedContentIds.length; + const pagesPublishedInSource = publishStatus.publishedPageIds.length; + const contentSkipped = publishStatus.unpublishedContentIds.length; + const pagesSkipped = publishStatus.unpublishedPageIds.length; + + console.log( + ansiColors.gray( + `Content: ${contentPublishedInSource}/${explicitContentIds.length} published in source (${contentSkipped} staging/unpublished skipped)` + ) + ); + console.log( + ansiColors.gray( + `Pages: ${pagesPublishedInSource}/${explicitPageIds.length} published in source (${pagesSkipped} staging/unpublished skipped)` + ) + ); + + // Use only IDs that are published in source + contentIds = publishStatus.publishedContentIds; + pageIds = publishStatus.publishedPageIds; + } else { + // For non-publish operations, use all explicit IDs + contentIds = explicitContentIds; + pageIds = explicitPageIds; + } + } else { + // Standard mode - use mappings files + console.log(ansiColors.gray(`\nMapping Summary:`)); + console.log(ansiColors.gray(`Content items: ${summary.totalContent}`)); + console.log(ansiColors.gray(`Pages: ${summary.totalPages}`)); + console.log(ansiColors.gray(`Locales with data: ${summary.localesFound.join(", ") || "none"}`)); + + if (summary.totalContent === 0 && summary.totalPages === 0) { + console.log(ansiColors.yellow("\n⚠️ No mappings found to process.")); + console.log( + ansiColors.gray( + " Run a sync operation first to create mappings, or use --contentIDs/--pageIDs to specify IDs directly." + ) + ); + result.elapsedTime = Date.now() - startTime; + return result; + } + + // Read mappings + const mappingResult = readMappingsForGuidPair(source, target, locales); + + if (mappingResult.errors.length > 0) { + console.log(ansiColors.yellow("\nWarnings during mapping read:")); + mappingResult.errors.forEach((err) => console.log(ansiColors.yellow(` - ${err}`))); + } + + // For publish operations, check source publish status to filter only published items + contentIds = mappingResult.contentIds; + pageIds = mappingResult.pageIds; + + if (operationType === WorkflowOperationType.Publish) { + console.log(ansiColors.cyan("\nChecking source instance publish status...")); + const publishStatus = checkSourcePublishStatus( + mappingResult.contentMappings, + mappingResult.pageMappings, + source, + locales + ); + + // Report status check warnings + if (publishStatus.errors.length > 0) { + console.log( + ansiColors.yellow(`${publishStatus.errors.length} items not found in source files (will be included)`) + ); + } + + // Report filtering results + const totalContentMapped = mappingResult.contentIds.length; + const totalPagesMapped = mappingResult.pageIds.length; + const contentPublishedInSource = publishStatus.publishedContentIds.length; + const pagesPublishedInSource = publishStatus.publishedPageIds.length; + const contentSkipped = publishStatus.unpublishedContentIds.length; + const pagesSkipped = publishStatus.unpublishedPageIds.length; + + console.log( + ansiColors.gray( + `Content: ${contentPublishedInSource}/${totalContentMapped} published in source (${contentSkipped} staging/unpublished skipped)` + ) + ); + console.log( + ansiColors.gray( + `Pages: ${pagesPublishedInSource}/${totalPagesMapped} published in source (${pagesSkipped} staging/unpublished skipped)` + ) + ); + + // Filter IDs based on publish mode AND source publish status + contentIds = options.processContent ? publishStatus.publishedContentIds : []; + pageIds = options.processPages ? publishStatus.publishedPageIds : []; + } else { + // For non-publish operations, use all mapped IDs + contentIds = options.processContent ? mappingResult.contentIds : []; + pageIds = options.processPages ? mappingResult.pageIds : []; + } + } + + const modeDescription = + options.processContent && options.processPages + ? "content and pages" + : options.processContent + ? "content only" + : "pages only"; + + console.log( + ansiColors.cyan(`\n${operationName.charAt(0).toUpperCase() + operationName.slice(1)}ing ${modeDescription}...`) + ); + console.log(ansiColors.gray(`Content items to ${operationName}: ${contentIds.length}`)); + console.log(ansiColors.gray(`Pages to ${operationName}: ${pageIds.length}`)); + + // DRY RUN: Show preview and exit without executing + if (state.dryRun) { + console.log(ansiColors.yellow("\n" + "═".repeat(50))); + console.log(ansiColors.yellow(`🔍 DRY RUN PREVIEW - ${operationName.toUpperCase()}`)); + console.log(ansiColors.yellow("═".repeat(50))); + console.log(ansiColors.gray("\nThe following items would be processed:")); + + if (contentIds.length > 0) { + console.log(ansiColors.cyan(`\n📄 Content Items (${contentIds.length}):`)); + const displayContentIds = contentIds.slice(0, 20); + displayContentIds.forEach((id) => console.log(ansiColors.white(` • ID: ${id}`))); + if (contentIds.length > 20) { + console.log(ansiColors.gray(` ... and ${contentIds.length - 20} more content items`)); + } + } + + if (pageIds.length > 0) { + console.log(ansiColors.cyan(`\n📑 Pages (${pageIds.length}):`)); + const displayPageIds = pageIds.slice(0, 20); + displayPageIds.forEach((id) => console.log(ansiColors.white(` • ID: ${id}`))); + if (pageIds.length > 20) { + console.log(ansiColors.gray(` ... and ${pageIds.length - 20} more pages`)); + } + } + + console.log(ansiColors.yellow("\n" + "─".repeat(50))); + console.log(ansiColors.yellow("⚠️ DRY RUN COMPLETE - No changes were made")); + console.log(ansiColors.gray(`Remove --dryRun flag to execute the ${operationName} operation`)); + console.log(ansiColors.yellow("─".repeat(50))); + + result.contentProcessed = contentIds.length; + result.pagesProcessed = pageIds.length; result.elapsedTime = Date.now() - startTime; + finalizeLogger(); return result; + } + + // Execute workflow operation + console.log(ansiColors.cyan("\n" + "─".repeat(50))); + console.log(ansiColors.cyan(`🚀 ${operationName.toUpperCase()} PHASE (${modeDescription})`)); + console.log(ansiColors.cyan("─".repeat(50))); + + const workflowResult = await workflowOrchestrator(contentIds, pageIds, options); + + // Update results + result.contentProcessed = workflowResult.contentResults.processed; + result.contentFailed = workflowResult.contentResults.failed; + result.pagesProcessed = workflowResult.pageResults.processed; + result.pagesFailed = workflowResult.pageResults.failed; + result.success = workflowResult.success; + result.errors = workflowResult.errors; + + // If items were published, refresh target instance data and update mappings + if ( + operationType === WorkflowOperationType.Publish && + (workflowResult.contentResults.processed > 0 || workflowResult.pageResults.processed > 0) + ) { + await refreshAndUpdateMappings( + workflowResult.contentResults.processedIds, + workflowResult.pageResults.processedIds, + source, + target, + primaryLocale, + workflowResult.logLines // Pass publish log lines to include in log file + ); + } + + // Final summary + result.elapsedTime = Date.now() - startTime; + const totalProcessed = result.contentProcessed + result.pagesProcessed; + const totalFailed = result.contentFailed + result.pagesFailed; + const totalSeconds = Math.floor(result.elapsedTime / 1000); + + console.log(ansiColors.cyan("\n" + "═".repeat(50))); + console.log(ansiColors.cyan(`📊 ${operationName.toUpperCase()} COMPLETE`)); + console.log(ansiColors.cyan("═".repeat(50))); + console.log(ansiColors.green(`✓ Processed: ${totalProcessed} items`)); + if (totalFailed > 0) { + console.log(ansiColors.red(`✗ Failed: ${totalFailed} items`)); + } + console.log(ansiColors.gray(`Total time: ${Math.floor(totalSeconds / 60)}m ${totalSeconds % 60}s`)); + + if (result.errors.length > 0) { + console.log(ansiColors.yellow("\nErrors encountered:")); + result.errors.forEach((err) => console.log(ansiColors.red(` - ${err}`))); + } + } catch (error: any) { + result.success = false; + result.errors.push(error.message); + console.error(ansiColors.red(`\n❌ Workflow operation failed: ${error.message}`)); } - /** - * List available mapping pairs for workflow operations - */ - listMappings(): void { - listMappings(); - } + finalizeLogger(); + result.elapsedTime = Date.now() - startTime; + return result; + } + + /** + * List available mapping pairs for workflow operations + */ + listMappings(): void { + listMappings(); + } } diff --git a/src/lib/workflows/workflow-options.ts b/src/lib/workflows/workflow-options.ts index e8903c3..e2352e4 100644 --- a/src/lib/workflows/workflow-options.ts +++ b/src/lib/workflows/workflow-options.ts @@ -1,89 +1,89 @@ /** * Workflow Options Parsing - * + * * Functions for parsing and converting workflow operation options. */ -import { WorkflowOperationType, WorkflowOptions } from '../../types'; +import { WorkflowOperationType, WorkflowOptions } from "../../types"; /** * Convert string operation type to WorkflowOperationType enum */ export function parseOperationType(operationType: string | undefined): WorkflowOperationType { - if (!operationType) return WorkflowOperationType.Publish; - - switch (operationType.toLowerCase()) { - case 'publish': - return WorkflowOperationType.Publish; - case 'unpublish': - return WorkflowOperationType.Unpublish; - case 'approve': - return WorkflowOperationType.Approve; - case 'decline': - return WorkflowOperationType.Decline; - case 'requestapproval': - case 'request-approval': - case 'request_approval': - return WorkflowOperationType.RequestApproval; - default: - return WorkflowOperationType.Publish; - } + if (!operationType) return WorkflowOperationType.Publish; + + switch (operationType.toLowerCase()) { + case "publish": + return WorkflowOperationType.Publish; + case "unpublish": + return WorkflowOperationType.Unpublish; + case "approve": + return WorkflowOperationType.Approve; + case "decline": + return WorkflowOperationType.Decline; + case "requestapproval": + case "request-approval": + case "request_approval": + return WorkflowOperationType.RequestApproval; + default: + return WorkflowOperationType.Publish; + } } /** * Parse workflow options from state/command args */ export function parseWorkflowOptions( - operationType: string | boolean | WorkflowOperationType, - locale: string + operationType: string | boolean | WorkflowOperationType, + locale: string ): WorkflowOptions | null { - if (!operationType) return null; - - // Default operation is Publish - let operation = WorkflowOperationType.Publish; - let processContent = true; - let processPages = true; - - // Handle string operation types - if (typeof operationType === 'string') { - const value = operationType.toLowerCase(); - - // Check for operation type - switch (value) { - case 'publish': - operation = WorkflowOperationType.Publish; - break; - case 'unpublish': - operation = WorkflowOperationType.Unpublish; - break; - case 'approve': - operation = WorkflowOperationType.Approve; - break; - case 'decline': - operation = WorkflowOperationType.Decline; - break; - case 'requestapproval': - case 'request-approval': - case 'request_approval': - operation = WorkflowOperationType.RequestApproval; - break; - case 'content': - processPages = false; - break; - case 'pages': - processContent = false; - break; - case 'true': - // Default behavior - process both - break; - default: - // Unrecognized value - default to publish both - break; - } - } else if (typeof operationType === 'number') { - // Direct WorkflowOperationType enum value - operation = operationType; + if (!operationType) return null; + + // Default operation is Publish + let operation = WorkflowOperationType.Publish; + let processContent = true; + let processPages = true; + + // Handle string operation types + if (typeof operationType === "string") { + const value = operationType.toLowerCase(); + + // Check for operation type + switch (value) { + case "publish": + operation = WorkflowOperationType.Publish; + break; + case "unpublish": + operation = WorkflowOperationType.Unpublish; + break; + case "approve": + operation = WorkflowOperationType.Approve; + break; + case "decline": + operation = WorkflowOperationType.Decline; + break; + case "requestapproval": + case "request-approval": + case "request_approval": + operation = WorkflowOperationType.RequestApproval; + break; + case "content": + processPages = false; + break; + case "pages": + processContent = false; + break; + case "true": + // Default behavior - process both + break; + default: + // Unrecognized value - default to publish both + break; } - - return { processContent, processPages, locale, operation }; + } else if (typeof operationType === "number") { + // Direct WorkflowOperationType enum value + operation = operationType; + } + + return { processContent, processPages, locale, operation }; } diff --git a/src/lib/workflows/workflow-orchestrator.ts b/src/lib/workflows/workflow-orchestrator.ts index 19c3ed0..16b41df 100644 --- a/src/lib/workflows/workflow-orchestrator.ts +++ b/src/lib/workflows/workflow-orchestrator.ts @@ -1,20 +1,20 @@ /** * Workflow Orchestrator - * + * * Orchestrates batch workflow operations for content items and pages. */ -import ansiColors from 'ansi-colors'; -import { processBatches } from './process-batches'; -import { getOperationName, getOperationVerb } from './workflow-helpers'; -import { WorkflowOrchestratorResult, WorkflowOptions } from '../../types'; +import ansiColors from "ansi-colors"; +import { processBatches } from "./process-batches"; +import { getOperationName, getOperationVerb } from "./workflow-helpers"; +import { WorkflowOrchestratorResult, WorkflowOptions } from "../../types"; /** * Helper to log to both console and capture lines */ function logLine(line: string, logLines: string[]): void { - console.log(line); - logLines.push(line); + console.log(line); + logLines.push(line); } /** @@ -22,55 +22,63 @@ function logLine(line: string, logLines: string[]): void { * Processes items in batches and reports progress */ export async function workflowOrchestrator( - contentIds: number[], - pageIds: number[], - options: WorkflowOptions + contentIds: number[], + pageIds: number[], + options: WorkflowOptions ): Promise { - const errors: string[] = []; - const logLines: string[] = []; - const { locale, processContent, processPages, operation } = options; - const operationName = getOperationName(operation); - const operationVerb = getOperationVerb(operation); + const errors: string[] = []; + const logLines: string[] = []; + const { locale, processContent, processPages, operation } = options; + const operationName = getOperationName(operation); + const operationVerb = getOperationVerb(operation); - // Process content and pages - const contentResults = processContent - ? await processBatches(contentIds, 'content', locale, operation, errors) - : { total: 0, processed: 0, failed: 0, batches: 0, processedIds: [], logLines: [] }; - - // Collect content log lines - logLines.push(...contentResults.logLines); - - const pageResults = processPages - ? await processBatches(pageIds, 'pages', locale, operation, errors) - : { total: 0, processed: 0, failed: 0, batches: 0, processedIds: [], logLines: [] }; - - // Collect page log lines - logLines.push(...pageResults.logLines); + // Process content and pages + const contentResults = processContent + ? await processBatches(contentIds, "content", locale, operation, errors) + : { total: 0, processed: 0, failed: 0, batches: 0, processedIds: [], logLines: [] }; - // Summary - const totalProcessed = contentResults.processed + pageResults.processed; - const totalFailed = contentResults.failed + pageResults.failed; - const totalRequested = contentResults.total + pageResults.total; - const totalNested = totalProcessed > totalRequested ? totalProcessed - totalRequested : 0; + // Collect content log lines + logLines.push(...contentResults.logLines); - if (totalRequested > 0) { - if (totalNested > 0) { - logLine(ansiColors.cyan(`\nWorkflow summary: ${totalProcessed} items ${operationVerb} (${totalRequested} requested + ${totalNested} nested)`), logLines); - } else { - logLine(ansiColors.cyan(`\nWorkflow summary: ${totalProcessed}/${totalRequested} items ${operationVerb} successfully`), logLines); - } - if (totalFailed > 0) { - logLine(ansiColors.yellow(` ${totalFailed} items failed`), logLines); - } + const pageResults = processPages + ? await processBatches(pageIds, "pages", locale, operation, errors) + : { total: 0, processed: 0, failed: 0, batches: 0, processedIds: [], logLines: [] }; + + // Collect page log lines + logLines.push(...pageResults.logLines); + + // Summary + const totalProcessed = contentResults.processed + pageResults.processed; + const totalFailed = contentResults.failed + pageResults.failed; + const totalRequested = contentResults.total + pageResults.total; + const totalNested = totalProcessed > totalRequested ? totalProcessed - totalRequested : 0; + + if (totalRequested > 0) { + if (totalNested > 0) { + logLine( + ansiColors.cyan( + `\nWorkflow summary: ${totalProcessed} items ${operationVerb} (${totalRequested} requested + ${totalNested} nested)` + ), + logLines + ); } else { - logLine(ansiColors.gray(`\nNo items to ${operationName}`), logLines); + logLine( + ansiColors.cyan(`\nWorkflow summary: ${totalProcessed}/${totalRequested} items ${operationVerb} successfully`), + logLines + ); + } + if (totalFailed > 0) { + logLine(ansiColors.yellow(` ${totalFailed} items failed`), logLines); } + } else { + logLine(ansiColors.gray(`\nNo items to ${operationName}`), logLines); + } - return { - success: errors.length === 0, - contentResults, - pageResults, - errors, - logLines - }; + return { + success: errors.length === 0, + contentResults, + pageResults, + errors, + logLines, + }; } diff --git a/src/tests/setup.ts b/src/tests/setup.ts index a1749eb..fc6f490 100644 --- a/src/tests/setup.ts +++ b/src/tests/setup.ts @@ -1,18 +1,18 @@ /** * Jest setup file - loads environment variables for testing - * + * * Test env file location: src/tests/.env.test * Copy .env.test.example to .env.test and fill in your test credentials */ -import dotenv from 'dotenv'; -import path from 'path'; +import dotenv from "dotenv"; +import path from "path"; // Load .env.test from the tests folder (not project root) -const envPath = path.resolve(__dirname, '.env'); +const envPath = path.resolve(__dirname, ".env"); const result = dotenv.config({ path: envPath }); if (result.error) { - console.warn(` + console.warn(` ⚠️ Test environment file not found: ${envPath} Copy src/tests/.env.test.example to src/tests/.env and fill in your test credentials. diff --git a/src/tests/shared/fetch-api-status.integration.test.ts b/src/tests/shared/fetch-api-status.integration.test.ts index 51a66f6..af8ebbd 100644 --- a/src/tests/shared/fetch-api-status.integration.test.ts +++ b/src/tests/shared/fetch-api-status.integration.test.ts @@ -1,120 +1,117 @@ /** * Integration tests for Fetch API Status helper - * + * * Tests the getFetchApiStatus and waitForFetchApiSync functions * against a real Agility CMS instance. - * + * * Setup: * 1. Copy src/tests/env.test.example to src/tests/.env * 2. Fill in your test credentials - * + * * Required env vars in src/tests/.env: * - AGILITY_TOKEN - Valid authentication token * - AGILITY_GUID or AGILITY_TARGET_GUID - Instance GUID to check - * + * * Run with: npm run test:integration */ // Disable SSL certificate verification for local development -process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; - -import { getFetchApiStatus, waitForFetchApiSync, FetchApiStatus } from '../../lib/shared/get-fetch-api-status'; -import { state } from '../../core/state'; -import * as mgmtApi from '@agility/management-sdk'; -import { primeFromEnv } from '../../core/state'; - -describe('Fetch API Status - Integration Tests', () => { - let testGuid: string; - - beforeAll(async () => { - // Prime state from .env - primeFromEnv(); - - // Get required environment variables - const token = process.env.AGILITY_TOKEN2 || process.env.AGILITY_TOKEN; - testGuid = process.env.AGILITY_GUID || process.env.AGILITY_TARGET_GUID || ''; - const baseUrl = process.env.AGILITY_BASE_URL || process.env.BASE_URL; - - if (!token) { - throw new Error('AGILITY_TOKEN is required in .env for integration tests'); - } - if (!testGuid) { - throw new Error('AGILITY_GUID or AGILITY_TARGET_GUID is required in .env for integration tests'); - } - - // Initialize API client with real credentials - const options: mgmtApi.Options = { - token: token, - baseUrl: baseUrl, - refresh_token: null, - duration: 3000, - retryCount: 500, - }; - - const apiClient = new mgmtApi.ApiClient(options); - - // Set state for the helper functions - state.mgmtApiOptions = options; - state.cachedApiClient = apiClient; - }); - - describe('getFetchApiStatus', () => { - it('should return sync status for fetch mode', async () => { - const status = await getFetchApiStatus(testGuid, 'fetch', false); - - expect(status).toBeDefined(); - expect(typeof status.inProgress).toBe('boolean'); - expect(typeof status.lastContentVersionID).toBe('number'); - expect(typeof status.pushType).toBe('number'); - - // pushType should be 2 for fetch mode - expect(status.pushType).toBe(2); - - }, 30000); - - it('should return sync status for preview mode', async () => { - const status = await getFetchApiStatus(testGuid, 'preview', false); - - expect(status).toBeDefined(); - expect(typeof status.inProgress).toBe('boolean'); - expect(typeof status.lastContentVersionID).toBe('number'); - expect(typeof status.pushType).toBe('number'); - - // pushType should be 1 for preview mode - expect(status.pushType).toBe(1); - - }, 30000); - }); - - describe('waitForFetchApiSync', () => { - it('should wait for sync to complete (or return immediately if not syncing)', async () => { - const startTime = Date.now(); - const result = await waitForFetchApiSync(testGuid, 'fetch', true); - const elapsed = Date.now() - startTime; - - expect(result).toBeDefined(); - expect(result.status).toBeDefined(); - expect(result.status.inProgress).toBe(false); - expect(Array.isArray(result.logLines)).toBe(true); - - }, 120000); // 2 minute timeout for waiting - }); - - describe('error handling', () => { - it('should handle invalid GUID gracefully', async () => { - // Temporarily override the state with invalid credentials - const originalClient = state.cachedApiClient; - - try { - // This should throw or return an error - await getFetchApiStatus('invalid-guid-xxx', 'fetch', false); - // If it doesn't throw, that's also acceptable (API might return a default) - } catch (error: any) { - expect(error).toBeDefined(); - } - - // Restore original client - state.cachedApiClient = originalClient; - }, 30000); - }); +process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; + +import { getFetchApiStatus, waitForFetchApiSync, FetchApiStatus } from "../../lib/shared/get-fetch-api-status"; +import { state } from "../../core/state"; +import * as mgmtApi from "@agility/management-sdk"; +import { primeFromEnv } from "../../core/state"; + +describe("Fetch API Status - Integration Tests", () => { + let testGuid: string; + + beforeAll(async () => { + // Prime state from .env + primeFromEnv(); + + // Get required environment variables + const token = process.env.AGILITY_TOKEN2 || process.env.AGILITY_TOKEN; + testGuid = process.env.AGILITY_GUID || process.env.AGILITY_TARGET_GUID || ""; + const baseUrl = process.env.AGILITY_BASE_URL || process.env.BASE_URL; + + if (!token) { + throw new Error("AGILITY_TOKEN is required in .env for integration tests"); + } + if (!testGuid) { + throw new Error("AGILITY_GUID or AGILITY_TARGET_GUID is required in .env for integration tests"); + } + + // Initialize API client with real credentials + const options: mgmtApi.Options = { + token: token, + baseUrl: baseUrl, + refresh_token: null, + duration: 3000, + retryCount: 500, + }; + + const apiClient = new mgmtApi.ApiClient(options); + + // Set state for the helper functions + state.mgmtApiOptions = options; + state.cachedApiClient = apiClient; + }); + + describe("getFetchApiStatus", () => { + it("should return sync status for fetch mode", async () => { + const status = await getFetchApiStatus(testGuid, "fetch", false); + + expect(status).toBeDefined(); + expect(typeof status.inProgress).toBe("boolean"); + expect(typeof status.lastContentVersionID).toBe("number"); + expect(typeof status.pushType).toBe("number"); + + // pushType should be 2 for fetch mode + expect(status.pushType).toBe(2); + }, 30000); + + it("should return sync status for preview mode", async () => { + const status = await getFetchApiStatus(testGuid, "preview", false); + + expect(status).toBeDefined(); + expect(typeof status.inProgress).toBe("boolean"); + expect(typeof status.lastContentVersionID).toBe("number"); + expect(typeof status.pushType).toBe("number"); + + // pushType should be 1 for preview mode + expect(status.pushType).toBe(1); + }, 30000); + }); + + describe("waitForFetchApiSync", () => { + it("should wait for sync to complete (or return immediately if not syncing)", async () => { + const startTime = Date.now(); + const result = await waitForFetchApiSync(testGuid, "fetch", true); + const elapsed = Date.now() - startTime; + + expect(result).toBeDefined(); + expect(result.status).toBeDefined(); + expect(result.status.inProgress).toBe(false); + expect(Array.isArray(result.logLines)).toBe(true); + }, 120000); // 2 minute timeout for waiting + }); + + describe("error handling", () => { + it("should handle invalid GUID gracefully", async () => { + // Temporarily override the state with invalid credentials + const originalClient = state.cachedApiClient; + + try { + // This should throw or return an error + await getFetchApiStatus("invalid-guid-xxx", "fetch", false); + // If it doesn't throw, that's also acceptable (API might return a default) + } catch (error: any) { + expect(error).toBeDefined(); + } + + // Restore original client + state.cachedApiClient = originalClient; + }, 30000); + }); }); diff --git a/src/tests/workflows/batch-workflows.integration.test.ts b/src/tests/workflows/batch-workflows.integration.test.ts index de4ad04..c4d2369 100644 --- a/src/tests/workflows/batch-workflows.integration.test.ts +++ b/src/tests/workflows/batch-workflows.integration.test.ts @@ -1,61 +1,61 @@ /** * Integration tests for batch workflow operations * Tests workflow operations (publish, unpublish, etc.) on content and pages using a REAL API client - * + * * Setup: * 1. Copy src/tests/env.test.example to src/tests/.env.test * 2. Fill in your test credentials - * + * * Required env vars in src/tests/.env.test: * - AGILITY_TOKEN - Valid authentication token * - AGILITY_TARGET_GUID or AGILITY_GUID - Target instance GUID * - AGILITY_LOCALE or AGILITY_LOCALES - Locale(s) for testing * - CONTENTIDS_TO_BATCH_PUBLISH - Comma-separated content IDs * - PAGES_TO_BATCH_PUBLISH - Comma-separated page IDs - * + * * Run with: npm test -- --testPathPattern="integration" */ // Disable SSL certificate verification for local development -process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; +process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; -import { batchWorkflow } from '../../core/batch-workflows'; -import { WorkflowOperationType } from '@agility/management-sdk'; -import { state } from '../../core/state'; -import * as mgmtApi from '@agility/management-sdk'; -import { primeFromEnv } from '../../core/state'; +import { batchWorkflow } from "../../core/batch-workflows"; +import { WorkflowOperationType } from "@agility/management-sdk"; +import { state } from "../../core/state"; +import * as mgmtApi from "@agility/management-sdk"; +import { primeFromEnv } from "../../core/state"; // Helper function to parse comma-separated IDs from environment variable function parseIDs(envVar: string | undefined, fallback: number[]): number[] { if (!envVar) return fallback; return envVar - .split(',') - .map(id => parseInt(id.trim(), 10)) - .filter(id => !isNaN(id)); + .split(",") + .map((id) => parseInt(id.trim(), 10)) + .filter((id) => !isNaN(id)); } // Get test data from environment variables const TEST_CONTENT_IDS = parseIDs(process.env.CONTENTIDS_TO_BATCH_PUBLISH, []); const TEST_PAGE_IDS = parseIDs(process.env.PAGES_TO_BATCH_PUBLISH, []); -const TEST_LOCALE = process.env.AGILITY_LOCALE || process.env.AGILITY_LOCALES?.split(',')[0] || 'en-us'; -const BASE_URL = process.env.AGILITY_BASE_URL || process.env.BASE_URL || 'https://api.agilitycms.com'; +const TEST_LOCALE = process.env.AGILITY_LOCALE || process.env.AGILITY_LOCALES?.split(",")[0] || "en-us"; +const BASE_URL = process.env.AGILITY_BASE_URL || process.env.BASE_URL || "https://api.agilitycms.com"; -describe('Batch Workflow Operations - Integration Tests', () => { +describe("Batch Workflow Operations - Integration Tests", () => { let apiClient: mgmtApi.ApiClient; beforeAll(async () => { // Prime state from .env primeFromEnv(); - + // Get required environment variables const token = process.env.AGILITY_TOKEN2 || process.env.AGILITY_TOKEN; const targetGuid = process.env.AGILITY_TARGET_GUID || process.env.AGILITY_GUID; - + if (!token) { - throw new Error('AGILITY_TOKEN is required in .env for integration tests'); + throw new Error("AGILITY_TOKEN is required in .env for integration tests"); } if (!targetGuid) { - throw new Error('AGILITY_TARGET_GUID or AGILITY_GUID is required in .env for integration tests'); + throw new Error("AGILITY_TARGET_GUID or AGILITY_GUID is required in .env for integration tests"); } // Initialize API client with real credentials @@ -66,9 +66,9 @@ describe('Batch Workflow Operations - Integration Tests', () => { duration: 3000, retryCount: 500, }; - + apiClient = new mgmtApi.ApiClient(options); - + // Set state for the workflow functions state.targetGuid = [targetGuid]; state.mgmtApiOptions = options; @@ -78,37 +78,37 @@ describe('Batch Workflow Operations - Integration Tests', () => { // ============================================================================ // Content Workflow Operations // ============================================================================ - - describe('Content Workflow Operations', () => { + + describe("Content Workflow Operations", () => { beforeAll(() => { if (TEST_CONTENT_IDS.length === 0) { - console.warn('CONTENTIDS_TO_BATCH_PUBLISH not set - content tests will be skipped'); + console.warn("CONTENTIDS_TO_BATCH_PUBLISH not set - content tests will be skipped"); } }); - it('should run publish workflow operation on content items', async () => { + it("should run publish workflow operation on content items", async () => { if (TEST_CONTENT_IDS.length === 0) return; - - const result = await batchWorkflow(TEST_CONTENT_IDS, TEST_LOCALE, WorkflowOperationType.Publish, 'content'); + + const result = await batchWorkflow(TEST_CONTENT_IDS, TEST_LOCALE, WorkflowOperationType.Publish, "content"); expect(result.success).toBe(true); expect(result.processedIds).toBeDefined(); expect(result.processedIds.length).toBeGreaterThan(0); expect(result.error).toBeUndefined(); }, 30000); - it('should run unpublish workflow operation on content items', async () => { + it("should run unpublish workflow operation on content items", async () => { if (TEST_CONTENT_IDS.length === 0) return; - - const result = await batchWorkflow(TEST_CONTENT_IDS, TEST_LOCALE, WorkflowOperationType.Unpublish, 'content'); + + const result = await batchWorkflow(TEST_CONTENT_IDS, TEST_LOCALE, WorkflowOperationType.Unpublish, "content"); expect(result.success).toBe(true); expect(result.processedIds).toBeDefined(); expect(result.processedIds.length).toBeGreaterThan(0); expect(result.error).toBeUndefined(); }, 30000); - it('should handle workflow operation on invalid content IDs gracefully', async () => { + it("should handle workflow operation on invalid content IDs gracefully", async () => { const invalidContentIDs = [999999, 999998]; - const result = await batchWorkflow(invalidContentIDs, TEST_LOCALE, WorkflowOperationType.Publish, 'content'); + const result = await batchWorkflow(invalidContentIDs, TEST_LOCALE, WorkflowOperationType.Publish, "content"); expect(result).toBeDefined(); expect(result.success !== undefined).toBe(true); }, 30000); @@ -117,37 +117,37 @@ describe('Batch Workflow Operations - Integration Tests', () => { // ============================================================================ // Page Workflow Operations // ============================================================================ - - describe('Page Workflow Operations', () => { + + describe("Page Workflow Operations", () => { beforeAll(() => { if (TEST_PAGE_IDS.length === 0) { - console.warn('PAGES_TO_BATCH_PUBLISH not set - page tests will be skipped'); + console.warn("PAGES_TO_BATCH_PUBLISH not set - page tests will be skipped"); } }); - it('should run publish workflow operation on pages', async () => { + it("should run publish workflow operation on pages", async () => { if (TEST_PAGE_IDS.length === 0) return; - - const result = await batchWorkflow(TEST_PAGE_IDS, TEST_LOCALE, WorkflowOperationType.Publish, 'pages'); + + const result = await batchWorkflow(TEST_PAGE_IDS, TEST_LOCALE, WorkflowOperationType.Publish, "pages"); expect(result.success).toBe(true); expect(result.processedIds).toBeDefined(); expect(result.processedIds.length).toBeGreaterThan(0); expect(result.error).toBeUndefined(); }, 30000); - it('should run unpublish workflow operation on pages', async () => { + it("should run unpublish workflow operation on pages", async () => { if (TEST_PAGE_IDS.length === 0) return; - - const result = await batchWorkflow(TEST_PAGE_IDS, TEST_LOCALE, WorkflowOperationType.Unpublish, 'pages'); + + const result = await batchWorkflow(TEST_PAGE_IDS, TEST_LOCALE, WorkflowOperationType.Unpublish, "pages"); expect(result.success).toBe(true); expect(result.processedIds).toBeDefined(); expect(result.processedIds.length).toBeGreaterThan(0); expect(result.error).toBeUndefined(); }, 30000); - it('should handle workflow operation on invalid page IDs gracefully', async () => { + it("should handle workflow operation on invalid page IDs gracefully", async () => { const invalidPageIDs = [999999, 999998]; - const result = await batchWorkflow(invalidPageIDs, TEST_LOCALE, WorkflowOperationType.Publish, 'pages'); + const result = await batchWorkflow(invalidPageIDs, TEST_LOCALE, WorkflowOperationType.Publish, "pages"); expect(result).toBeDefined(); expect(result.success !== undefined).toBe(true); }, 30000); diff --git a/src/types/agilityInstance.ts b/src/types/agilityInstance.ts index 40d84c0..e6d448b 100644 --- a/src/types/agilityInstance.ts +++ b/src/types/agilityInstance.ts @@ -1,8 +1,8 @@ import { websiteListing } from "./websiteListing"; export interface AgilityInstance { - guid: string; - previewKey: string; - fetchKey: string; - websiteDetails: websiteListing - } \ No newline at end of file + guid: string; + previewKey: string; + fetchKey: string; + websiteDetails: websiteListing; +} diff --git a/src/types/cliToken.ts b/src/types/cliToken.ts index 11a3d59..9e7a88f 100644 --- a/src/types/cliToken.ts +++ b/src/types/cliToken.ts @@ -1,9 +1,9 @@ -export class cliToken{ - PartitionKey : string| null; - RowKey : string| null; - access_token : string| null; - expires_in : number| null; - token_type : string| null; - refresh_token : string| null; - timestamp: string | null; -} \ No newline at end of file +export class cliToken { + PartitionKey: string | null; + RowKey: string | null; + access_token: string | null; + expires_in: number | null; + token_type: string | null; + refresh_token: string | null; + timestamp: string | null; +} diff --git a/src/types/comparisonResult.ts b/src/types/comparisonResult.ts index 986aeda..d3acfa7 100644 --- a/src/types/comparisonResult.ts +++ b/src/types/comparisonResult.ts @@ -1,3 +1,3 @@ interface ComparisonResult { - [key: string]: any; -} \ No newline at end of file + [key: string]: any; +} diff --git a/src/types/index.ts b/src/types/index.ts index 19dc096..2df91fa 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,18 +1,18 @@ // Export all types from the types directory -export * from './sourceData'; -export * from './agilityInstance'; -export * from './syncAnalysis'; -export * from './instancePermission'; -export * from './instanceRole'; -export * from './modelFilter'; -export * from './serverUser'; -export * from './websiteListing'; -export * from './websiteUser'; -export * from './cliToken'; -// Note: comparisonResult.ts doesn't export anything, skipping +export * from "./sourceData"; +export * from "./agilityInstance"; +export * from "./syncAnalysis"; +export * from "./instancePermission"; +export * from "./instanceRole"; +export * from "./modelFilter"; +export * from "./serverUser"; +export * from "./websiteListing"; +export * from "./websiteUser"; +export * from "./cliToken"; +// Note: comparisonResult.ts doesn't export anything, skipping // ReferenceMapperV2 types -export * from './referenceMapperV2'; +export * from "./referenceMapperV2"; // Workflow types (batch workflows, mappings, publish status) -export * from './workflows'; \ No newline at end of file +export * from "./workflows"; diff --git a/src/types/instancePermission.ts b/src/types/instancePermission.ts index e5b3634..1d9d496 100644 --- a/src/types/instancePermission.ts +++ b/src/types/instancePermission.ts @@ -1,5 +1,5 @@ export class InstancePermission { - permissionType: string; - permissionID: number; - name: string; -} \ No newline at end of file + permissionType: string; + permissionID: number; + name: string; +} diff --git a/src/types/instanceRole.ts b/src/types/instanceRole.ts index f5738d5..f44d8b1 100644 --- a/src/types/instanceRole.ts +++ b/src/types/instanceRole.ts @@ -1,7 +1,7 @@ export class InstanceRole { - roleID: number; - isGlobalRole: boolean; - sort: number; - role: string | null; - name: string | null; -} \ No newline at end of file + roleID: number; + isGlobalRole: boolean; + sort: number; + role: string | null; + name: string | null; +} diff --git a/src/types/modelFilter.ts b/src/types/modelFilter.ts index 771f5b5..92a836f 100644 --- a/src/types/modelFilter.ts +++ b/src/types/modelFilter.ts @@ -1,14 +1,14 @@ export class FilterData { - Models: string[]; - Templates: string[]; + Models: string[]; + Templates: string[]; } export class ModelFilter { - filter: { [key: string]: string[] } = {}; - constructor(data: FilterData) { - this.filter = { - Models: data.Models, - Templates: data.Templates - }; - } -} \ No newline at end of file + filter: { [key: string]: string[] } = {}; + constructor(data: FilterData) { + this.filter = { + Models: data.Models, + Templates: data.Templates, + }; + } +} diff --git a/src/types/referenceMapperV2.ts b/src/types/referenceMapperV2.ts index 0b76c1c..b39782a 100644 --- a/src/types/referenceMapperV2.ts +++ b/src/types/referenceMapperV2.ts @@ -3,7 +3,7 @@ * Canonical storage approach - each mapping stored once under lexicographically smaller GUID */ -export type EntityType = 'model' | 'container' | 'content' | 'asset' | 'gallery' | 'template' | 'page'; +export type EntityType = "model" | "container" | "content" | "asset" | "gallery" | "template" | "page"; export interface EntityReference { guid: string; @@ -15,26 +15,27 @@ export interface EntityReference { export interface MappingEntry { entityA: EntityReference; entityB: EntityReference; - lastSyncDirection: string; // "guidA→guidB" + lastSyncDirection: string; // "guidA→guidB" syncHistory: SyncHistoryEntry[]; } export interface SyncHistoryEntry { - direction: string; // "guidA→guidB" - timestamp: string; // ISO date string - syncType?: string; // "create", "update", "overwrite" + direction: string; // "guidA→guidB" + timestamp: string; // ISO date string + syncType?: string; // "create", "update", "overwrite" } export interface EntityMappingFile { metadata: { - canonicalGuid: string; // The GUID this file belongs to (lexicographically smaller) - lastUpdated: string; // ISO date - version: string; // "2.0" - entityType: EntityType; // Type of entities in this file + canonicalGuid: string; // The GUID this file belongs to (lexicographically smaller) + lastUpdated: string; // ISO date + version: string; // "2.0" + entityType: EntityType; // Type of entities in this file }; mappings: { - [relationshipGuid: string]: { // Other GUID in the relationship - [compoundKey: string]: MappingEntry; // "sourceId-targetId" or unique identifier + [relationshipGuid: string]: { + // Other GUID in the relationship + [compoundKey: string]: MappingEntry; // "sourceId-targetId" or unique identifier }; }; } @@ -42,7 +43,7 @@ export interface EntityMappingFile { export interface MappingLookupResult { entry: MappingEntry; targetId: number; - canonicalLocation: string; // File path where mapping is stored + canonicalLocation: string; // File path where mapping is stored } export interface MappingContext { @@ -66,8 +67,8 @@ export interface BulkMappingResult { // Configuration interface export interface ReferenceMapperV2Config { - enableLegacyMode?: boolean; // Use v1 format for compatibility - autoMigrate?: boolean; // Automatically migrate v1 to v2 + enableLegacyMode?: boolean; // Use v1 format for compatibility + autoMigrate?: boolean; // Automatically migrate v1 to v2 enableBackupOnWrite?: boolean; // Create backups before writing - cacheSize?: number; // LRU cache size for mapping files -} \ No newline at end of file + cacheSize?: number; // LRU cache size for mapping files +} diff --git a/src/types/serverUser.ts b/src/types/serverUser.ts index 5fde62d..2f6e15f 100644 --- a/src/types/serverUser.ts +++ b/src/types/serverUser.ts @@ -1,17 +1,17 @@ import { websiteListing } from "./websiteListing"; -export class serverUser{ - userID: number| null; - userName: string| null; - emailAddress: string| null; - firstName: string| null; - lastName: string| null; - isSuspended: boolean| null; - isProfileComplete: boolean| null; - currentWebsite: string| null; - userTypeID: number| null; - timeZoneRegion: string| null; - jobRole: string| null; - createdDate: Date| null; - websiteAccess: websiteListing[]| null; -} \ No newline at end of file +export class serverUser { + userID: number | null; + userName: string | null; + emailAddress: string | null; + firstName: string | null; + lastName: string | null; + isSuspended: boolean | null; + isProfileComplete: boolean | null; + currentWebsite: string | null; + userTypeID: number | null; + timeZoneRegion: string | null; + jobRole: string | null; + createdDate: Date | null; + websiteAccess: websiteListing[] | null; +} diff --git a/src/types/sourceData.ts b/src/types/sourceData.ts index 7cc27ad..e927883 100644 --- a/src/types/sourceData.ts +++ b/src/types/sourceData.ts @@ -1,7 +1,7 @@ import * as mgmtApi from "@agility/management-sdk"; export interface PageModuleExtended extends mgmtApi.PageModule { - title: string; + title: string; } /** @@ -9,14 +9,14 @@ export interface PageModuleExtended extends mgmtApi.PageModule { * Replaces 'any' type usage with proper TypeScript interfaces */ export interface SourceData { - pages: mgmtApi.PageItem[]; - content: mgmtApi.ContentItem[]; - models: mgmtApi.Model[]; - templates: mgmtApi.PageModel[]; - lists: mgmtApi.Container[]; - containers: mgmtApi.Container[]; - assets: mgmtApi.Media[]; - galleries: mgmtApi.assetMediaGrouping[]; + pages: mgmtApi.PageItem[]; + content: mgmtApi.ContentItem[]; + models: mgmtApi.Model[]; + templates: mgmtApi.PageModel[]; + lists: mgmtApi.Container[]; + containers: mgmtApi.Container[]; + assets: mgmtApi.Media[]; + galleries: mgmtApi.assetMediaGrouping[]; } /** @@ -24,23 +24,23 @@ export interface SourceData { * Consolidates tracking into single callback pattern */ export type PusherProgressCallback = ( - processed: number, - total: number, - status: 'success' | 'error' | 'skipped', - itemName?: string + processed: number, + total: number, + status: "success" | "error" | "skipped", + itemName?: string ) => void; /** * Individual failure detail with optional link metadata */ export interface FailureDetail { - name: string; - error: string; - type?: 'content' | 'page'; // For generating appropriate link - pageID?: number; // Source page ID for page links - contentID?: number; // Source content ID for content links - guid?: string; // Source instance GUID - locale?: string; // Locale code + name: string; + error: string; + type?: "content" | "page"; // For generating appropriate link + pageID?: number; // Source page ID for page links + contentID?: number; // Source content ID for content links + guid?: string; // Source instance GUID + locale?: string; // Locale code } /** @@ -48,19 +48,19 @@ export interface FailureDetail { * Replaces inline type definitions with consistent response structure */ export interface PusherResult { - successful: number; - failed: number; - skipped: number; - status: 'success' | 'error'; - publishableIds?: number[]; // Optional: target instance IDs for workflow operations (content items and pages only) - failureDetails?: FailureDetail[]; // Individual failure details for error summary + successful: number; + failed: number; + skipped: number; + status: "success" | "error"; + publishableIds?: number[]; // Optional: target instance IDs for workflow operations (content items and pages only) + failureDetails?: FailureDetail[]; // Individual failure details for error summary } /** * Pusher function signature with standardized types */ export type PusherFunction = ( - sourceData: SourceData, - referenceMapper: any, // TODO: Import proper ReferenceMapper type - onProgress?: PusherProgressCallback -) => Promise; \ No newline at end of file + sourceData: SourceData, + referenceMapper: any, // TODO: Import proper ReferenceMapper type + onProgress?: PusherProgressCallback +) => Promise; diff --git a/src/types/syncAnalysis.ts b/src/types/syncAnalysis.ts index 61da44b..5280395 100644 --- a/src/types/syncAnalysis.ts +++ b/src/types/syncAnalysis.ts @@ -6,149 +6,149 @@ * Model tracking to prevent duplicates across all chain displays */ export interface ModelTracker { - displayedModels: Set; - isModelDisplayed(modelName: string): boolean; - markModelDisplayed(modelName: string): void; - reset(): void; + displayedModels: Set; + isModelDisplayed(modelName: string): boolean; + markModelDisplayed(modelName: string): void; + reset(): void; } /** * Context for sync analysis operations */ export interface SyncAnalysisContext { - sourceGuid: string; - locale: string; - isPreview: boolean; - rootPath: string; - legacyFolders?: boolean; - debug: boolean; - elements: string[]; - modelTracker?: ModelTracker; // Optional model tracking for duplicate detection + sourceGuid: string; + locale: string; + isPreview: boolean; + rootPath: string; + legacyFolders?: boolean; + debug: boolean; + elements: string[]; + modelTracker?: ModelTracker; // Optional model tracking for duplicate detection } /** * Base interface for all sync analysis services */ export interface SyncAnalysisService { - /** - * Initialize the service with context - */ - initialize(context: SyncAnalysisContext): void; + /** + * Initialize the service with context + */ + initialize(context: SyncAnalysisContext): void; } /** * Interface for services that analyze specific entity chains */ export interface ChainAnalysisService extends SyncAnalysisService { - /** - * Analyze and display the chains for this service's domain - */ - analyzeChains(sourceEntities: SourceEntities): void; + /** + * Analyze and display the chains for this service's domain + */ + analyzeChains(sourceEntities: SourceEntities): void; } /** * Interface for utility services that extract references */ export interface ReferenceExtractionService extends SyncAnalysisService { - /** - * Extract references from the given data structure - */ - extractReferences(data: any): any[]; + /** + * Extract references from the given data structure + */ + extractReferences(data: any): any[]; } /** * Interface for services that validate dependencies */ export interface DependencyValidationService extends SyncAnalysisService { - /** - * Validate dependencies for a given entity - */ - validateDependencies(entity: any, sourceEntities: SourceEntities): DependencyValidationResult; + /** + * Validate dependencies for a given entity + */ + validateDependencies(entity: any, sourceEntities: SourceEntities): DependencyValidationResult; } /** * Result of dependency validation */ export interface DependencyValidationResult { - missing: string[]; - isBroken: boolean; + missing: string[]; + isBroken: boolean; } export interface SitemapNode { - title: string | null; - name: string; - pageID: number; - menuText: string; - visible: { - menu: boolean; - sitemap: boolean; - }; - path: string; - redirect: { url: string; target: string } | null; - isFolder: boolean; - contentID?: number; - children?: SitemapNode[]; + title: string | null; + name: string; + pageID: number; + menuText: string; + visible: { + menu: boolean; + sitemap: boolean; + }; + path: string; + redirect: { url: string; target: string } | null; + isFolder: boolean; + contentID?: number; + children?: SitemapNode[]; } export interface PageHierarchy { - [parentPageID: number]: number[]; // parent ID → array of child IDs + [parentPageID: number]: number[]; // parent ID → array of child IDs } export interface HierarchicalPageGroup { - rootPage: any; - childPages: any[]; - allPageIds: Set; + rootPage: any; + childPages: any[]; + allPageIds: Set; } export interface SourceEntities { - pages?: any[]; - content?: any[]; - models?: any[]; - templates?: any[]; - containers?: any[]; - assets?: any[]; - galleries?: any[]; + pages?: any[]; + content?: any[]; + models?: any[]; + templates?: any[]; + containers?: any[]; + assets?: any[]; + galleries?: any[]; } export interface MissingDependency { - type: string; - id: string | number; - name?: string; + type: string; + id: string | number; + name?: string; } export interface BrokenChain { - entity: any; - missing: string[]; - type: 'page' | 'container' | 'model'; + entity: any; + missing: string[]; + type: "page" | "container" | "model"; } export interface EntityCounts { - pages: number; - content: number; - models: number; - templates: number; - containers: number; - assets: number; - galleries: number; + pages: number; + content: number; + models: number; + templates: number; + containers: number; + assets: number; + galleries: number; } export interface EntitiesInChains { - pages: Set; - content: Set; - models: Set; - templates: Set; - containers: Set; - assets: Set; - galleries: Set; + pages: Set; + content: Set; + models: Set; + templates: Set; + containers: Set; + assets: Set; + galleries: Set; } export interface AssetReference { - url: string; - fieldPath: string; + url: string; + fieldPath: string; } export interface ContainerReference { - contentID: number; - fieldPath: string; - referenceName?: string; // Optional: container reference name for lookup -} \ No newline at end of file + contentID: number; + fieldPath: string; + referenceName?: string; // Optional: container reference name for lookup +} diff --git a/src/types/websiteListing.ts b/src/types/websiteListing.ts index 9e76f51..57e3d0b 100644 --- a/src/types/websiteListing.ts +++ b/src/types/websiteListing.ts @@ -1,15 +1,15 @@ -export class websiteListing{ - orgCode: string| null; - orgName: string| null; - websiteName: string| null; - websiteNameStripped: string| null; - displayName: string| null; - guid: string| null; - websiteID: number| null; - isCurrent: boolean| null; - managerUrl: string| null; - version: string| null; - isOwner: boolean| null; - isDormant: boolean| null; - isRestoring: boolean| null; -} \ No newline at end of file +export class websiteListing { + orgCode: string | null; + orgName: string | null; + websiteName: string | null; + websiteNameStripped: string | null; + displayName: string | null; + guid: string | null; + websiteID: number | null; + isCurrent: boolean | null; + managerUrl: string | null; + version: string | null; + isOwner: boolean | null; + isDormant: boolean | null; + isRestoring: boolean | null; +} diff --git a/src/types/websiteUser.ts b/src/types/websiteUser.ts index 8899d4f..2499dd7 100644 --- a/src/types/websiteUser.ts +++ b/src/types/websiteUser.ts @@ -2,18 +2,18 @@ import { InstancePermission } from "./instancePermission"; import { InstanceRole } from "./instanceRole"; export class WebsiteUser { - userID: number | null; - userName: string | null; - firstName: string | null; - lastName: string | null; - emailAddress: string | null; - isDeleted: boolean; - fullName: string | null; - isTeamUser: boolean; - isSuspended: boolean; - teamID: number | null; - userRoles: InstanceRole[]; - userPermissions: InstancePermission[]; - loginDate: string | null; - isOrgAdmin: boolean; -} \ No newline at end of file + userID: number | null; + userName: string | null; + firstName: string | null; + lastName: string | null; + emailAddress: string | null; + isDeleted: boolean; + fullName: string | null; + isTeamUser: boolean; + isSuspended: boolean; + teamID: number | null; + userRoles: InstanceRole[]; + userPermissions: InstancePermission[]; + loginDate: string | null; + isOrgAdmin: boolean; +} diff --git a/src/types/workflows.ts b/src/types/workflows.ts index ba8dbf7..fe044e3 100644 --- a/src/types/workflows.ts +++ b/src/types/workflows.ts @@ -1,10 +1,10 @@ /** * Workflow Types - * + * * Type definitions for batch workflow operations, mappings, and publish status checking. */ -import { WorkflowOperationType } from '@agility/management-sdk'; +import { WorkflowOperationType } from "@agility/management-sdk"; // Re-export WorkflowOperationType for convenience export { WorkflowOperationType }; @@ -17,64 +17,64 @@ export { WorkflowOperationType }; * Result from a batch workflow operation */ export interface BatchWorkflowResult { - success: boolean; - processedIds: number[]; - failedCount: number; - batchId?: number; // The batch ID from the API - error?: string; - /** Partial success details when some items succeed and some fail */ - partialSuccess?: { - successCount: number; - failureCount: number; - batchId: number; - }; + success: boolean; + processedIds: number[]; + failedCount: number; + batchId?: number; // The batch ID from the API + error?: string; + /** Partial success details when some items succeed and some fail */ + partialSuccess?: { + successCount: number; + failureCount: number; + batchId: number; + }; } /** * Combined result from workflow orchestration */ export interface WorkflowOrchestratorResult { - success: boolean; - contentResults: { - total: number; - processed: number; - failed: number; - batches: number; - processedIds: number[]; - }; - pageResults: { - total: number; - processed: number; - failed: number; - batches: number; - processedIds: number[]; - }; - errors: string[]; - logLines: string[]; + success: boolean; + contentResults: { + total: number; + processed: number; + failed: number; + batches: number; + processedIds: number[]; + }; + pageResults: { + total: number; + processed: number; + failed: number; + batches: number; + processedIds: number[]; + }; + errors: string[]; + logLines: string[]; } /** * Options for workflow operations */ export interface WorkflowOptions { - processContent: boolean; - processPages: boolean; - locale: string; - operation: WorkflowOperationType; + processContent: boolean; + processPages: boolean; + locale: string; + operation: WorkflowOperationType; } /** * Result from a workflow operation command */ export interface WorkflowOperationResult { - success: boolean; - contentProcessed: number; - contentFailed: number; - pagesProcessed: number; - pagesFailed: number; - elapsedTime: number; - errors: string[]; - operation: string; + success: boolean; + contentProcessed: number; + contentFailed: number; + pagesProcessed: number; + pagesFailed: number; + elapsedTime: number; + errors: string[]; + operation: string; } // ============================================================================ @@ -85,46 +85,46 @@ export interface WorkflowOperationResult { * Content item mapping between source and target instances */ export interface ContentMapping { - sourceGuid: string; - targetGuid: string; - sourceContentID: number; - targetContentID: number; - sourceVersionID: number; - targetVersionID: number; + sourceGuid: string; + targetGuid: string; + sourceContentID: number; + targetContentID: number; + sourceVersionID: number; + targetVersionID: number; } /** * Page mapping between source and target instances */ export interface PageMapping { - sourceGuid: string; - targetGuid: string; - sourcePageID: number; - targetPageID: number; - sourceVersionID: number; - targetVersionID: number; - sourcePageTemplateName: string | null; - targetPageTemplateName: string | null; + sourceGuid: string; + targetGuid: string; + sourcePageID: number; + targetPageID: number; + sourceVersionID: number; + targetVersionID: number; + sourcePageTemplateName: string | null; + targetPageTemplateName: string | null; } /** * Result from reading mappings */ export interface MappingReadResult { - contentIds: number[]; - pageIds: number[]; - contentMappings: ContentMapping[]; - pageMappings: PageMapping[]; - errors: string[]; + contentIds: number[]; + pageIds: number[]; + contentMappings: ContentMapping[]; + pageMappings: PageMapping[]; + errors: string[]; } /** * Result from updating mappings after publishing */ export interface MappingUpdateResult { - contentMappingsUpdated: number; - pageMappingsUpdated: number; - errors: string[]; + contentMappingsUpdated: number; + pageMappingsUpdated: number; + errors: string[]; } // ============================================================================ @@ -135,37 +135,37 @@ export interface MappingUpdateResult { * Item state values from the Agility CMS ItemState enum */ export enum ItemState { - New = -1, - None = 0, - Staging = 1, - Published = 2, - Deleted = 3, - Approved = 4, - AwaitingApproval = 5, - Declined = 6, - Unpublished = 7 + New = -1, + None = 0, + Staging = 1, + Published = 2, + Deleted = 3, + Approved = 4, + AwaitingApproval = 5, + Declined = 6, + Unpublished = 7, } /** * Source item data structure for publish status checking */ export interface SourceItemData { - contentID?: number; - pageID?: number; - properties: { - state: number; - modified: string; - versionID: number; - }; + contentID?: number; + pageID?: number; + properties: { + state: number; + modified: string; + versionID: number; + }; } /** * Result from checking publish status of source items */ export interface PublishStatusResult { - publishedContentIds: number[]; - unpublishedContentIds: number[]; - publishedPageIds: number[]; - unpublishedPageIds: number[]; - errors: string[]; + publishedContentIds: number[]; + unpublishedContentIds: number[]; + publishedPageIds: number[]; + unpublishedPageIds: number[]; + errors: string[]; } diff --git a/tsconfig.json b/tsconfig.json index f79d269..0793957 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,27 +1,24 @@ { - "compilerOptions": { - "module": "commonjs", - "allowJs": true, - "declaration": true, - "jsx": "react", - "target": "es5", - "outDir": "./dist/", - "lib": ["es2016", "dom"], - "esModuleInterop": true, - "skipLibCheck": true, - "allowSyntheticDefaultImports": true, - "baseUrl": ".", - "sourceMap": true, - "paths": { - "lib/*": ["src/lib/*"], - "core/*": ["src/core/*"], - "core": ["src/core"], - "types/*": ["src/types/*"] - } - }, - "include": ["src/**/*"], - "exclude": [ - "**/*.test.ts", - "**/tests/**" - ] + "compilerOptions": { + "module": "commonjs", + "allowJs": true, + "declaration": true, + "jsx": "react", + "target": "es5", + "outDir": "./dist/", + "lib": ["es2016", "dom"], + "esModuleInterop": true, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "baseUrl": ".", + "sourceMap": true, + "paths": { + "lib/*": ["src/lib/*"], + "core/*": ["src/core/*"], + "core": ["src/core"], + "types/*": ["src/types/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts", "**/tests/**"] } diff --git a/yarn.lock b/yarn.lock index 413c83d..8ace521 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2106,11 +2106,6 @@ fs.realpath@^1.0.0: resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -fsevents@^2.3.2: - version "2.3.3" - resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz" - integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== - function-bind@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz" @@ -3521,6 +3516,11 @@ prelude-ls@^1.2.1: resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== +prettier@3.8.3: + version "3.8.3" + resolved "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz" + integrity sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw== + pretty-format@^29.0.0, pretty-format@^29.7.0: version "29.7.0" resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz"