GitHub(nextgroup2706/ken_nogi)は今後使わず自社Gitea運用に切替え。 NodeSrvは旧リポジトリの履歴を破棄しファイルのみ統合(Dokploy用サービスアカウントは 別途mygit-admin/NodeSrv.gitに履歴あり)。notepmエクスポート(12GB)とPleasanter インストーラzip(208MB)はサイズが大きいため.gitignoreで除外。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
345 lines
21 KiB
JavaScript
345 lines
21 KiB
JavaScript
const { test } = require("node:test");
|
||
const assert = require("node:assert");
|
||
const { masterItemToAttributes } = require("../src/commands/sync-keycloak");
|
||
const { RESULT_ID_ATTR } = require("../src/lib/keycloakColumnSync");
|
||
|
||
const EMPTY_CHOICE_MAPS = { positionIdToTitle: new Map(), levelIdToTitle: new Map(), userTypeIdToTitle: new Map() };
|
||
|
||
// applySyncは常にsyncOrgUnitTree(組織階層グループ同期)を実行するため、全テストのfetchImplで
|
||
// GET /groups?max=1000(トップレベル一覧)とPOST /groups("組織"ルートグループ作成、無ければ)の
|
||
// 両方を素通りさせるヘルパー。orgUnits=[]なら以降のツリー構築ループは何も行わない
|
||
function withOrgRootGroupMocks(fetchImpl) {
|
||
return async (url, opts) => {
|
||
const urlStr = String(url);
|
||
if (urlStr.endsWith("/groups?max=1000")) return { ok: true, json: async () => [] };
|
||
if (urlStr.endsWith("/groups") && opts && opts.method === "POST") {
|
||
const body = JSON.parse(opts.body);
|
||
if (body.name === "組織") {
|
||
return { ok: true, headers: { get: (n) => (n.toLowerCase() === "location" ? "https://kc.example.test/admin/realms/nexthd/groups/org-root" : null) } };
|
||
}
|
||
}
|
||
return fetchImpl(url, opts);
|
||
};
|
||
}
|
||
|
||
test("masterItemToAttributes extracts every column's value as a Keycloak string-array attribute, plus the ResultId key", () => {
|
||
const columns = [{ ColumnName: "ClassA" }, { ColumnName: "Check062" }, { ColumnName: "Class001" }];
|
||
const item = { ResultId: 1350, ClassA: "u1-uuid", Check062: true, Class001: "山田" };
|
||
|
||
const attrs = masterItemToAttributes(item, columns);
|
||
|
||
assert.deepStrictEqual(attrs[RESULT_ID_ATTR], ["1350"]);
|
||
assert.deepStrictEqual(attrs.ClassA, ["u1-uuid"]);
|
||
assert.deepStrictEqual(attrs.Check062, ["true"]);
|
||
assert.deepStrictEqual(attrs.Class001, ["山田"]);
|
||
});
|
||
|
||
test("masterItemToAttributes converts missing/null/undefined column values to an empty string", () => {
|
||
const { masterItemToAttributes } = require("../src/commands/sync-keycloak");
|
||
const attrs = masterItemToAttributes({ ResultId: 1 }, [{ ColumnName: "Class999" }]);
|
||
assert.deepStrictEqual(attrs.Class999, [""]);
|
||
});
|
||
|
||
test("masterItemToAttributes converts Pleasanter's unset-date sentinel (1899-...) to an empty string", () => {
|
||
const item = { ResultId: 1, Date007: "1899-12-30T00:00:00", Date060: "1899/12/31" };
|
||
const columns = [{ ColumnName: "Date007" }, { ColumnName: "Date060" }];
|
||
|
||
const attrs = masterItemToAttributes(item, columns);
|
||
|
||
assert.deepStrictEqual(attrs.Date007, [""]);
|
||
assert.deepStrictEqual(attrs.Date060, [""]);
|
||
});
|
||
|
||
test("masterItemToAttributes keeps a real date value and does not treat falsy non-date values (false, 0) as unset", () => {
|
||
const item = { ResultId: 1, Date007: "2026-01-15T00:00:00", Check062: false, Class999: 0 };
|
||
const columns = [{ ColumnName: "Date007" }, { ColumnName: "Check062" }, { ColumnName: "Class999" }];
|
||
|
||
const attrs = masterItemToAttributes(item, columns);
|
||
|
||
assert.deepStrictEqual(attrs.Date007, ["2026-01-15T00:00:00"]);
|
||
assert.deepStrictEqual(attrs.Check062, ["false"]);
|
||
assert.deepStrictEqual(attrs.Class999, ["0"]);
|
||
});
|
||
|
||
test("masterItemToKeycloakUser builds a emailLocalPart_ResultId username, firstName/lastName from Class002/Class001, and enabled from Check062 (not ClassZ)", () => {
|
||
const { masterItemToKeycloakUser } = require("../src/commands/sync-keycloak");
|
||
const user = masterItemToKeycloakUser({ ResultId: 831, ClassA: "uuid-1", ClassB: "taro.yamada@next-hd.co.jp", Class001: "山田", Class002: "太郎", Check062: true, ClassZ: "有" }, [{ ColumnName: "ClassA" }]);
|
||
assert.strictEqual(user.username, "taro.yamada_831");
|
||
assert.strictEqual(user.firstName, "太郎");
|
||
assert.strictEqual(user.lastName, "山田");
|
||
assert.strictEqual(user.email, "taro.yamada@next-hd.co.jp");
|
||
assert.strictEqual(user.enabled, true);
|
||
});
|
||
|
||
test("masterItemToKeycloakUser falls back to empty string for username-local-part/firstName/lastName when ClassB/Class001/Class002 are missing", () => {
|
||
const { masterItemToKeycloakUser } = require("../src/commands/sync-keycloak");
|
||
const user = masterItemToKeycloakUser({ ResultId: 999, Check062: false, ClassZ: "無" }, []);
|
||
assert.strictEqual(user.username, "_999");
|
||
assert.strictEqual(user.firstName, "");
|
||
assert.strictEqual(user.lastName, "");
|
||
});
|
||
|
||
test("mergeKeycloakUser keeps the existing username unchanged (Keycloak editUsernameAllowed=false rejects username edits) while overwriting firstName/lastName/email/enabled with desired values, keeping protected/current-column attributes, and dropping attributes for columns no longer in Columns", () => {
|
||
const { mergeKeycloakUser } = require("../src/commands/sync-keycloak");
|
||
const { RESULT_ID_ATTR } = require("../src/lib/keycloakColumnSync");
|
||
const columns = [{ ColumnName: "ClassA" }]; // Class999はもうColumnsに無い(削除された想定)
|
||
const existingUser = {
|
||
id: "kc-1", username: "taro.yamada_831", email: "old@example.com", enabled: true,
|
||
firstName: "Existing", lastName: "User",
|
||
attributes: {
|
||
[RESULT_ID_ATTR]: ["831"],
|
||
ClassA: ["old-value"],
|
||
Class999: ["deleted-column-old-value"], // 削除されるべき
|
||
},
|
||
};
|
||
const desired = {
|
||
username: "different-username_831", firstName: "太郎", lastName: "山田",
|
||
email: "new@example.com", enabled: false,
|
||
attributes: { [RESULT_ID_ATTR]: ["831"], ClassA: ["new-value"] },
|
||
};
|
||
|
||
const merged = mergeKeycloakUser(existingUser, desired, columns);
|
||
|
||
assert.strictEqual(merged.username, "taro.yamada_831"); // 既存usernameを維持(desired.usernameでは上書きしない)
|
||
assert.strictEqual(merged.firstName, "太郎"); // マスタが正、firstNameは上書き
|
||
assert.strictEqual(merged.lastName, "山田"); // マスタが正、lastNameは上書き
|
||
assert.deepStrictEqual(merged.attributes.ClassA, ["new-value"]); // 新しい値で上書き
|
||
assert.strictEqual(merged.attributes.Class999, undefined); // Columnsから消えた属性は削除される
|
||
assert.deepStrictEqual(merged.attributes[RESULT_ID_ATTR], ["831"]); // 突合キーは常に保持
|
||
});
|
||
|
||
test("flatGroupNamesFromMaster resolves Class057/058/059 choice-master ResultIds to prefixed names, skipping unresolved ones (Pleasanter Groups is no longer consulted)", () => {
|
||
const { flatGroupNamesFromMaster } = require("../src/commands/sync-keycloak");
|
||
const choiceMaps = {
|
||
positionIdToTitle: new Map([["100", "課長"]]),
|
||
levelIdToTitle: new Map([["200", "正社員"]]),
|
||
userTypeIdToTitle: new Map([["300", "一般"]]),
|
||
};
|
||
const names = flatGroupNamesFromMaster({ Class057: "100", Class058: "200", Class059: "300" }, choiceMaps);
|
||
assert.deepStrictEqual(names, ["役職:課長", "職級:正社員", "利用権限タイプ:一般"]);
|
||
|
||
assert.deepStrictEqual(flatGroupNamesFromMaster({ Class057: "999" }, choiceMaps), []); // 未解決IDは無視
|
||
});
|
||
|
||
test("parseOrgUnitsField parses \"orgUnitId=orgUnitName;...\" (merge-master.js's orgUnitsToText format) back into refs", () => {
|
||
const { parseOrgUnitsField } = require("../src/commands/sync-keycloak");
|
||
assert.deepStrictEqual(
|
||
parseOrgUnitsField("ou-1=情報システム部;ou-2=管理本部"),
|
||
[{ orgUnitId: "ou-1", orgUnitName: "情報システム部" }, { orgUnitId: "ou-2", orgUnitName: "管理本部" }]
|
||
);
|
||
assert.deepStrictEqual(parseOrgUnitsField(""), []);
|
||
assert.deepStrictEqual(parseOrgUnitsField(undefined), []);
|
||
});
|
||
|
||
test("topoSortOrgUnits orders parents before children by displayLevel then displayOrder", () => {
|
||
const { topoSortOrgUnits } = require("../src/commands/sync-keycloak");
|
||
const orgUnits = [
|
||
{ orgUnitId: "c", displayLevel: 2, displayOrder: 1 },
|
||
{ orgUnitId: "a", displayLevel: 1, displayOrder: 2 },
|
||
{ orgUnitId: "b", displayLevel: 1, displayOrder: 1 },
|
||
];
|
||
assert.deepStrictEqual(topoSortOrgUnits(orgUnits).map((o) => o.orgUnitId), ["b", "a", "c"]);
|
||
});
|
||
|
||
test("syncOrgUnitTree creates a new hierarchy of Keycloak subgroups under the \"組織\" root, keyed by attributes.orgUnitId (regression: same-name org units under different parents, e.g. 営業設計部)", async () => {
|
||
const { syncOrgUnitTree } = require("../src/commands/sync-keycloak");
|
||
const orgUnits = [
|
||
{ orgUnitId: "west", orgUnitName: "西東京建設", parentOrgUnitId: null, displayLevel: 1, displayOrder: 1 },
|
||
{ orgUnitId: "daiichi", orgUnitName: "第一建材工業", parentOrgUnitId: null, displayLevel: 1, displayOrder: 2 },
|
||
{ orgUnitId: "west-eigyo", orgUnitName: "営業設計部", parentOrgUnitId: "west", displayLevel: 2, displayOrder: 1 },
|
||
{ orgUnitId: "daiichi-eigyo", orgUnitName: "営業設計部", parentOrgUnitId: "daiichi", displayLevel: 2, displayOrder: 1 },
|
||
];
|
||
|
||
const createSubGroupCalls = [];
|
||
const fetchImpl = async (url, opts) => {
|
||
const urlStr = String(url);
|
||
if (urlStr.endsWith("/groups?max=1000")) return { ok: true, json: async () => [] };
|
||
if (urlStr.endsWith("/groups") && opts.method === "POST") {
|
||
return { ok: true, headers: { get: (n) => (n.toLowerCase() === "location" ? "https://kc.example.test/admin/realms/nexthd/groups/org-root" : null) } };
|
||
}
|
||
if (urlStr.match(/\/groups\/[^/]+\/children$/) && opts.method === "POST") {
|
||
const body = JSON.parse(opts.body);
|
||
createSubGroupCalls.push({ parentId: urlStr.match(/\/groups\/([^/]+)\/children$/)[1], body });
|
||
return { ok: true, headers: { get: (n) => (n.toLowerCase() === "location" ? `https://kc.example.test/admin/realms/nexthd/groups/g-${body.attributes.orgUnitId[0]}` : null) } };
|
||
}
|
||
throw new Error(`unexpected fetch: ${urlStr}`);
|
||
};
|
||
|
||
const { orgUnitIdToGroupId, created } = await syncOrgUnitTree(orgUnits, { baseUrl: "https://kc.example.test", realm: "nexthd", accessToken: "t", fetchImpl });
|
||
|
||
assert.strictEqual(created, 5); // ルート「組織」+4 org units
|
||
// 同名「営業設計部」でも別のKeycloakグループIDに解決される(orgUnitId軸のため取り違えない)
|
||
assert.notStrictEqual(orgUnitIdToGroupId.get("west-eigyo"), orgUnitIdToGroupId.get("daiichi-eigyo"));
|
||
const westEigyoCall = createSubGroupCalls.find((c) => c.body.attributes.orgUnitId[0] === "west-eigyo");
|
||
assert.strictEqual(westEigyoCall.parentId, orgUnitIdToGroupId.get("west")); // 親は西東京建設グループ
|
||
});
|
||
|
||
test("syncOrgUnitTree in dryRun mode does not call any write API but still returns usable (dummy) group ids for downstream user-group-assignment estimation", async () => {
|
||
const { syncOrgUnitTree } = require("../src/commands/sync-keycloak");
|
||
const orgUnits = [{ orgUnitId: "ou-1", orgUnitName: "西東京建設", parentOrgUnitId: null, displayLevel: 1, displayOrder: 1 }];
|
||
|
||
const fetchImpl = async (url) => {
|
||
const urlStr = String(url);
|
||
if (urlStr.endsWith("/groups?max=1000")) return { ok: true, json: async () => [] };
|
||
throw new Error(`unexpected write call in dry run: ${urlStr}`);
|
||
};
|
||
|
||
const { orgUnitIdToGroupId, created } = await syncOrgUnitTree(orgUnits, { baseUrl: "https://kc.example.test", realm: "nexthd", accessToken: "t", fetchImpl, dryRun: true });
|
||
|
||
assert.strictEqual(created, 2); // ルート + 1 org unit(見積もりのみ)
|
||
assert.ok(orgUnitIdToGroupId.get("ou-1")); // ダミーIDが払い出される
|
||
});
|
||
|
||
test("planSync includes every master item without filtering by ClassZ (needed for delete detection)", () => {
|
||
const { planSync } = require("../src/commands/sync-keycloak");
|
||
const masterItems = [
|
||
{ ResultId: 1, ClassA: "u1", ClassZ: "有" },
|
||
{ ResultId: 2, ClassA: "u2", ClassZ: "無" },
|
||
];
|
||
const plan = planSync(masterItems);
|
||
assert.strictEqual(plan.length, 2);
|
||
});
|
||
|
||
test("applySync creates a new Keycloak user only when ClassZ is 有, and skips creation for LINEWORKS-absent (ClassZ=無) master-only items with no existing Keycloak user", async () => {
|
||
const { planSync, applySync } = require("../src/commands/sync-keycloak");
|
||
const columns = [{ ColumnName: "ClassA", LabelText: "社員ID" }];
|
||
const masterItems = [
|
||
{ ResultId: 831, ClassA: "u1-uuid", ClassB: "taro@next-hd.co.jp", Check062: true, ClassZ: "有", Description062: "" },
|
||
{ ResultId: 1350, ClassA: "", ClassB: "", Check062: false, ClassZ: "無", Description062: "" }, // プリザンター専用、Keycloak未登録
|
||
];
|
||
const plan = planSync(masterItems);
|
||
|
||
const calls = [];
|
||
let linkedFederatedIdentityBody;
|
||
const fetchImpl = withOrgRootGroupMocks(async (url, opts) => {
|
||
const urlStr = String(url);
|
||
calls.push(urlStr);
|
||
if (urlStr.endsWith("/users/profile") && (!opts || !opts.method || opts.method === "GET")) return { ok: true, json: async () => ({ attributes: [{ name: "email" }] }) };
|
||
if (urlStr.endsWith("/users/profile") && opts.method === "PUT") return { ok: true };
|
||
if (urlStr.includes("/users?q=")) return { ok: true, json: async () => [] }; // どちらも未登録
|
||
if (urlStr.includes("/users?email=")) return { ok: true, json: async () => [] };
|
||
if (urlStr.endsWith("/users") && opts.method === "POST") {
|
||
return { ok: true, headers: { get: (n) => (n.toLowerCase() === "location" ? "https://kc.example.test/admin/realms/nexthd/users/new-user-id" : null) } };
|
||
}
|
||
if (urlStr.endsWith("/users/new-user-id/federated-identity/lineworks") && opts.method === "POST") {
|
||
linkedFederatedIdentityBody = JSON.parse(opts.body);
|
||
return { ok: true };
|
||
}
|
||
throw new Error(`unexpected fetch: ${urlStr}`);
|
||
});
|
||
|
||
const result = await applySync(plan, columns, [], EMPTY_CHOICE_MAPS, { baseUrl: "https://kc.example.test", realm: "nexthd", accessToken: "t", fetchImpl });
|
||
|
||
assert.strictEqual(result.created, 1); // ResultId=831のみ作成
|
||
assert.ok(!calls.some((u) => u.endsWith("/users") && u.includes("POST"))); // 簡易チェック(実際はPOST回数で見る)
|
||
// 新規作成直後、確認なしログインのためlineworks IdPとのfederated-identityを事前登録する
|
||
assert.deepStrictEqual(linkedFederatedIdentityBody, { identityProvider: "lineworks", userId: "taro@next-hd.co.jp", userName: "taro@next-hd.co.jp" });
|
||
});
|
||
|
||
test("applySync updates an existing user's enabled flag based on Check062 even when ClassZ is 無 (deactivation, not deletion)", async () => {
|
||
const { planSync, applySync } = require("../src/commands/sync-keycloak");
|
||
const columns = [{ ColumnName: "ClassA", LabelText: "社員ID" }];
|
||
const masterItems = [{ ResultId: 10, ClassA: "u1", ClassB: "taro@next-hd.co.jp", Check062: false, ClassZ: "無", Description062: "" }]; // 退職済み、レコード自体は残っている
|
||
const plan = planSync(masterItems);
|
||
|
||
let putUserBody, deleteCalled = false, linkedFederatedIdentityBody;
|
||
const fetchImpl = withOrgRootGroupMocks(async (url, opts) => {
|
||
const urlStr = String(url);
|
||
if (urlStr.endsWith("/users/profile")) return { ok: true, json: async () => ({ attributes: [{ name: "ClassA" }, { name: "pleasanterResultId" }] }) };
|
||
if (urlStr.includes("/users?q=")) return { ok: true, json: async () => [{ id: "existing-id", username: "pleasanter-10", attributes: { pleasanterResultId: ["10"], ClassA: ["old"] } }] };
|
||
if (urlStr.endsWith("/users/existing-id") && opts.method === "PUT") { putUserBody = JSON.parse(opts.body); return { ok: true }; }
|
||
if (urlStr.endsWith("/users/existing-id") && opts.method === "DELETE") { deleteCalled = true; return { ok: true }; }
|
||
if (urlStr.endsWith("/users/existing-id/federated-identity") && (!opts || !opts.method || opts.method === "GET")) return { ok: true, json: async () => [] };
|
||
if (urlStr.endsWith("/users/existing-id/federated-identity/lineworks") && opts.method === "POST") {
|
||
linkedFederatedIdentityBody = JSON.parse(opts.body);
|
||
return { ok: true };
|
||
}
|
||
throw new Error(`unexpected fetch: ${urlStr}`);
|
||
});
|
||
|
||
const result = await applySync(plan, columns, [], EMPTY_CHOICE_MAPS, { baseUrl: "https://kc.example.test", realm: "nexthd", accessToken: "t", fetchImpl, lastSyncedResultIds: [10] });
|
||
|
||
assert.strictEqual(result.updated, 1);
|
||
assert.strictEqual(putUserBody.enabled, false); // 無効化
|
||
assert.strictEqual(deleteCalled, false); // 削除はされない(レコード自体は存在するため)
|
||
// 既存ユーザーでもfederated-identity未リンクなら事前登録する
|
||
assert.deepStrictEqual(linkedFederatedIdentityBody, { identityProvider: "lineworks", userId: "taro@next-hd.co.jp", userName: "taro@next-hd.co.jp" });
|
||
});
|
||
|
||
test("applySync does not re-link federated-identity when the user is already linked to lineworks", async () => {
|
||
const { planSync, applySync } = require("../src/commands/sync-keycloak");
|
||
const columns = [{ ColumnName: "ClassA", LabelText: "社員ID" }];
|
||
const masterItems = [{ ResultId: 10, ClassA: "u1", ClassB: "taro@next-hd.co.jp", Check062: true, ClassZ: "有", Description062: "" }];
|
||
const plan = planSync(masterItems);
|
||
|
||
let linkCalled = false;
|
||
const fetchImpl = withOrgRootGroupMocks(async (url, opts) => {
|
||
const urlStr = String(url);
|
||
if (urlStr.endsWith("/users/profile")) return { ok: true, json: async () => ({ attributes: [{ name: "ClassA" }, { name: "pleasanterResultId" }] }) };
|
||
if (urlStr.includes("/users?q=")) return { ok: true, json: async () => [{ id: "existing-id", username: "pleasanter-10", attributes: { pleasanterResultId: ["10"], ClassA: ["old"] } }] };
|
||
if (urlStr.endsWith("/users/existing-id") && opts.method === "PUT") return { ok: true };
|
||
if (urlStr.endsWith("/users/existing-id/federated-identity") && (!opts || !opts.method || opts.method === "GET")) {
|
||
return { ok: true, json: async () => [{ identityProvider: "lineworks", userId: "taro@next-hd.co.jp" }] };
|
||
}
|
||
if (urlStr.endsWith("/users/existing-id/federated-identity/lineworks") && opts.method === "POST") {
|
||
linkCalled = true;
|
||
return { ok: true };
|
||
}
|
||
throw new Error(`unexpected fetch: ${urlStr}`);
|
||
});
|
||
|
||
await applySync(plan, columns, [], EMPTY_CHOICE_MAPS, { baseUrl: "https://kc.example.test", realm: "nexthd", accessToken: "t", fetchImpl, lastSyncedResultIds: [10] });
|
||
|
||
assert.strictEqual(linkCalled, false);
|
||
});
|
||
|
||
test("applySync deletes the Keycloak user for a ResultId that was synced last time but is absent from today's master (record physically removed)", async () => {
|
||
const { applySync } = require("../src/commands/sync-keycloak");
|
||
const columns = [{ ColumnName: "ClassA", LabelText: "社員ID" }];
|
||
const plan = []; // 今回のマスタにResultId=99は存在しない
|
||
|
||
let deletedUserId;
|
||
const fetchImpl = withOrgRootGroupMocks(async (url, opts) => {
|
||
const urlStr = String(url);
|
||
if (urlStr.endsWith("/users/profile")) return { ok: true, json: async () => ({ attributes: [{ name: "pleasanterResultId" }] }) };
|
||
if (urlStr.includes("/users?q=pleasanterResultId%3A99")) return { ok: true, json: async () => [{ id: "to-delete-id" }] };
|
||
if (urlStr.endsWith("/users/to-delete-id") && opts.method === "DELETE") { deletedUserId = "to-delete-id"; return { ok: true }; }
|
||
throw new Error(`unexpected fetch: ${urlStr}`);
|
||
});
|
||
|
||
const result = await applySync(plan, columns, [], EMPTY_CHOICE_MAPS, { baseUrl: "https://kc.example.test", realm: "nexthd", accessToken: "t", fetchImpl, lastSyncedResultIds: [99] });
|
||
|
||
assert.strictEqual(result.deleted, 1);
|
||
assert.strictEqual(deletedUserId, "to-delete-id");
|
||
});
|
||
|
||
test("applySync in dryRun mode counts additions/removals/create/update/delete/org-group-creations without calling any write API", async () => {
|
||
const { planSync, applySync } = require("../src/commands/sync-keycloak");
|
||
const columns = [{ ColumnName: "ClassA", LabelText: "社員ID" }];
|
||
const masterItems = [{ ResultId: 1, ClassA: "u1", ClassB: "", Check062: true, ClassZ: "有", Description062: "" }];
|
||
const plan = planSync(masterItems);
|
||
const orgUnits = [{ orgUnitId: "ou-1", orgUnitName: "西東京建設", parentOrgUnitId: null, displayLevel: 1, displayOrder: 1 }];
|
||
|
||
const calls = [];
|
||
const fetchImpl = async (url, opts) => {
|
||
const urlStr = String(url);
|
||
calls.push({ url: urlStr, method: opts && opts.method });
|
||
if (urlStr.endsWith("/users/profile")) return { ok: true, json: async () => ({ attributes: [{ name: "Class999" }, { name: "pleasanterResultId" }] }) };
|
||
if (urlStr.endsWith("/groups?max=1000")) return { ok: true, json: async () => [] };
|
||
if (urlStr.includes("/users?q=")) return { ok: true, json: async () => [] };
|
||
throw new Error(`unexpected fetch in dry run: ${urlStr}`);
|
||
};
|
||
|
||
const result = await applySync(plan, columns, orgUnits, EMPTY_CHOICE_MAPS, {
|
||
baseUrl: "https://kc.example.test", realm: "nexthd", accessToken: "t", fetchImpl, dryRun: true,
|
||
lastSyncedColumnNames: ["Class999"], lastSyncedResultIds: [99], // 99は今回planに無いので削除予定1件になるはず
|
||
});
|
||
|
||
assert.strictEqual(result.profileAttributesAdded, 1); // ClassA
|
||
assert.strictEqual(result.profileAttributesRemoved, 1); // Class999
|
||
assert.strictEqual(result.created, 1);
|
||
assert.strictEqual(result.deleted, 1); // 99が削除予定(dryRunなので実際のDELETE呼び出しは無い)
|
||
assert.strictEqual(result.orgGroupsCreated, 2); // ルート「組織」+ ou-1(見積もりのみ)
|
||
assert.ok(!calls.some((c) => c.method === "POST" || c.method === "PUT" || c.method === "DELETE"));
|
||
});
|