ken_nogi/NodeSrv/apps/org-master-sync/test/merge-master.test.js
Kenichiro NOGI ce58cb4be4 初回コミット: dev配下(NodeSrv/Pleasanter等)をGitea管理下に統合
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>
2026-09-04 15:37:06 +09:00

506 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const { test } = require("node:test");
const assert = require("node:assert");
const {
planMerge, applyMerge, i18nName, orgUnitRefs, orgUnitsToText, pleasanterUserToFields,
ensureChoiceMasterId,
} = require("../src/commands/merge-master");
function makeUser(overrides = {}) {
return {
userId: "u1",
email: "taro.yamada@next-hd.co.jp",
privateEmail: "taro.private@example.com",
aliasEmails: ["taro.alias@next-hd.co.jp"],
userName: {
lastName: "山田",
firstName: "太郎",
phoneticLastName: "ヤマダ",
phoneticFirstName: "タロウ",
},
i18nNames: [{ language: "en_US", lastName: "Yamada", firstName: "Taro" }],
nickName: "タロー",
employeeNumber: "400123",
telephone: "03-1234-5678",
cellPhone: "+81 090-1234-5678",
location: "東京本社",
birthday: "1990-04-01",
hiredDate: "2015-04-01",
organizations: [{ levelName: "正社員", orgUnits: [{ orgUnitId: "ou-1", orgUnitName: "情報システム部", primary: true, positionName: "課長" }] }],
userTypeName: "一般",
isSuspended: false,
isAdministrator: false,
leaveOfAbsence: { isLeaveOfAbsence: false },
...overrides,
};
}
test("planMerge creates a new record when the employeeId is not in the existing master", () => {
const snapshot = { fetchedAt: "2026-08-08T00:00:00.000Z", users: [makeUser()], orgUnits: [], positions: [], levels: [], userTypes: [] };
const plan = planMerge(snapshot, []);
assert.strictEqual(plan.creates.length, 1);
assert.strictEqual(plan.updates.length, 0);
assert.strictEqual(plan.retirements.length, 0);
const fields = plan.creates[0].fields;
// タブ1: 基本情報
assert.strictEqual(fields.ClassA, "u1");
assert.strictEqual(fields.ClassB, "taro.yamada@next-hd.co.jp");
assert.strictEqual(fields.Class001, "山田");
assert.strictEqual(fields.Class002, "太郎");
assert.strictEqual(fields.Class003, "ヤマダ");
assert.strictEqual(fields.Class004, "タロウ");
assert.strictEqual(fields.Class005, "Yamada");
assert.strictEqual(fields.Class006, "Taro");
assert.strictEqual(fields.Date007, "1990-04-01");
assert.strictEqual(fields.DateA, "2026-08-08T00:00:00.000Z");
// タブ2: プリザンター管理項目はplanMergeの時点では書かないClass035はapplyMergeがグループ解決後に追加する
assert.strictEqual(fields.Class035, undefined);
assert.strictEqual(fields.Class011, undefined);
assert.strictEqual(fields.Body, undefined);
// planMergeはuserをそのまま持ち越すapplyMergeでのグループ解決に使う
assert.strictEqual(plan.creates[0].user.userId, "u1");
// タブ3: LINEWORKS固有情報
assert.strictEqual(fields.Class051, "taro.private@example.com");
assert.strictEqual(fields.Class052, "タロー");
assert.strictEqual(fields.Class053, "400123");
assert.strictEqual(fields.Class054, "03-1234-5678");
assert.strictEqual(fields.Class055, "+81 090-1234-5678");
assert.strictEqual(fields.Class056, "東京本社");
// Class057/058/059役職・職級・利用権限タイプは外部マスタのResultId解決が必要なため、
// planMerge時点では存在せずapplyMergeが解決する下記のapplyMergeテスト参照
assert.strictEqual(fields.Class057, undefined);
assert.strictEqual(fields.Class058, undefined);
assert.strictEqual(fields.Class059, undefined);
assert.strictEqual(fields.Date060, "2015-04-01");
assert.strictEqual(fields.Description061, "taro.alias@next-hd.co.jp");
assert.strictEqual(fields.Description062, "ou-1=情報システム部");
assert.strictEqual(fields.Check062, true);
assert.strictEqual(fields.Check063, false);
assert.strictEqual(fields.Check064, false);
assert.strictEqual(fields.Check065, false);
});
test("planMerge updates an existing record matched by employeeId (ClassA)", () => {
const snapshot = { fetchedAt: "2026-08-08T00:00:00.000Z", users: [makeUser({ organizations: [{ levelName: "正社員", orgUnits: [{ orgUnitName: "情報システム部", primary: true, positionName: "部長" }] }] })], orgUnits: [], positions: [], levels: [], userTypes: [] };
const existing = [{ ResultId: 10, ClassA: "u1", ClassB: "taro.yamada@next-hd.co.jp", Check062: true }];
const plan = planMerge(snapshot, existing);
assert.strictEqual(plan.creates.length, 0);
assert.strictEqual(plan.updates.length, 1);
assert.strictEqual(plan.updates[0].itemId, 10);
assert.strictEqual(plan.updates[0].fields.Class057, undefined);
});
test("pleasanterUserToFields maps a Pleasanter Users API record onto tab-2 fields, including the AllowXxx/LoginExpiration properties found only in live responses", () => {
const pUser = {
UserId: 42, LoginId: "hayato", Name: "中野 隼人", UserCode: "", Gender: "", Language: "ja",
TimeZone: "Tokyo Standard Time", DeptId: 7, Manager: 3, TenantManager: false, Theme: "",
Body: "説明文", PasswordExpirationTime: "2023-08-31T12:00:00", Disabled: false, Lockout: false,
AllowCreationAtTopSite: true, AllowGroupAdministration: false, AllowGroupCreation: true,
AllowApi: false, AllowMovingFromTopSite: true, SecretKey: "abc123",
LoginExpirationLimit: "2030-01-01T00:00:00", LoginExpirationPeriod: 90,
};
assert.deepStrictEqual(pleasanterUserToFields(pUser), {
Class011: "42", Class012: "hayato", Class013: "中野 隼人", Class014: "", Class015: "", Class016: "ja",
Class017: "Tokyo Standard Time", Class018: "7", Class019: "3", Check020: false, Class021: "",
Description022: "説明文", Class023: "2023-08-31T12:00:00",
Check024: true, Check025: false, Check026: true, Check027: false, Check028: true,
Check029: false, Check030: false, Check031: true,
Class032: "2030-01-01T00:00:00", Class033: "90", Class036: "",
});
});
test("pleasanterUserToFields maps MailAddresses[0] to Class036 (PLメールアドレス), empty string when absent", () => {
const withMail = pleasanterUserToFields({ UserId: 1, LoginId: "x", Name: "x", MailAddresses: ["taro@nexthd.jp", "taro2@nexthd.jp"] });
assert.strictEqual(withMail.Class036, "taro@nexthd.jp");
const withoutMail = pleasanterUserToFields({ UserId: 1, LoginId: "x", Name: "x" });
assert.strictEqual(withoutMail.Class036, "");
});
test("pleasanterUserToFields treats DeptId=0, Manager=0, and the 1899 sentinel dates as unset", () => {
const pUser = {
UserId: 1, LoginId: "admin", Name: "管理者", DeptId: 0, Manager: 0,
PasswordExpirationTime: "1899-12-30T00:00:00", LoginExpirationLimit: "1899-12-30T00:00:00",
};
const fields = pleasanterUserToFields(pUser);
assert.strictEqual(fields.Class018, "");
assert.strictEqual(fields.Class019, "");
assert.strictEqual(fields.Class023, "");
assert.strictEqual(fields.Class032, "");
});
test("planMerge mirrors tab-2 fields from a Pleasanter user matched by email, and leaves them unset when no match exists", () => {
const snapshot = { fetchedAt: "2026-08-08T00:00:00.000Z", users: [makeUser()], orgUnits: [], positions: [], levels: [], userTypes: [] };
const pleasanterUsers = [{ UserId: 99, LoginId: "taro", Name: "山田 太郎", MailAddresses: ["taro.yamada@next-hd.co.jp"], DeptId: 3 }];
const withMatch = planMerge(snapshot, [], pleasanterUsers);
assert.strictEqual(withMatch.creates[0].fields.Class011, "99");
assert.strictEqual(withMatch.creates[0].fields.Class018, "3");
const withoutMatch = planMerge(snapshot, [], []);
assert.strictEqual(withoutMatch.creates[0].fields.Class011, undefined);
});
test("planMerge matches a Pleasanter user by email local-part even when the domain differs (next-hd.co.jp vs nexthd.jp)", () => {
const snapshot = { fetchedAt: "2026-08-08T00:00:00.000Z", users: [makeUser()], orgUnits: [], positions: [], levels: [], userTypes: [] };
const pleasanterUsers = [{ UserId: 36, LoginId: "ntkco12216", Name: "山田 太郎", MailAddresses: ["taro.yamada@nexthd.jp"] }];
const plan = planMerge(snapshot, [], pleasanterUsers);
assert.strictEqual(plan.needsReview.length, 0);
assert.strictEqual(plan.creates[0].fields.Class011, "36");
});
test("planMerge does not report needsReview when the same Pleasanter user simply has two mail addresses sharing a local-part", () => {
const snapshot = { fetchedAt: "2026-08-08T00:00:00.000Z", users: [makeUser()], orgUnits: [], positions: [], levels: [], userTypes: [] };
const pleasanterUsers = [{ UserId: 132, LoginId: "shiori", Name: "大内 詩織", MailAddresses: ["taro.yamada@next-hd.co.jp", "taro.yamada@nexthd.jp"] }];
const plan = planMerge(snapshot, [], pleasanterUsers);
assert.strictEqual(plan.needsReview.length, 0);
assert.strictEqual(plan.creates[0].fields.Class011, "132");
});
test("planMerge reports needsReview when an email local-part has multiple Pleasanter UserIds and no override is set, without touching tab-2", () => {
const snapshot = { fetchedAt: "2026-08-08T00:00:00.000Z", users: [makeUser()], orgUnits: [], positions: [], levels: [], userTypes: [] };
const pleasanterUsers = [
{ UserId: 36, LoginId: "legacy", Name: "山田 太郎", MailAddresses: ["taro.yamada@next-hd.co.jp"] },
{ UserId: 593, LoginId: "current", Name: "山田 太郎", MailAddresses: ["taro.yamada@nexthd.jp"] },
];
const plan = planMerge(snapshot, [], pleasanterUsers);
assert.strictEqual(plan.needsReview.length, 1);
assert.strictEqual(plan.needsReview[0].email, "taro.yamada");
assert.deepStrictEqual(plan.needsReview[0].candidateUserIds.sort(), [36, 593]);
assert.strictEqual(plan.creates[0].fields.Class011, undefined);
});
test("planMerge resolves a duplicate email local-part via emailOverrides without reporting needsReview", () => {
const snapshot = { fetchedAt: "2026-08-08T00:00:00.000Z", users: [makeUser()], orgUnits: [], positions: [], levels: [], userTypes: [] };
const pleasanterUsers = [
{ UserId: 36, LoginId: "legacy", Name: "山田 太郎", MailAddresses: ["taro.yamada@next-hd.co.jp"] },
{ UserId: 593, LoginId: "current", Name: "山田 太郎", MailAddresses: ["taro.yamada@nexthd.jp"] },
];
const plan = planMerge(snapshot, [], pleasanterUsers, { "taro.yamada": 593 });
assert.strictEqual(plan.needsReview.length, 0);
assert.strictEqual(plan.creates[0].fields.Class011, "593");
});
test("planMerge also creates a record for a Pleasanter user who has no matching LINEWORKS account (retired/disabled etc.), with tab-2 only and Check062=false", () => {
const snapshot = { fetchedAt: "2026-08-08T00:00:00.000Z", users: [], orgUnits: [], positions: [], levels: [], userTypes: [] };
const pleasanterUsers = [{ UserId: 77, LoginId: "retired", Name: "退職 太郎", MailAddresses: ["tairo.taishoku@nexthd.jp"], Disabled: true }];
const plan = planMerge(snapshot, [], pleasanterUsers);
assert.strictEqual(plan.creates.length, 1);
const fields = plan.creates[0].fields;
assert.strictEqual(fields.ClassA, undefined); // LINEWORKS社員IDは無い
assert.strictEqual(fields.ClassB, "tairo.taishoku@nexthd.jp");
assert.strictEqual(fields.Check062, false); // LINEWORKS在籍フラグはfalse固定
assert.strictEqual(fields.Class011, "77");
assert.strictEqual(fields.Check029, true); // Disabled
assert.deepStrictEqual(plan.creates[0].user, {}); // applyMergeのグループ/役職解決ヘルパーに安全に渡せる空オブジェクト
});
test("planMerge updates an existing Pleasanter-only master record matched by Class011 instead of creating a duplicate", () => {
const snapshot = { fetchedAt: "2026-08-08T00:00:00.000Z", users: [], orgUnits: [], positions: [], levels: [], userTypes: [] };
const pleasanterUsers = [{ UserId: 77, LoginId: "retired", Name: "退職 太郎改名", MailAddresses: ["tairo.taishoku@nexthd.jp"] }];
const existing = [{ ResultId: 50, ClassA: "", ClassB: "tairo.taishoku@nexthd.jp", Class011: "77", Check062: false }];
const plan = planMerge(snapshot, existing, pleasanterUsers);
assert.strictEqual(plan.creates.length, 0);
assert.strictEqual(plan.updates.length, 1);
assert.strictEqual(plan.updates[0].itemId, 50);
assert.strictEqual(plan.updates[0].fields.Class013, "退職 太郎改名");
});
test("planMerge does not let multiple Pleasanter UserIds sharing the same email local-part steal each other's master record (regression: kenichiro.nogi had 4 duplicate accounts)", () => {
const snapshot = { fetchedAt: "2026-08-08T00:00:00.000Z", users: [], orgUnits: [], positions: [], levels: [], userTypes: [] };
const pleasanterUsers = [
{ UserId: 266, LoginId: "user0001", Name: "A", MailAddresses: ["kenichiro.nogi@nexthd.jp"] },
{ UserId: 493, LoginId: "user0002", Name: "B", MailAddresses: ["kenichiro.nogi@nexthd.jp"] },
];
// 既存: 266は既にマスタに存在前回作成済み。493はまだマスタに存在しない
const existing = [{ ResultId: 1354, ClassA: "", ClassB: "kenichiro.nogi@nexthd.jp", Class011: "266", Check062: false }];
const plan = planMerge(snapshot, existing, pleasanterUsers);
// 493は266のレコード(1354)を奪わず、独立した新規レコードとして作成されるべき
assert.strictEqual(plan.updates.length, 1);
assert.strictEqual(plan.updates[0].itemId, 1354);
assert.strictEqual(plan.updates[0].fields.Class011, "266");
assert.strictEqual(plan.creates.length, 1);
assert.strictEqual(plan.creates[0].fields.Class011, "493");
});
test("planMerge does not duplicate a user who exists in both LINEWORKS and Pleasanter (already handled by the main loop)", () => {
const snapshot = { fetchedAt: "2026-08-08T00:00:00.000Z", users: [makeUser()], orgUnits: [], positions: [], levels: [], userTypes: [] };
const pleasanterUsers = [{ UserId: 99, LoginId: "taro", Name: "山田 太郎", MailAddresses: ["taro.yamada@next-hd.co.jp"] }];
const plan = planMerge(snapshot, [], pleasanterUsers);
assert.strictEqual(plan.creates.length, 1); // LINEWORKSユーザー分のみ。プリザンター専用ループでの二重作成なし
});
test("planMerge registers a Pleasanter-only user who has no MailAddresses at all (e.g. test/legacy/system accounts)", () => {
const snapshot = { fetchedAt: "2026-08-08T00:00:00.000Z", users: [], orgUnits: [], positions: [], levels: [], userTypes: [] };
const pleasanterUsers = [{ UserId: 461, LoginId: "test003", Name: "テストユーザ" }]; // MailAddressesキー自体が無い
const plan = planMerge(snapshot, [], pleasanterUsers);
assert.strictEqual(plan.creates.length, 1);
assert.strictEqual(plan.creates[0].fields.Class011, "461");
assert.strictEqual(plan.creates[0].fields.ClassB, undefined); // メールアドレスが無いので突合キーは書かない
assert.strictEqual(plan.creates[0].fields.Check062, false);
});
test("planMerge matches an existing Pleasanter-only master record by Class011 (Pleasanter UserId) when re-run", () => {
const snapshot = { fetchedAt: "2026-08-08T00:00:00.000Z", users: [], orgUnits: [], positions: [], levels: [], userTypes: [] };
const pleasanterUsers = [{ UserId: 461, LoginId: "test003", Name: "テストユーザ改名" }];
const existing = [{ ResultId: 60, ClassA: "", ClassB: "", Class011: "461", Check062: false }];
const plan = planMerge(snapshot, existing, pleasanterUsers);
assert.strictEqual(plan.creates.length, 0);
assert.strictEqual(plan.updates.length, 1);
assert.strictEqual(plan.updates[0].itemId, 60);
assert.strictEqual(plan.updates[0].fields.Class013, "テストユーザ改名");
});
test("planMerge resolves tab-2 via the existing record's already-linked Class011 (Pleasanter UserId) even when that user's email was cleared and no longer matches by local-part, without creating a duplicate", () => {
// 再現ケース(2026-08-11実機発覚): UserId=36は既にResultId=10のClass011として紐付け済みだったが、
// プリザンター管理画面でこのユーザーのメールアドレスを空にしたため、メールローカル部突合が
// 効かなくなり、needsReview行き(タブ2未更新)→2巡目のプリザンター専用ループでも
// LINEWORKS現役社員として除外され、結果マスタに重複レコードが作成される事故が起きた。
const snapshot = { fetchedAt: "2026-08-08T00:00:00.000Z", users: [makeUser()], orgUnits: [], positions: [], levels: [], userTypes: [] };
const pleasanterUsers = [{ UserId: 36, LoginId: "ntkco12216", Name: "山田 太郎", MailAddresses: [] }];
const existing = [{ ResultId: 10, ClassA: "u1", ClassB: "taro.yamada@next-hd.co.jp", Check062: true, Class011: "36" }];
const plan = planMerge(snapshot, existing, pleasanterUsers);
assert.strictEqual(plan.needsReview.length, 0);
assert.strictEqual(plan.updates.length, 1);
assert.strictEqual(plan.updates[0].itemId, 10);
assert.strictEqual(plan.updates[0].fields.Class011, "36");
assert.strictEqual(plan.updates[0].fields.Class012, "ntkco12216");
assert.strictEqual(plan.creates.length, 0);
});
test("planMerge does not let a duplicate Pleasanter account (same local-part, not chosen by overrides) overwrite the active LINEWORKS employee's master record", () => {
// 再現ケース: 「u1」はLINEWORKS社員。プリザンター側に同じローカル部を持つアカウントが2つあり、
// overridesでUserId=36が採用済みLINEWORKSループでmirroredPleasanterUserIdsに入る
// 採用されなかったUserId=266は「プリザンター専用ループ」に流れるが、既存マスタのLINEWORKS社員
// レコードClassA="u1"、ClassB="taro.yamada@next-hd.co.jp")を誤って上書きしてはならない
const snapshot = { fetchedAt: "2026-08-08T00:00:00.000Z", users: [makeUser()], orgUnits: [], positions: [], levels: [], userTypes: [] };
const pleasanterUsers = [
{ UserId: 36, LoginId: "current", Name: "山田 太郎", MailAddresses: ["taro.yamada@next-hd.co.jp"] },
{ UserId: 266, LoginId: "legacy", Name: "山田 太郎(旧)", MailAddresses: ["taro.yamada@nexthd.jp"] },
];
// 既存マスタ: u1は既にLINEWORKS社員として在籍中(Check062=true)、UserId=36の情報が反映済み
const existing = [{ ResultId: 10, ClassA: "u1", ClassB: "taro.yamada@next-hd.co.jp", Check062: true, Class011: "36" }];
const plan = planMerge(snapshot, existing, pleasanterUsers, { "taro.yamada": 36 });
// UserId=266は既存のu1レコードを上書きせず、新規レコードとして作成されるべき
assert.strictEqual(plan.updates.length, 1);
assert.strictEqual(plan.updates[0].itemId, 10);
assert.strictEqual(plan.updates[0].fields.Check062, true); // LINEWORKS社員側はuserToFieldsがCheck062=trueを設定在籍中
assert.strictEqual(plan.updates[0].fields.Class011, "36");
assert.strictEqual(plan.creates.length, 1);
assert.strictEqual(plan.creates[0].fields.Class011, "266");
assert.strictEqual(plan.creates[0].fields.Check062, false);
});
test("planMerge explicitly clears Date007/Date060 with NULL_DATE when LINEWORKS no longer has a value but the master had one", () => {
const snapshot = { fetchedAt: "2026-08-08T00:00:00.000Z", users: [makeUser({ birthday: null, hiredDate: null })], orgUnits: [], positions: [], levels: [], userTypes: [] };
const existing = [{ ResultId: 10, ClassA: "u1", ClassB: "taro.yamada@next-hd.co.jp", Check062: true, Date007: "1990-04-01T00:00:00", Date060: "2015-04-01T00:00:00" }];
const plan = planMerge(snapshot, existing);
assert.strictEqual(plan.updates[0].fields.Date007, "1899/12/31");
assert.strictEqual(plan.updates[0].fields.Date060, "1899/12/31");
});
test("planMerge does not send a clear-date value when the master date is already unset", () => {
const snapshot = { fetchedAt: "2026-08-08T00:00:00.000Z", users: [makeUser({ birthday: null, hiredDate: null })], orgUnits: [], positions: [], levels: [], userTypes: [] };
const existing = [{ ResultId: 10, ClassA: "u1", ClassB: "taro.yamada@next-hd.co.jp", Check062: true, Date007: "1899-12-30T00:00:00", Date060: "" }];
const plan = planMerge(snapshot, existing);
assert.strictEqual(plan.updates[0].fields.Date007, undefined);
assert.strictEqual(plan.updates[0].fields.Date060, undefined);
});
test("planMerge matches an existing record by email local-part on first-time import", () => {
const snapshot = { fetchedAt: "2026-08-08T00:00:00.000Z", users: [makeUser()], orgUnits: [], positions: [], levels: [], userTypes: [] };
const existing = [{ ResultId: 20, ClassA: "", ClassB: "taro.yamada@example.com", Check062: true }];
const plan = planMerge(snapshot, existing);
assert.strictEqual(plan.updates.length, 1);
assert.strictEqual(plan.updates[0].itemId, 20);
});
test("planMerge marks a previously-active record as retired when it disappears from the LINEWORKS snapshot", () => {
const snapshot = { fetchedAt: "2026-08-08T00:00:00.000Z", users: [], orgUnits: [], positions: [], levels: [], userTypes: [] };
const existing = [{ ResultId: 30, ClassA: "gone-user", ClassB: "gone@next-hd.co.jp", Check062: true }];
const plan = planMerge(snapshot, existing);
assert.strictEqual(plan.retirements.length, 1);
assert.strictEqual(plan.retirements[0].itemId, 30);
assert.strictEqual(plan.retirements[0].fields.Check062, false);
assert.strictEqual(plan.retirements[0].fields.ClassZ, "無"); // 2026-08-09修正: ClassZもCheck062と同時に更新する
});
test("planMerge does not re-retire a record that is already inactive", () => {
const snapshot = { fetchedAt: "2026-08-08T00:00:00.000Z", users: [], orgUnits: [], positions: [], levels: [], userTypes: [] };
const existing = [{ ResultId: 40, ClassA: "already-gone", Check062: false }];
const plan = planMerge(snapshot, existing);
assert.strictEqual(plan.retirements.length, 0);
});
test("i18nName falls back to empty strings when there is no en_US entry", () => {
assert.deepStrictEqual(i18nName({ i18nNames: [] }), { lastName: "", firstName: "" });
assert.deepStrictEqual(i18nName({ i18nNames: [{ language: "zh_CN", lastName: "山", firstName: "田" }] }), { lastName: "", firstName: "" });
});
test("orgUnitRefs extracts {orgUnitId, orgUnitName} for every org unit across all organizations (multi-membership supported)", () => {
const user = makeUser({
organizations: [{
levelName: "正社員",
orgUnits: [
{ orgUnitId: "ou-1", orgUnitName: "情報システム部", primary: true, positionName: "課長" },
{ orgUnitId: "ou-2", orgUnitName: "管理本部", primary: false, positionName: "課長" },
],
}],
});
assert.deepStrictEqual(orgUnitRefs(user), [
{ orgUnitId: "ou-1", orgUnitName: "情報システム部" },
{ orgUnitId: "ou-2", orgUnitName: "管理本部" },
]);
});
test("orgUnitRefs drops entries missing orgUnitId or orgUnitName", () => {
const user = makeUser({
organizations: [{ orgUnits: [{ orgUnitId: "ou-1", orgUnitName: "" }, { orgUnitId: "", orgUnitName: "名前だけ" }] }],
});
assert.deepStrictEqual(orgUnitRefs(user), []);
});
test("orgUnitsToText formats refs as semicolon-separated \"orgUnitId=orgUnitName\" pairs (same pattern as aliasEmails)", () => {
assert.strictEqual(
orgUnitsToText([{ orgUnitId: "ou-1", orgUnitName: "情報システム部" }, { orgUnitId: "ou-2", orgUnitName: "管理本部" }]),
"ou-1=情報システム部;ou-2=管理本部"
);
assert.strictEqual(orgUnitsToText([]), "");
});
test("applyMerge resolves position/level/userType (get-or-create) against the choice masters, without touching Pleasanter Groups", async () => {
const snapshot = { fetchedAt: "2026-08-08T00:00:00.000Z", users: [makeUser()], orgUnits: [], positions: [], levels: [], userTypes: [] };
const existing = [{ ResultId: 10, ClassA: "u1", ClassB: "taro.yamada@next-hd.co.jp", Check062: true }];
const plan = planMerge(snapshot, existing);
const calls = [];
const fetchImpl = async (url, opts) => {
const urlStr = String(url);
const body = opts.body ? JSON.parse(opts.body) : {};
calls.push({ url: urlStr, body });
if (urlStr.endsWith("/api/groups/get") || urlStr.endsWith("/api/groups/create")) {
throw new Error("Pleasanter Groups API must not be called any more (org-unit info now lives in Description062)");
}
if (urlStr.match(/\/api\/items\/3\/get$/)) {
return { ok: true, json: async () => ({ StatusCode: 200, Response: { Data: [{ ResultId: 100, Title: "課長" }], TotalCount: 1 } }) };
}
if (urlStr.match(/\/api\/items\/5\/get$/)) {
return { ok: true, json: async () => ({ StatusCode: 200, Response: { Data: [{ ResultId: 200, Title: "正社員" }], TotalCount: 1 } }) };
}
if (urlStr.match(/\/api\/items\/6\/get$/)) {
return { ok: true, json: async () => ({ StatusCode: 200, Response: { Data: [], TotalCount: 0 } }) }; // 利用権限タイプ「一般」は未登録→create
}
if (urlStr.match(/\/api\/items\/6\/create$/)) {
return { ok: true, json: async () => ({ Id: 300, StatusCode: 200 }) };
}
if (urlStr.match(/\/api\/items\/\d+\/update$/)) {
return { ok: true, json: async () => ({ Id: 10, StatusCode: 200 }) };
}
throw new Error(`unexpected fetch: ${urlStr}`);
};
const result = await applyMerge(plan, {
baseUrl: "https://example.test/", apiKey: "k", siteId: 1,
positionSiteId: 3, levelSiteId: 5, userTypeSiteId: 6, fetchImpl,
});
assert.strictEqual(result.updated, 1);
const updateCall = calls.find((c) => c.url.match(/\/api\/items\/10\/update$/));
// updateSiteItemはfieldsをHash形式ClassHash等/DescriptionHashへネストして送信するpleasanterClient.js参照
assert.strictEqual(updateCall.body.ClassHash.Class057, "100");
assert.strictEqual(updateCall.body.ClassHash.Class058, "200");
assert.strictEqual(updateCall.body.ClassHash.Class059, "300");
assert.strictEqual(updateCall.body.DescriptionHash.Description062, "ou-1=情報システム部");
});
test("ensureChoiceMasterId reuses a cached ResultId and creates a new choice-master record when the title is missing", async () => {
const titleToId = new Map([["課長", 100]]);
const createCalls = [];
const fetchImpl = async (url, opts) => {
createCalls.push(JSON.parse(opts.body).Title);
return { ok: true, json: async () => ({ Id: 300, StatusCode: 200 }) };
};
const cachedId = await ensureChoiceMasterId("課長", titleToId, { baseUrl: "https://example.test/", apiKey: "k", siteId: 3, fetchImpl });
const createdId = await ensureChoiceMasterId("新役職", titleToId, { baseUrl: "https://example.test/", apiKey: "k", siteId: 3, fetchImpl });
const emptyId = await ensureChoiceMasterId("", titleToId, { baseUrl: "https://example.test/", apiKey: "k", siteId: 3, fetchImpl });
assert.strictEqual(cachedId, "100");
assert.strictEqual(createdId, "300");
assert.strictEqual(emptyId, "");
assert.deepStrictEqual(createCalls, ["新役職"]); // キャッシュ済みの「課長」は作成しない
assert.strictEqual(titleToId.get("新役職"), 300); // キャッシュに追加される
});
test("planMerge sets ClassZ=有 (LINEWORKS exists) always, and ClassY (Pleasanter exists) based on whether a Pleasanter account was mirrored", () => {
const snapshot = { fetchedAt: "2026-08-08T00:00:00.000Z", users: [makeUser()], orgUnits: [], positions: [], levels: [], userTypes: [] };
const withPleasanterAccount = planMerge(snapshot, [], [{ UserId: 99, LoginId: "taro", Name: "山田 太郎", MailAddresses: ["taro.yamada@next-hd.co.jp"] }]);
assert.strictEqual(withPleasanterAccount.creates[0].fields.ClassZ, "有");
assert.strictEqual(withPleasanterAccount.creates[0].fields.ClassY, "有");
const withoutPleasanterAccount = planMerge(snapshot, [], []);
assert.strictEqual(withoutPleasanterAccount.creates[0].fields.ClassZ, "有");
assert.strictEqual(withoutPleasanterAccount.creates[0].fields.ClassY, "無");
});
test("planMerge sets ClassZ=無 (LINEWORKS does not exist) and ClassY=有 for a Pleasanter-only user", () => {
const snapshot = { fetchedAt: "2026-08-08T00:00:00.000Z", users: [], orgUnits: [], positions: [], levels: [], userTypes: [] };
const pleasanterUsers = [{ UserId: 77, LoginId: "retired", Name: "退職 太郎", MailAddresses: ["tairo.taishoku@nexthd.jp"] }];
const plan = planMerge(snapshot, [], pleasanterUsers);
assert.strictEqual(plan.creates[0].fields.ClassZ, "無");
assert.strictEqual(plan.creates[0].fields.ClassY, "有");
});
test("planMerge always overrides Class013 (name) with lastName + halfwidth-space + firstName for a LINEWORKS employee, ignoring the Pleasanter Name value", () => {
const snapshot = { fetchedAt: "2026-08-08T00:00:00.000Z", users: [makeUser()], orgUnits: [], positions: [], levels: [], userTypes: [] };
const pleasanterUsers = [{ UserId: 99, LoginId: "taro", Name: "プリザンター側の別名", MailAddresses: ["taro.yamada@next-hd.co.jp"] }];
const withPleasanterAccount = planMerge(snapshot, [], pleasanterUsers);
assert.strictEqual(withPleasanterAccount.creates[0].fields.Class013, "山田 太郎");
const withoutPleasanterAccount = planMerge(snapshot, [], []);
assert.strictEqual(withoutPleasanterAccount.creates[0].fields.Class013, "山田 太郎");
});