Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ agility pull [options]

| Option | Type | Default | Description |
| ------------ | ------ | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `--elements` | string | `Models,Galleries,Assets,Containers,Content,Templates,Pages,Sitemaps` | Comma-separated list of elements to process |
| `--elements` | string | `Models,Galleries,Assets,Containers,Content,Templates,Pages,Sitemaps,UrlRedirections` | Comma-separated list of elements to process |
| `--models` | string | _(empty)_ | Comma-separated list of model reference names to sync (only syncs the specified models) |

**Authentication & Environment Options:**
Expand Down Expand Up @@ -92,7 +92,7 @@ agility sync [options]

| Option | Type | Default | Description |
| -------------------- | ------ | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--elements` | string | `Models,Galleries,Assets,Containers,Content,Templates,Pages,Sitemaps` | Comma-separated list of elements to process |
| `--elements` | string | `Models,Galleries,Assets,Containers,Content,Templates,Pages,Sitemaps,UrlRedirections` | 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 their full dependency tree — includes dependent content, pages, templates, assets, galleries, and containers |
| `--contentIDs` | string | _(empty)_ | Comma-separated list of target content IDs to process directly, bypassing the mappings lookup (e.g. `--contentIDs=121,1221`) |
Expand Down
25 changes: 25 additions & 0 deletions src/core/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export type EntityType =
| "gallery"
| "template"
| "sitemap"
| "urlRedirection"
| "auth"
| "system"
| "summary";
Expand Down Expand Up @@ -984,6 +985,30 @@ export class Logs {
},
};

// URL redirection logging methods
urlRedirection = {
created: (entity: any, details?: string, targetGuid?: string) => {
const itemName = entity?.originUrl || `URL Redirection ${entity?.id || "Unknown"}`;
this.logDataElement("urlRedirection", "created", "success", itemName, targetGuid, details);
},

updated: (entity: any, details?: string, targetGuid?: string) => {
const itemName = entity?.originUrl || `URL Redirection ${entity?.id || "Unknown"}`;
this.logDataElement("urlRedirection", "updated", "success", itemName, targetGuid, details);
},

skipped: (entity: any, details?: string, targetGuid?: string) => {
const itemName = entity?.originUrl || `URL Redirection`;
this.logDataElement("urlRedirection", "skipped", "skipped", itemName, targetGuid || this.guid, details);
},

error: (entity: any, apiError: any, targetGuid?: string) => {
const itemName = entity?.originUrl || `URL Redirection ${entity?.id || "Unknown"}`;
const errorDetails = apiError?.message || apiError || "Unknown error";
this.logDataElement("urlRedirection", "failed", "failed", itemName, targetGuid || this.guid, errorDetails);
},
};

// Sitemap logging methods
sitemap = {
downloaded: (entity: any, details?: string) => {
Expand Down
4 changes: 2 additions & 2 deletions src/core/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export const state: State = {
apiKeys: [],
channel: "website",
preview: true,
elements: "Models,Galleries,Assets,Containers,Content,Templates,Pages,Sitemaps",
elements: "Models,Galleries,Assets,Containers,Content,Templates,Pages,Sitemaps,UrlRedirections",

// File system
rootPath: "agility-files",
Expand Down Expand Up @@ -388,7 +388,7 @@ export function resetState() {
state.apiKeys = [];
state.channel = "website";
state.preview = true;
state.elements = "Models,Galleries,Assets,Containers,Content,Templates,Pages,Sitemaps";
state.elements = "Models,Galleries,Assets,Containers,Content,Templates,Pages,Sitemaps,UrlRedirections";

// File system
state.rootPath = "agility-files";
Expand Down
4 changes: 2 additions & 2 deletions src/core/system-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,10 @@ export const systemArgs = {
},
elements: {
describe:
"Comma-separated list of elements to process (Models,Galleries,Assets,Containers,Content,Templates,Pages,Sitemaps)",
"Comma-separated list of elements to process (Models,Galleries,Assets,Containers,Content,Templates,Pages,Sitemaps,UrlRedirections)",
demandOption: false,
type: "string" as const,
default: "Models,Galleries,Assets,Containers,Content,Templates,Pages,Sitemaps",
default: "Models,Galleries,Assets,Containers,Content,Templates,Pages,Sitemaps,UrlRedirections",
},

// **NEW: Selective Model-Based Sync Parameter (Task 103)**
Expand Down
17 changes: 17 additions & 0 deletions src/lib/getters/filesystem/get-url-redirections.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { fileOperations } from "../../../core";
import { UrlRedirectionSyncItem, UrlRedirectionSyncFile } from "../../../types/urlRedirection";

/**
* Get URL redirections from the filesystem without side effects.
* The Content Sync SDK stores them per-locale at urlredirections/urlredirections.json,
* so the fileOps instance must be locale-level. The set is instance-wide (the same for
* every locale), so reading one locale's file is sufficient.
* Pure function - no filesystem operations, delegates to fileOperations
*/
export function getUrlRedirectionsFromFileSystem(fileOps: fileOperations): UrlRedirectionSyncItem[] {
const data: UrlRedirectionSyncFile | null = fileOps.readJsonFile("urlredirections/urlredirections.json");
if (!data || !Array.isArray(data.items)) {
return [];
}
return data.items;
}
76 changes: 76 additions & 0 deletions src/lib/getters/filesystem/tests/get-url-redirections.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { resetState } from "core/state";
import { getUrlRedirectionsFromFileSystem } from "../get-url-redirections";

beforeEach(() => {
resetState();
jest.spyOn(console, "log").mockImplementation(() => {});
jest.spyOn(console, "warn").mockImplementation(() => {});
jest.spyOn(console, "error").mockImplementation(() => {});
});

afterEach(() => {
jest.restoreAllMocks();
});

function makeFileOps(readJsonFileImpl: (filePath: string) => any): any {
return { readJsonFile: jest.fn().mockImplementation(readJsonFileImpl) };
}

// ─── getUrlRedirectionsFromFileSystem ─────────────────────────────────────────

describe("getUrlRedirectionsFromFileSystem", () => {
it("returns the items array when data is well-formed", () => {
const items = [
{ id: 1, originUrl: "/old", destinationUrl: "/new", statusCode: 301 },
{ id: 2, originUrl: "/gone", destinationUrl: "/here", statusCode: 302 },
];
const fileOps = makeFileOps(() => ({ items, isUpToDate: true, lastAccessDate: "" }));

const result = getUrlRedirectionsFromFileSystem(fileOps);

expect(result).toEqual(items);
expect(fileOps.readJsonFile).toHaveBeenCalledWith("urlredirections/urlredirections.json");
});

it("returns an empty array when readJsonFile returns null", () => {
const fileOps = makeFileOps(() => null);
expect(getUrlRedirectionsFromFileSystem(fileOps)).toEqual([]);
});

it("returns an empty array when the data object has no items property", () => {
const fileOps = makeFileOps(() => ({ isUpToDate: true }));
expect(getUrlRedirectionsFromFileSystem(fileOps)).toEqual([]);
});

it("returns an empty array when items is not an array (string)", () => {
const fileOps = makeFileOps(() => ({ items: "not-an-array" }));
expect(getUrlRedirectionsFromFileSystem(fileOps)).toEqual([]);
});

it("returns an empty array when items is not an array (object)", () => {
const fileOps = makeFileOps(() => ({ items: { 0: { id: 1 } } }));
expect(getUrlRedirectionsFromFileSystem(fileOps)).toEqual([]);
});

it("returns an empty array when items is null", () => {
const fileOps = makeFileOps(() => ({ items: null }));
expect(getUrlRedirectionsFromFileSystem(fileOps)).toEqual([]);
});

it("returns an empty array when readJsonFile returns undefined", () => {
const fileOps = makeFileOps(() => undefined);
expect(getUrlRedirectionsFromFileSystem(fileOps)).toEqual([]);
});

it("returns an empty array when the items array is empty", () => {
const fileOps = makeFileOps(() => ({ items: [] }));
expect(getUrlRedirectionsFromFileSystem(fileOps)).toEqual([]);
});

it("always reads the fixed path regardless of arguments", () => {
const fileOps = makeFileOps(() => null);
getUrlRedirectionsFromFileSystem(fileOps);
expect(fileOps.readJsonFile).toHaveBeenCalledTimes(1);
expect(fileOps.readJsonFile).toHaveBeenCalledWith("urlredirections/urlredirections.json");
});
});
159 changes: 159 additions & 0 deletions src/lib/mappers/tests/url-redirection-mapper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { resetState, setState } from "core/state";
import { UrlRedirectionMapper } from "../url-redirection-mapper";

let tmpDir: string;

beforeAll(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agility-url-redir-mapper-"));
});

afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

let testCounter = 0;
let currentSrc: string;
let currentTgt: string;

function makeMapper(): UrlRedirectionMapper {
testCounter++;
currentSrc = `src-redir-${testCounter}`;
currentTgt = `tgt-redir-${testCounter}`;
return new UrlRedirectionMapper(currentSrc, currentTgt);
}

beforeEach(() => {
resetState();
setState({ rootPath: tmpDir });
jest.spyOn(console, "log").mockImplementation(() => {});
jest.spyOn(console, "warn").mockImplementation(() => {});
jest.spyOn(console, "error").mockImplementation(() => {});
});

afterEach(() => {
jest.restoreAllMocks();
});

// ─── constructor ──────────────────────────────────────────────────────────────

describe("UrlRedirectionMapper constructor", () => {
it("constructs without throwing", () => {
expect(() => makeMapper()).not.toThrow();
});
});

// ─── addMapping — new entry ───────────────────────────────────────────────────

describe("UrlRedirectionMapper.addMapping — new entry", () => {
it("adds a new mapping and persists it", () => {
const mapper = makeMapper();
mapper.addMapping(10, 20, "/old-path");

const found = mapper.getMapping(10, "source");
expect(found).not.toBeNull();
expect(found!.sourceUrlRedirectionID).toBe(10);
expect(found!.targetUrlRedirectionID).toBe(20);
expect(found!.originUrl).toBe("/old-path");
expect(found!.sourceGuid).toBe(currentSrc);
expect(found!.targetGuid).toBe(currentTgt);
});

it("stores multiple distinct mappings", () => {
const mapper = makeMapper();
mapper.addMapping(1, 100, "/a");
mapper.addMapping(2, 200, "/b");

expect(mapper.getMapping(1, "source")).not.toBeNull();
expect(mapper.getMapping(2, "source")).not.toBeNull();
});

it("does not create a duplicate when the same sourceUrlRedirectionID is added twice", () => {
const mapper = makeMapper();
mapper.addMapping(5, 50, "/url-1");
mapper.addMapping(5, 99, "/url-1-updated");

// There must still be exactly one mapping for source ID 5.
const bySource = mapper.getMapping(5, "source");
expect(bySource).not.toBeNull();
expect(bySource!.targetUrlRedirectionID).toBe(99);

// The old target ID (50) must not appear as a distinct entry.
const byOldTarget = mapper.getMapping(50, "target");
expect(byOldTarget).toBeNull();
});

it("updates targetUrlRedirectionID in place when sourceUrlRedirectionID already exists", () => {
const mapper = makeMapper();
mapper.addMapping(7, 70, "/page");
mapper.addMapping(7, 77, "/page-new");

const m = mapper.getMapping(7, "source")!;
expect(m.targetUrlRedirectionID).toBe(77);
expect(m.originUrl).toBe("/page-new");
});
});

// ─── getMapping — by source ───────────────────────────────────────────────────

describe("UrlRedirectionMapper.getMapping — by source", () => {
it("returns null when no mappings exist", () => {
const mapper = makeMapper();
expect(mapper.getMapping(999, "source")).toBeNull();
});

it("returns the mapping when found by sourceUrlRedirectionID", () => {
const mapper = makeMapper();
mapper.addMapping(11, 22, "/test");
const m = mapper.getMapping(11, "source");
expect(m).not.toBeNull();
expect(m!.sourceUrlRedirectionID).toBe(11);
});

it("returns null for an ID that was not added", () => {
const mapper = makeMapper();
mapper.addMapping(11, 22, "/test");
expect(mapper.getMapping(99, "source")).toBeNull();
});
});

// ─── getMapping — by target ───────────────────────────────────────────────────

describe("UrlRedirectionMapper.getMapping — by target", () => {
it("returns null when no mappings exist", () => {
const mapper = makeMapper();
expect(mapper.getMapping(999, "target")).toBeNull();
});

it("returns the mapping when found by targetUrlRedirectionID", () => {
const mapper = makeMapper();
mapper.addMapping(33, 44, "/target-test");
const m = mapper.getMapping(44, "target");
expect(m).not.toBeNull();
expect(m!.targetUrlRedirectionID).toBe(44);
});

it("returns null for a target ID that was not added", () => {
const mapper = makeMapper();
mapper.addMapping(33, 44, "/target-test");
expect(mapper.getMapping(55, "target")).toBeNull();
});
});

// ─── persistence (saveMappingFile / loadMapping) ──────────────────────────────

describe("UrlRedirectionMapper — mapping persistence", () => {
it("persists mappings so a new mapper instance can reload them", () => {
const src = `persist-src-${testCounter + 100}`;
const tgt = `persist-tgt-${testCounter + 100}`;
const mapper1 = new UrlRedirectionMapper(src, tgt);
mapper1.addMapping(60, 600, "/persist-me");

const mapper2 = new UrlRedirectionMapper(src, tgt);
const found = mapper2.getMapping(60, "source");
expect(found).not.toBeNull();
expect(found!.targetUrlRedirectionID).toBe(600);
});
});
Loading
Loading