Files
idsync/internal/mapping/mapping_test.go
T
caowangrenbo 8fd10f134b refactor: mapping 落库化(期望状态改从镜像视图构造 + 拼音拍平组名)
- mapping.BuildFromMirror:由镜像 active 用户/部门构造期望 LLDAP 状态(纯函数),
  组名/DN 直接复用快照落库时算好的拍平拼音,用户 DN 固定 ou=people、去掉离职 OU
- Feat: FlatDeptInfo 已在本阶段完成测试覆盖(拍平拼音父子/空拼音回落/重名消歧/DN 稳定)
- 移除已被目标架构取代的旧「嵌套 CN」测试断言
- 旧 Build(OpenLDAP 单阶段)暂留保证过渡期编译,阶段6退役
2026-09-29 23:25:50 +08:00

317 lines
8.9 KiB
Go
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.
package mapping
import (
"strings"
"testing"
"time"
"idsync/internal/feishu"
)
func dept(id, parent, name string) *feishu.Department {
return &feishu.Department{ID: id, ParentID: parent, Name: name}
}
func user(uid, name, email string, depts ...string) *feishu.User {
return &feishu.User{
UnionID: "u-" + uid,
Name: name,
EnterpriseEmail: email,
DepartmentIDs: depts,
}
}
func options() BuildOptions {
return BuildOptions{
UserOUDN: "ou=people,dc=example,dc=com",
InactiveOUDN: "ou=Inactive,dc=example,dc=com",
GroupRootDN: "ou=feishuroot,dc=example,dc=com",
}
}
// TestBuildGroupOrderingIsParentFirst 是一个关键回归测试。
//
// 分组必须按层级排序,父分组排在子分组之前。早期实现按部门 ID 字母序排序,
// 当子部门的 ID 字母序恰好排在父部门之前时,创建子分组会因父分组尚不存在
// 而失败(LDAP No Such Object)。
func TestBuildGroupOrderingIsParentFirst(t *testing.T) {
snap := &feishu.Snapshot{
Departments: []*feishu.Department{
// 子部门的 ID 字母序刻意排在父部门之前
dept("od-aaa-child", "od-root", "子部门"),
dept("od-root", "0", "根部门"),
dept("od-zzz-grandchild", "od-aaa-child", "孙部门"),
},
}
st := Build(snap, options())
seen := make(map[string]int)
for i, g := range st.Groups {
seen[g.DeptID] = i
}
for _, g := range st.Groups {
if g.ParentID == "" || g.ParentID == "0" {
continue
}
pi, ok := seen[g.ParentID]
if !ok {
continue // 父不在范围内
}
ci := seen[g.DeptID]
if pi > ci {
t.Errorf("子分组 %s 排在父分组 %s 之前(%d > %d)",
g.DeptID, g.ParentID, ci, pi)
}
}
// 深度应正确反映层级
for _, g := range st.Groups {
switch g.DeptID {
case "od-root":
if g.Depth != 0 {
t.Errorf("根部门深度 = %d, 期望 0", g.Depth)
}
case "od-aaa-child":
if g.Depth != 1 {
t.Errorf("子部门深度 = %d, 期望 1", g.Depth)
}
case "od-zzz-grandchild":
if g.Depth != 2 {
t.Errorf("孙部门深度 = %d, 期望 2", g.Depth)
}
}
}
}
// TestBuildGroupDNReflectsNesting 已废弃:目标架构用 LLDAP 拍平拼音命名,
// 不再使用嵌套 CN。拍平命名的覆盖见 flat_test.go / mirror_test.go。
// TestBuildUsernameFromEnterpriseEmail 验证登录名来源。
func TestBuildUsernameFromEnterpriseEmail(t *testing.T) {
snap := &feishu.Snapshot{
Departments: []*feishu.Department{dept("od-root", "0", "Acme")},
Users: []*feishu.User{
user("1", "张三", "ZhangSan@Example.com", "od-root"),
},
}
st := Build(snap, options())
if len(st.Users) != 1 {
t.Fatalf("用户数 = %d, 期望 1", len(st.Users))
}
u := st.Users[0]
// 登录名应小写且不含域名
if u.UID != "zhangsan" {
t.Errorf("uid = %q, 期望 zhangsan(应取企业邮箱前缀并转小写)", u.UID)
}
if u.DN != "uid=zhangsan,ou=people,dc=example,dc=com" {
t.Errorf("DN = %q", u.DN)
}
if u.InactiveDN != "uid=zhangsan,ou=Inactive,dc=example,dc=com" {
t.Errorf("离职 DN = %q", u.InactiveDN)
}
// mail 必须是企业邮箱本身(保留原始大小写)
if u.Mail != "ZhangSan@Example.com" {
t.Errorf("mail = %q, 期望企业邮箱原值", u.Mail)
}
if u.DisplayName != "张三" {
t.Errorf("displayName = %q, 期望 张三", u.DisplayName)
}
}
// TestBuildSkipsUserWithoutEnterpriseEmail 验证缺少企业邮箱的用户不会被臆造登录名。
//
// 这里刻意不断言「回落到个人邮箱」——那样的行为会产生不可预期、
// 且离职后仍然有效的登录名,因此必须跳过。
func TestBuildSkipsUserWithoutEnterpriseEmail(t *testing.T) {
snap := &feishu.Snapshot{
Departments: []*feishu.Department{dept("od-root", "0", "Acme")},
Users: []*feishu.User{
// 有个人邮箱但没有企业邮箱
{
UnionID: "u-nomail",
Name: "孙七",
Email: "sq@personal.com",
DepartmentIDs: []string{"od-root"},
},
},
}
st := Build(snap, options())
if len(st.Users) != 0 {
t.Errorf("缺少企业邮箱的用户不应被映射为登录名,实际生成 %d 个", len(st.Users))
}
}
// TestBuildMembership 验证成员关系按部门正确归集。
func TestBuildMembership(t *testing.T) {
snap := &feishu.Snapshot{
Departments: []*feishu.Department{
dept("od-root", "0", "Acme"),
dept("od-rd", "od-root", "研发部"),
},
Users: []*feishu.User{
user("1", "张三", "zhangsan@example.com", "od-rd"),
// 一人属多部门
user("2", "李四", "lisi@example.com", "od-root", "od-rd"),
},
}
st := Build(snap, options())
rd := st.GroupByDeptID["od-rd"]
if _, ok := rd.Members["uid=zhangsan,ou=people,dc=example,dc=com"]; !ok {
t.Errorf("张三 未加入研发部")
}
if _, ok := rd.Members["uid=lisi,ou=people,dc=example,dc=com"]; !ok {
t.Errorf("李四 未加入研发部")
}
root := st.GroupByDeptID["od-root"]
if _, ok := root.Members["uid=lisi,ou=people,dc=example,dc=com"]; !ok {
t.Errorf("李四 未加入根部门")
}
if _, ok := root.Members["uid=zhangsan,ou=people,dc=example,dc=com"]; ok {
t.Errorf("张三 只属研发部,不应出现在根部门")
}
}
// TestBuildOrphanDepartmentWarnsAndAttachesToRoot 验证父部门不在范围内时的处理。
//
// 行为是「挂到分组根下并告警」,而不是静默跳过或静默挂错层级:
// 静默跳过会让部门凭空消失,静默挂错层级会让人以为结构正确。
func TestBuildOrphanDepartmentWarnsAndAttachesToRoot(t *testing.T) {
snap := &feishu.Snapshot{
Departments: []*feishu.Department{
// 父部门 od-missing 不在快照中
dept("od-orphan", "od-missing", "孤儿部门"),
},
}
st := Build(snap, options())
g, ok := st.GroupByDeptID["od-orphan"]
if !ok {
t.Fatalf("父部门不在范围内的部门仍应被同步(挂到分组根下)")
}
if want := "cn=guerbumen,ou=feishuroot,dc=example,dc=com"; g.DN != want {
t.Errorf("孤儿部门 DN = %q, 期望 %q", g.DN, want)
}
found := false
for _, w := range st.Warnings {
if strings.Contains(w, "od-missing") && strings.Contains(w, "不在同步范围内") {
found = true
break
}
}
if !found {
t.Errorf("父部门不在范围内时应产生告警,实际告警: %v", st.Warnings)
}
}
// TestBuildCycleProtection 验证部门树出现环时不会死循环。
func TestBuildCycleProtection(t *testing.T) {
snap := &feishu.Snapshot{
Departments: []*feishu.Department{
// a 的父是 b,b 的父是 a:构造一个环
dept("od-a", "od-b", "A"),
dept("od-b", "od-a", "B"),
},
}
done := make(chan *State, 1)
go func() { done <- Build(snap, options()) }()
select {
case st := <-done:
// 关键是能返回而不是卡死
if st == nil {
t.Fatalf("Build 返回 nil")
}
case <-timeAfterSeconds(5):
t.Fatalf("部门数据成环时 Build 未在 5 秒内返回,可能存在死循环")
}
}
// TestToPinyin 验证部门名到 CN 的转换与清理。
func TestToPinyin(t *testing.T) {
cases := []struct {
in string
want string
}{
{"研发部", "yanfabu"},
{"后端组", "houduanzu"},
{"Acme", "Acme"},
{"IT Dept", "IT_Dept"},
{"", ""},
{" ", ""},
{"Finance-2", "Finance-2"},
}
for _, c := range cases {
if got := toPinyin(c.in); got != c.want {
t.Errorf("toPinyin(%q) = %q, 期望 %q", c.in, got, c.want)
}
}
}
// TestCnNameFallsBackToDeptID 验证纯符号部门名不会产生空 CN。
func TestCnNameFallsBackToDeptID(t *testing.T) {
got := cnName("★☆", "od-xyz")
if got == "" {
t.Errorf("cnName 不应返回空字符串")
}
if !strings.Contains(got, "od-xyz") {
t.Errorf("无法转为拼音时应回落到部门 ID,实际: %q", got)
}
}
// TestAttrsPreserveEmptyOptionalFields 验证可选字段为空时不会填充臆造值。
//
// 这些属性在 LDAP 侧会走删除分支,而不是写入 "Default: xxx" 之类的占位值。
func TestBuildKeepsOptionalFieldsEmpty(t *testing.T) {
snap := &feishu.Snapshot{
Departments: []*feishu.Department{dept("od-root", "0", "Acme")},
Users: []*feishu.User{
{
UnionID: "u-1", Name: "张三", EnterpriseEmail: "zhangsan@example.com",
// Mobile/EmployeeNo/JobTitle/WorkStation 全部留空
DepartmentIDs: []string{"od-root"},
},
},
}
st := Build(snap, options())
u := st.Users[0]
if u.Mobile != "" {
t.Errorf("mobile 应为空,实际 %q", u.Mobile)
}
if u.EmployeeNumber != "" {
t.Errorf("employeeNumber 应为空,实际 %q", u.EmployeeNumber)
}
if u.DepartmentNumber != "" {
t.Errorf("departmentNumber 应为空,实际 %q", u.DepartmentNumber)
}
if u.PostalAddress != "" {
t.Errorf("postalAddress 应为空,实际 %q", u.PostalAddress)
}
// sn 是 inetOrgPerson 的必填项,必须有值
if u.SN == "" {
t.Errorf("sn 不可为空(inetOrgPerson 必填)")
}
}
// timeAfterSeconds 返回一个等待指定秒数后触发的通道,用于检验死循环。
func timeAfterSeconds(n int) <-chan struct{} {
ch := make(chan struct{})
go func() {
time.Sleep(time.Duration(n) * time.Second)
close(ch)
}()
return ch
}