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, "山田 太郎"); });