1
0
forked from baron/baron-sso

org chart 연동기능 추가

This commit is contained in:
2026-04-29 21:00:51 +09:00
parent 438f844f2b
commit 01e7b15c46
25 changed files with 5141 additions and 2940 deletions

View File

@@ -1,61 +0,0 @@
package handler
import (
"baron-sso-backend/internal/service"
"log/slog"
"github.com/gofiber/fiber/v2"
)
type OrgChartHandler struct {
Service service.OrgChartService
}
func NewOrgChartHandler(s service.OrgChartService) *OrgChartHandler {
return &OrgChartHandler{Service: s}
}
func (h *OrgChartHandler) ImportOrgChart(c *fiber.Ctx) error {
tenantID := c.Params("tenantId")
if tenantID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId is required"})
}
file, err := c.FormFile("file")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "failed to get file from form"})
}
f, err := file.Open()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to open file"})
}
defer f.Close()
progressID := c.Query("progressId")
result, err := h.Service.ImportOrgChart(c.Context(), tenantID, f, file.Filename, progressID)
if err != nil {
slog.Error("Failed to import org chart", "error", err, "tenantID", tenantID, "filename", file.Filename)
// If we have a result even with error, return it
if result != nil {
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"message": "Import completed with errors",
"data": result,
})
}
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
return c.JSON(fiber.Map{
"message": "Import completed",
"data": result,
})
}
func (h *OrgChartHandler) GetImportProgress(c *fiber.Ctx) error {
pid := c.Params("progressId")
if val, ok := service.ImportProgressCache.Load(pid); ok {
return c.JSON(val)
}
return c.JSON(fiber.Map{"current": 0, "total": 0})
}

View File

@@ -5,7 +5,11 @@ import (
"baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service"
"baron-sso-backend/internal/utils"
"bytes"
"encoding/csv"
"errors"
"fmt"
"io"
"strings"
"time"
@@ -57,6 +61,23 @@ type tenantListResponse struct {
Total int64 `json:"total"`
}
type tenantImportResult struct {
Created int `json:"created"`
Updated int `json:"updated"`
Failed int `json:"failed"`
Errors []string `json:"errors"`
}
type tenantCSVRecord struct {
TenantID string
Name string
Type string
ParentTenantID *string
Slug string
Memo string
Domains []string
}
func (h *TenantHandler) RegisterTenantPublic(c *fiber.Ctx) error {
var req struct {
Name string `json:"name"`
@@ -229,6 +250,361 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
return c.JSON(tenantListResponse{Items: items, Limit: limit, Offset: offset, Total: total})
}
func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
tenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "")
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
var buf bytes.Buffer
writer := csv.NewWriter(&buf)
if err := writer.Write([]string{"tenant_id", "name", "type", "parent_tenant_id", "slug", "memo", "email_domain"}); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
for _, tenant := range tenants {
parentID := ""
if tenant.ParentID != nil {
parentID = *tenant.ParentID
}
domains := make([]string, 0, len(tenant.Domains))
for _, domainName := range tenant.Domains {
domainName := strings.TrimSpace(domainName.Domain)
if domainName != "" {
domains = append(domains, domainName)
}
}
if err := writer.Write([]string{
tenant.ID,
tenant.Name,
tenant.Type,
parentID,
tenant.Slug,
tenant.Description,
strings.Join(domains, ";"),
}); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
}
writer.Flush()
if err := writer.Error(); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
c.Set(fiber.HeaderContentType, "text/csv")
c.Set(fiber.HeaderContentDisposition, `attachment; filename="tenants.csv"`)
return c.Send(buf.Bytes())
}
func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error {
reader, err := tenantCSVReaderFromRequest(c)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
records, err := parseTenantCSVRecords(reader)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
creatorID := ""
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
creatorID = profile.ID
}
result := tenantImportResult{Errors: make([]string, 0)}
for i, record := range records {
rowNumber := i + 2
if record.TenantID != "" || (h.DB != nil && record.Slug != "") {
updated, err := h.upsertTenantCSVRecord(c, record)
if err != nil {
result.Failed++
result.Errors = append(result.Errors, fmt.Sprintf("row %d: %s", rowNumber, err.Error()))
continue
}
if updated {
result.Updated++
continue
}
}
if err := h.createTenantCSVRecord(c, record, creatorID); err != nil {
result.Failed++
result.Errors = append(result.Errors, fmt.Sprintf("row %d: %s", rowNumber, err.Error()))
continue
}
result.Created++
}
return c.JSON(result)
}
func tenantCSVReaderFromRequest(c *fiber.Ctx) (io.Reader, error) {
file, err := c.FormFile("file")
if err == nil && file != nil {
opened, err := file.Open()
if err != nil {
return nil, errors.New("failed to open uploaded file")
}
defer opened.Close()
data, err := io.ReadAll(opened)
if err != nil {
return nil, errors.New("failed to read uploaded file")
}
return bytes.NewReader(data), nil
}
body := c.Body()
if len(bytes.TrimSpace(body)) == 0 {
return nil, errors.New("csv file is required")
}
return bytes.NewReader(body), nil
}
func parseTenantCSVRecords(r io.Reader) ([]tenantCSVRecord, error) {
data, err := io.ReadAll(r)
if err != nil {
return nil, errors.New("failed to read csv")
}
data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
reader := csv.NewReader(bytes.NewReader(data))
reader.FieldsPerRecord = -1
rows, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("invalid csv: %w", err)
}
if len(rows) == 0 {
return nil, errors.New("csv is empty")
}
header := tenantCSVHeaderIndex(rows[0])
required := []string{"name", "type", "slug"}
for _, key := range required {
if _, ok := header[key]; !ok {
return nil, fmt.Errorf("missing required column: %s", key)
}
}
records := make([]tenantCSVRecord, 0, len(rows)-1)
for i, row := range rows[1:] {
if tenantCSVRowIsEmpty(row) {
continue
}
name := tenantCSVValue(row, header, "name")
if name == "" {
return nil, fmt.Errorf("row %d: name is required", i+2)
}
tenantType := normalizeTenantType(tenantCSVValue(row, header, "type"))
if tenantType == "" {
return nil, fmt.Errorf("row %d: invalid tenant type", i+2)
}
slug := utils.GenerateSlug(tenantCSVValue(row, header, "slug"))
if slug == "" {
return nil, fmt.Errorf("row %d: slug is required", i+2)
}
parentValue := tenantCSVValue(row, header, "parent_tenant_id")
var parentID *string
if parentValue != "" {
parentID = &parentValue
}
records = append(records, tenantCSVRecord{
TenantID: tenantCSVValue(row, header, "tenant_id"),
Name: name,
Type: tenantType,
ParentTenantID: parentID,
Slug: slug,
Memo: tenantCSVValue(row, header, "memo"),
Domains: splitTenantCSVDomains(tenantCSVValue(row, header, "email_domain")),
})
}
return records, nil
}
func tenantCSVHeaderIndex(header []string) map[string]int {
index := make(map[string]int, len(header))
aliases := map[string]string{
"id": "tenant_id",
"tenantid": "tenant_id",
"tenant_id": "tenant_id",
"name": "name",
"type": "type",
"parentid": "parent_tenant_id",
"parent_id": "parent_tenant_id",
"parenttenantid": "parent_tenant_id",
"parent_tenant_id": "parent_tenant_id",
"slug": "slug",
"memo": "memo",
"description": "memo",
"email-domain": "email_domain",
"emaildomain": "email_domain",
"email_domain": "email_domain",
"domain": "email_domain",
"domains": "email_domain",
}
for i, column := range header {
key := strings.ToLower(strings.TrimSpace(column))
key = strings.ReplaceAll(key, " ", "_")
if canonical, ok := aliases[key]; ok {
index[canonical] = i
}
}
return index
}
func tenantCSVValue(row []string, header map[string]int, key string) string {
idx, ok := header[key]
if !ok || idx >= len(row) {
return ""
}
return strings.TrimSpace(row[idx])
}
func tenantCSVRowIsEmpty(row []string) bool {
for _, value := range row {
if strings.TrimSpace(value) != "" {
return false
}
}
return true
}
func splitTenantCSVDomains(value string) []string {
value = strings.ReplaceAll(value, "\n", ";")
value = strings.ReplaceAll(value, ",", ";")
parts := strings.Split(value, ";")
domains := make([]string, 0, len(parts))
seen := make(map[string]bool, len(parts))
for _, part := range parts {
domainName := strings.ToLower(strings.TrimSpace(part))
if domainName == "" || seen[domainName] {
continue
}
seen[domainName] = true
domains = append(domains, domainName)
}
return domains
}
func (h *TenantHandler) upsertTenantCSVRecord(c *fiber.Ctx, record tenantCSVRecord) (bool, error) {
if h.DB == nil {
if record.TenantID != "" {
return false, errors.New("database not available for tenant update")
}
return false, nil
}
var tenant domain.Tenant
query := h.DB.Preload("Domains")
var err error
if record.TenantID != "" {
err = query.First(&tenant, "id = ?", record.TenantID).Error
} else {
err = query.First(&tenant, "slug = ?", record.Slug).Error
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
if err != nil {
return false, err
}
tenant.Name = record.Name
tenant.Type = record.Type
tenant.ParentID = record.ParentTenantID
tenant.Slug = record.Slug
tenant.Description = record.Memo
if tenant.Status == "" {
tenant.Status = domain.TenantStatusActive
}
if err := h.DB.Save(&tenant).Error; err != nil {
return false, err
}
if err := h.DB.Delete(&domain.TenantDomain{}, "tenant_id = ?", tenant.ID).Error; err != nil {
return false, err
}
repo := repository.NewTenantRepository(h.DB)
for _, domainName := range record.Domains {
if err := repo.AddDomain(c.Context(), tenant.ID, domainName, true); err != nil {
return false, err
}
}
return true, nil
}
func (h *TenantHandler) createTenantCSVRecord(c *fiber.Ctx, record tenantCSVRecord, creatorID string) error {
if h.DB != nil && record.TenantID != "" {
var exists int64
if err := h.DB.Unscoped().Model(&domain.Tenant{}).Where("slug = ?", record.Slug).Count(&exists).Error; err != nil {
return err
}
if exists > 0 {
return errors.New("tenant slug already exists")
}
tenant := domain.Tenant{
ID: record.TenantID,
Type: record.Type,
ParentID: record.ParentTenantID,
Name: record.Name,
Slug: record.Slug,
Description: record.Memo,
Status: domain.TenantStatusActive,
}
if err := h.DB.Create(&tenant).Error; err != nil {
return err
}
if h.KetoOutbox != nil {
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
Relation: "admins",
Subject: "System:global#super_admins",
Action: domain.KetoOutboxActionCreate,
})
if tenant.ParentID != nil {
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
Relation: "parents",
Subject: "Tenant:" + *tenant.ParentID,
Action: domain.KetoOutboxActionCreate,
})
}
if creatorID != "" {
for _, relation := range []string{"owners", "admins", "members"} {
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
Relation: relation,
Subject: "User:" + creatorID,
Action: domain.KetoOutboxActionCreate,
})
}
}
}
repo := repository.NewTenantRepository(h.DB)
for _, domainName := range record.Domains {
if err := repo.AddDomain(c.Context(), tenant.ID, domainName, true); err != nil {
return err
}
}
return nil
}
_, err := h.Service.RegisterTenant(c.Context(), record.Name, record.Slug, record.Type, record.Memo, record.Domains, record.ParentTenantID, creatorID)
return err
}
func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
if h.DB == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")

View File

@@ -6,8 +6,11 @@ import (
"bytes"
"context"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gofiber/fiber/v2"
@@ -120,6 +123,12 @@ func (m *MockUserRepoForHandler) ListByTenant(ctx context.Context, tenantID stri
}
func (m *MockUserRepoForHandler) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) {
for _, call := range m.ExpectedCalls {
if call.Method == "List" {
args := m.Called(ctx, offset, limit, search, companyCode)
return args.Get(0).([]domain.User), args.Get(1).(int64), args.Error(2)
}
}
return nil, 0, nil
}
@@ -244,6 +253,84 @@ func TestTenantHandler_ListTenants(t *testing.T) {
}
}
func TestTenantHandler_ExportTenantsCSV(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
h := &TenantHandler{Service: mockSvc}
app.Get("/tenants/export", h.ExportTenantsCSV)
parentID := "parent-1"
tenants := []domain.Tenant{
{
ID: "t1",
Name: "Tenant A",
Type: domain.TenantTypeCompany,
ParentID: &parentID,
Slug: "tenant-a",
Description: "Primary tenant",
Domains: []domain.TenantDomain{
{Domain: "tenant-a.example.com"},
{Domain: "login.tenant-a.example.com"},
},
},
}
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(1), nil)
req := httptest.NewRequest("GET", "/tenants/export", nil)
resp, _ := app.Test(req)
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, resp.Header.Get("Content-Disposition"), "tenants.csv")
assert.Equal(t, "text/csv", strings.Split(resp.Header.Get("Content-Type"), ";")[0])
assert.Contains(t, string(body), "tenant_id,name,type,parent_tenant_id,slug,memo,email_domain")
assert.Contains(t, string(body), "t1,Tenant A,COMPANY,parent-1,tenant-a,Primary tenant,tenant-a.example.com;login.tenant-a.example.com")
}
func TestTenantHandler_ImportTenantsCSVCreatesTenant(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
h := &TenantHandler{Service: mockSvc}
app.Post("/tenants/import", h.ImportTenantsCSV)
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "tenants.csv")
assert.NoError(t, err)
_, err = part.Write([]byte("tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,Imported Tenant,COMPANY,parent-1,imported-tenant,Imported memo,imported.example.com;login.imported.example.com\n"))
assert.NoError(t, err)
assert.NoError(t, writer.Close())
mockSvc.On(
"RegisterTenant",
mock.Anything,
"Imported Tenant",
"imported-tenant",
domain.TenantTypeCompany,
"Imported memo",
[]string{"imported.example.com", "login.imported.example.com"},
mock.MatchedBy(func(parentID *string) bool {
return parentID != nil && *parentID == "parent-1"
}),
"",
).Return(&domain.Tenant{ID: "imported-1", Name: "Imported Tenant", Slug: "imported-tenant"}, nil)
req := httptest.NewRequest("POST", "/tenants/import", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, _ := app.Test(req)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var got map[string]interface{}
json.NewDecoder(resp.Body).Decode(&got)
assert.Equal(t, float64(1), got["created"])
assert.Equal(t, float64(0), got["updated"])
assert.Equal(t, float64(0), got["failed"])
mockSvc.AssertExpectations(t)
}
func TestTenantHandler_ApproveTenant(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)

View File

@@ -787,6 +787,9 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
search := strings.TrimSpace(c.Query("search"))
companyCode := strings.TrimSpace(c.Query("companyCode"))
if companyCode == "" {
companyCode = strings.TrimSpace(c.Query("tenantSlug"))
}
var requesterRole string
var manageableSlugs []string
@@ -867,7 +870,7 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
defer writer.Flush()
// Header row
header := []string{"ID", "Email", "Name", "Role", "Status", "Tenant", "Department", "Position", "JobTitle", "CreatedAt"}
header := []string{"ID", "Email", "Name", "Phone", "Status", "Tenant", "Position", "JobTitle", "CreatedAt"}
// Collect all possible metadata keys for dynamic columns
metaKeysMap := make(map[string]bool)
@@ -892,10 +895,9 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
u.ID,
u.Email,
u.Name,
u.Role,
u.Phone,
u.Status,
u.CompanyCode,
u.Department,
u.Position,
u.JobTitle,
u.CreatedAt.Format(time.RFC3339),

View File

@@ -7,9 +7,12 @@ import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
@@ -133,6 +136,51 @@ func (m *MockTenantServiceForUser) ProvisionTenantByDomain(ctx context.Context,
// --- Tests ---
func TestUserHandler_ExportUsersCSV_UsesTenantSlugAliasAndOmitsRole(t *testing.T) {
app := fiber.New()
mockRepo := new(MockUserRepoForHandler)
h := &UserHandler{UserRepo: mockRepo}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: domain.RoleSuperAdmin,
})
return c.Next()
})
app.Get("/users/export", h.ExportUsersCSV)
createdAt := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC)
mockRepo.On("List", mock.Anything, 0, 10000, "", "test-tenant").
Return([]domain.User{
{
ID: "u-1",
Email: "user@test.com",
Name: "Test User",
Phone: "010-1111-2222",
Role: domain.RoleSuperAdmin,
Status: "active",
CompanyCode: "test-tenant",
Department: "Legacy Department",
Position: "책임",
JobTitle: "플랫폼 운영",
CreatedAt: createdAt,
},
}, int64(1), nil).Once()
req := httptest.NewRequest("GET", "/users/export?tenantSlug=test-tenant", nil)
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
bodyBytes, _ := io.ReadAll(resp.Body)
body := strings.TrimPrefix(string(bodyBytes), "\ufeff")
assert.Contains(t, body, "ID,Email,Name,Phone,Status,Tenant,Position,JobTitle,CreatedAt")
assert.Contains(t, body, "u-1,user@test.com,Test User,010-1111-2222,active,test-tenant")
assert.NotContains(t, body, "Role")
assert.NotContains(t, body, "Department")
mockRepo.AssertExpectations(t)
}
func TestUserHandler_BulkCreateUsers(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)

View File

@@ -1,560 +0,0 @@
package service
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"baron-sso-backend/internal/utils"
"bytes"
"context"
"encoding/csv"
"fmt"
"io"
"log/slog"
"regexp"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/xuri/excelize/v2"
)
var (
whitespaceRegex = regexp.MustCompile(`\s+`)
nonAlphaNumRegex = regexp.MustCompile(`[^a-zA-Z0-9가-힣]+`)
)
type ProgressData struct {
Current int `json:"current"`
Total int `json:"total"`
}
var ImportProgressCache sync.Map
type ImportResult struct {
TotalRows int `json:"totalRows"`
Processed int `json:"processed"`
UserCreated int `json:"userCreated"`
UserUpdated int `json:"userUpdated"`
TenantCreated int `json:"tenantCreated"`
Errors []string `json:"errors"`
}
type OrgChartService interface {
ImportOrgChart(ctx context.Context, tenantID string, r io.Reader, filename string, progressID string) (*ImportResult, error)
}
type orgChartService struct {
tenantRepo repository.TenantRepository
userGroupRepo repository.UserGroupRepository
userRepo repository.UserRepository
ketoOutboxRepo repository.KetoOutboxRepository
kratos KratosAdminService
}
func NewOrgChartService(
tenantRepo repository.TenantRepository,
userGroupRepo repository.UserGroupRepository,
userRepo repository.UserRepository,
ketoOutbox repository.KetoOutboxRepository,
kratos KratosAdminService,
) OrgChartService {
return &orgChartService{
tenantRepo: tenantRepo,
userGroupRepo: userGroupRepo,
userRepo: userRepo,
ketoOutboxRepo: ketoOutbox,
kratos: kratos,
}
}
func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r io.Reader, filename string, progressID string) (*ImportResult, error) {
result := &ImportResult{Errors: make([]string, 0)}
var allSheetsRecords [][][]string
var err error
if strings.HasSuffix(strings.ToLower(filename), ".xlsx") {
allSheetsRecords, err = s.readAllXLSXSheets(r)
} else {
csvRecords, csvErr := s.readCSV(r)
if csvErr == nil {
allSheetsRecords = [][][]string{csvRecords}
}
err = csvErr
}
if err != nil {
return nil, err
}
fieldMapping := map[string][]string{
"email": {"email", "이메일", "e-mail", "loginid", "login_id", "id", "계정", "사용자id", "사용자계정", "사번", "employeeid", "empno", "mail", "이메일주소"},
"name": {"name", "이름", "성함", "성명", "username", "사용자명", "성함/이름", "사용자", "사원명"},
"position": {"position", "직급", "직위", "직급/직위", "직급명", "직위명", "rank"},
"jobtitle": {"jobtitle", "직무", "담당업무", "업무", "담당", "수행직무", "직종"},
"phone": {"phone", "전화번호", "연락처", "휴대폰", "휴대폰번호", "핸드폰", "tel", "mobile"},
"company": {"company", "소속", "법인", "회사", "소속회사", "소속법인", "사업부", "co"},
"is_owner": {"is_owner", "구분", "직책", "권한", "role", "직책명", "장급여부", "리더", "leader", "manager", "pos"},
}
var dataRows [][]string
actualMap := make(map[string]int)
found := false
var headerMap map[string]int
for sheetIdx, records := range allSheetsRecords {
for i, row := range records {
if len(row) < 2 {
continue
}
tempMap := make(map[string]int)
for j, cell := range row {
clean := s.cleanHeader(cell)
if clean != "" {
tempMap[clean] = j
}
}
emailIdx := s.findBestMatch(tempMap, fieldMapping["email"])
nameIdx := s.findBestMatch(tempMap, fieldMapping["name"])
if nameIdx != -1 && emailIdx == -1 {
for j, cell := range row {
c := s.cleanHeader(cell)
if strings.Contains(c, "mail") || strings.Contains(c, "계정") || strings.Contains(c, "id") || strings.Contains(c, "사번") {
emailIdx = j
break
}
}
}
if emailIdx != -1 && nameIdx != -1 {
dataRows = records[i+1:]
headerMap = tempMap
for key, aliases := range fieldMapping {
actualMap[key] = s.findBestMatch(tempMap, aliases)
}
if actualMap["email"] == -1 {
actualMap["email"] = emailIdx
}
found = true
slog.Info("Found header row", "sheet", sheetIdx, "row", i)
break
}
}
if found {
break
}
}
if !found {
return nil, fmt.Errorf("required columns (email/name) not found. please check your headers")
}
// [MH-OrgChart-Standalone Architecture]
// Hierarchy is explicitly ordered: 부서(part) -> 그룹(gr) -> 디비전(div) -> 팀(team) -> 셀(cell)
hierarchyLevels := [][]string{
{"department", "organization", "부서", "조직", "부서명", "조직명", "소속부서", "part", "파트", "본부", "실", "국"},
{"gr", "grp", "group", "그룹"},
{"div", "division", "디비젼", "디비전"},
{"team", "팀", "teal", "팀명"},
{"cell", "셀"},
}
hierarchyIdx := make([]int, 0)
for _, aliases := range hierarchyLevels {
idx := s.findBestMatch(headerMap, aliases)
hierarchyIdx = append(hierarchyIdx, idx) // Keep order, -1 means not found
}
pathCache := make(map[string]string)
result.TotalRows = len(dataRows)
if progressID != "" {
ImportProgressCache.Store(progressID, ProgressData{Current: 0, Total: result.TotalRows})
defer ImportProgressCache.Delete(progressID)
}
if tenantID == "root" || tenantID == "" {
t, _ := s.tenantRepo.FindBySlug(ctx, "root-group")
if t == nil {
tenantID = uuid.NewString()
_ = s.tenantRepo.Create(ctx, &domain.Tenant{ID: tenantID, Name: "Root Group", Slug: "root-group", Type: domain.TenantTypeCompanyGroup, Status: domain.TenantStatusActive})
result.TenantCreated++
} else {
tenantID = t.ID
}
}
for rowIdx, record := range dataRows {
if len(record) == 0 {
continue
}
email := s.getVal(record, actualMap["email"])
name := s.getVal(record, actualMap["name"])
if email == "" || name == "" {
continue
}
position := s.getVal(record, actualMap["position"])
jobTitle := s.getVal(record, actualMap["jobtitle"])
phone := s.normalizePhone(s.getVal(record, actualMap["phone"]))
companyName := s.getVal(record, actualMap["company"])
if companyName == "" {
companyName = "Main"
}
companySlug := s.generateCompanySlug(companyName)
companyTenantID, err := s.ensureCompanyTenant(ctx, tenantID, companyName, companySlug, email, pathCache, result)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: Company fail: %v", rowIdx+2, err))
continue
}
// Build orgPath following the strict order: 부서 -> 그룹 -> 디비전 -> 팀 -> 셀
var orgParts []string
for _, idx := range hierarchyIdx {
val := s.getVal(record, idx)
if val != "" && val != "-" {
orgParts = append(orgParts, val)
}
}
orgPath := strings.Join(orgParts, " > ")
leafID := companyTenantID
if len(orgParts) > 0 {
// [Matrix Fix] Build hierarchy under the Root Tenant (tenantID), NOT the individual company.
// This allows departments like '총괄기획실' to be shared across multiple companies without duplication.
leafID, err = s.ensureOrgPath(ctx, tenantID, orgParts, pathCache, result)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: Hierarchy fail: %v", rowIdx+2, err))
continue
}
}
isOwner := false
grade := "member"
if idx := actualMap["is_owner"]; idx != -1 && idx < len(record) {
grade = strings.TrimSpace(record[idx])
isOwner = strings.HasSuffix(grade, "장") || strings.EqualFold(grade, "true") || grade == "1" ||
strings.Contains(grade, "Manager") || strings.Contains(grade, "Leader") ||
strings.Contains(grade, "팀장") || strings.Contains(grade, "본부장")
}
kratosID, err := s.kratos.FindIdentityIDByIdentifier(ctx, email)
if (err != nil || kratosID == "") && phone != "" {
kratosID, _ = s.kratos.FindIdentityIDByIdentifier(ctx, phone)
}
if kratosID == "" {
brokerUser := &domain.BrokerUser{
Email: email, Name: name, PhoneNumber: phone,
Attributes: map[string]interface{}{
"affiliationType": "AFFILIATE", "companyCode": companySlug,
"department": orgPath, "grade": grade, "position": position,
"tenant_id": leafID, // [Matrix Fix] Sync leafID to Kratos to prevent lazy sync overwrite
},
}
kratosID, err = s.kratos.CreateUser(ctx, brokerUser, "baron1234!@#")
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: User creation failed: %v", rowIdx+2, err))
continue
}
result.UserCreated++
} else {
traits := map[string]interface{}{
"name": name, "companyCode": companySlug, "department": orgPath,
"grade": grade, "position": position, "affiliationType": "AFFILIATE",
"tenant_id": leafID, // [Matrix Fix] Sync leafID to Kratos to prevent lazy sync overwrite
}
if phone != "" {
traits["phone_number"] = phone
}
_, _ = s.kratos.UpdateIdentity(ctx, kratosID, traits, "active")
result.UserUpdated++
}
err = s.userRepo.Update(ctx, &domain.User{
ID: kratosID, Email: email, Name: name, Phone: phone, Position: position,
JobTitle: jobTitle, Department: orgPath,
TenantID: &leafID, // [Matrix Fix] Local DB points to Leaf Department, while CompanyCode points to the Legal Entity
CompanyCode: companySlug, AffiliationType: "AFFILIATE", Status: "active", UpdatedAt: time.Now(), Role: domain.RoleUser,
})
if err != nil {
slog.Error("Row update failed", "row", rowIdx+2, "email", email, "error", err)
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: DB Update fail: %v", rowIdx+2, err))
}
if s.ketoOutboxRepo != nil {
// 1. [Redundant Assignment] Always assign to the Legal Company Tenant
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: companyTenantID,
Relation: "members",
Subject: "User:" + kratosID,
Action: domain.KetoOutboxActionCreate,
})
// 2. [Redundant Assignment] ALSO assign to the Logical Department Tenant (if exists)
if leafID != companyTenantID {
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: leafID,
Relation: "members",
Subject: "User:" + kratosID,
Action: domain.KetoOutboxActionCreate,
})
}
// 3. Assign ownership if leader
if isOwner {
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: leafID,
Relation: "owners",
Subject: "User:" + kratosID,
Action: domain.KetoOutboxActionCreate,
})
}
}
result.Processed++
if progressID != "" && (result.Processed%5 == 0 || result.Processed == result.TotalRows) {
ImportProgressCache.Store(progressID, ProgressData{Current: result.Processed, Total: result.TotalRows})
}
}
return result, nil
}
func (s *orgChartService) cleanHeader(val string) string {
clean := strings.ToLower(whitespaceRegex.ReplaceAllString(val, ""))
clean = nonAlphaNumRegex.ReplaceAllString(clean, "")
return strings.TrimPrefix(clean, "\ufeff")
}
func (s *orgChartService) findBestMatch(tempMap map[string]int, aliases []string) int {
for _, alias := range aliases {
ca := s.cleanHeader(alias)
if idx, ok := tempMap[ca]; ok {
return idx
}
}
for cleaned, idx := range tempMap {
for _, alias := range aliases {
ca := s.cleanHeader(alias)
if len(ca) >= 2 && (strings.Contains(cleaned, ca) || strings.Contains(ca, cleaned)) {
return idx
}
}
}
return -1
}
func (s *orgChartService) getVal(record []string, idx int) string {
if idx == -1 || idx >= len(record) {
return ""
}
return strings.TrimSpace(record[idx])
}
func (s *orgChartService) normalizePhone(phone string) string {
normalized := strings.ReplaceAll(phone, "-", "")
normalized = strings.ReplaceAll(normalized, " ", "")
re := regexp.MustCompile(`[^0-9+]`)
normalized = re.ReplaceAllString(normalized, "")
if len(normalized) < 8 {
return ""
}
if strings.HasPrefix(normalized, "010") {
return "+82" + normalized[1:]
}
if strings.HasPrefix(normalized, "82") {
return "+" + normalized
}
if !strings.HasPrefix(normalized, "+") && len(normalized) >= 9 {
if strings.HasPrefix(normalized, "0") {
return "+82" + normalized[1:]
}
return "+82" + normalized
}
return normalized
}
func (s *orgChartService) readCSV(r io.Reader) ([][]string, error) {
data, err := io.ReadAll(r)
if err != nil {
return nil, err
}
reader := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(data, []byte("\xef\xbb\xbf"))))
reader.LazyQuotes = true
reader.FieldsPerRecord = -1
return reader.ReadAll()
}
func (s *orgChartService) readAllXLSXSheets(r io.Reader) ([][][]string, error) {
f, err := excelize.OpenReader(r)
if err != nil {
return nil, err
}
defer f.Close()
var allRecords [][][]string
for _, sheet := range f.GetSheetList() {
if rows, err := f.GetRows(sheet); err == nil {
allRecords = append(allRecords, rows)
}
}
return allRecords, nil
}
func (s *orgChartService) generateCompanySlug(name string) string {
n := strings.ToLower(whitespaceRegex.ReplaceAllString(name, ""))
slugs := map[string]string{
"한맥": "hanmac", "삼안": "saman", "장헌": "jangheon",
"ptc": "ptc", "피티씨": "ptc", "바론": "baron", "한라": "halla",
}
for k, v := range slugs {
if strings.Contains(n, k) || strings.Contains(n, v) {
return v
}
}
return utils.GenerateSlug(name)
}
func isAlphaNumeric(s string) bool {
for _, r := range s {
if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' {
return false
}
}
return true
}
func (s *orgChartService) ensureCompanyTenant(ctx context.Context, rootID, name, slug, email string, cache map[string]string, res *ImportResult) (string, error) {
if rootID == "root" || rootID == "" {
// Auto-provision a root group if none is provided
rootSlug := "root-group"
t, _ := s.tenantRepo.FindBySlug(ctx, rootSlug)
if t == nil {
t = &domain.Tenant{ID: uuid.NewString(), Name: "Root Group", Slug: rootSlug, Type: domain.TenantTypeCompanyGroup, Status: domain.TenantStatusActive}
_ = s.tenantRepo.Create(ctx, t)
res.TenantCreated++
}
rootID = t.ID
}
cacheKey := "company:" + slug
if id, ok := cache[cacheKey]; ok {
return id, nil
}
tenant, _ := s.tenantRepo.FindBySlug(ctx, slug)
if tenant == nil {
tenant, _ = s.tenantRepo.FindByName(ctx, name)
}
if tenant == nil {
tenant = &domain.Tenant{ID: uuid.NewString(), Name: name, Slug: slug, Type: domain.TenantTypeCompany, Status: domain.TenantStatusActive, ParentID: &rootID}
if err := s.tenantRepo.Create(ctx, tenant); err != nil {
return "", err
}
if s.ketoOutboxRepo != nil {
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{Namespace: "Tenant", Object: tenant.ID, Relation: "parents", Subject: "Tenant:" + rootID, Action: domain.KetoOutboxActionCreate})
}
res.TenantCreated++
}
domainPart := ""
if parts := strings.Split(email, "@"); len(parts) == 2 {
domainPart = parts[1]
}
if domainPart != "" {
_ = s.tenantRepo.AddDomain(ctx, tenant.ID, domainPart, true)
}
cache[cacheKey] = tenant.ID
return tenant.ID, nil
}
func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string, parts []string, cache map[string]string, res *ImportResult) (string, error) {
currentParentID := rootTenantID
currentPath := ""
for i, part := range parts {
part = strings.TrimSpace(part)
if part == "" || part == "-" {
continue
}
if currentPath == "" {
currentPath = part
} else {
currentPath += "/" + part
}
cacheKey := rootTenantID + ":" + currentPath
if id, ok := cache[cacheKey]; ok {
currentParentID = id
continue
}
var existingID string
if groups, err := s.userGroupRepo.ListByTenantID(ctx, rootTenantID); err == nil {
for _, g := range groups {
isTopMatch := (g.ParentID == nil && currentParentID == rootTenantID)
isSubMatch := (g.ParentID != nil && *g.ParentID == currentParentID)
if g.Name == part && (isTopMatch || isSubMatch) {
existingID = g.ID
break
}
}
}
if existingID == "" {
existingID = uuid.NewString()
groupSlug := fmt.Sprintf("ug-%s", existingID[:13])
if err := s.tenantRepo.Create(ctx, &domain.Tenant{
ID: existingID,
Type: domain.TenantTypeUserGroup,
ParentID: &currentParentID,
Name: part,
Slug: groupSlug,
Status: domain.TenantStatusActive,
}); err != nil {
return "", err
}
var ugParentID *string
if currentParentID != rootTenantID {
pid := currentParentID
ugParentID = &pid
}
if err := s.userGroupRepo.Create(ctx, &domain.UserGroup{
ID: existingID,
TenantID: rootTenantID,
ParentID: ugParentID,
Name: part,
UnitType: s.guessUnitType(i, len(parts)),
}); err != nil {
return "", err
}
if s.ketoOutboxRepo != nil {
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{Namespace: "Tenant", Object: existingID, Relation: "parents", Subject: "Tenant:" + currentParentID, Action: domain.KetoOutboxActionCreate})
}
res.TenantCreated++
}
cache[cacheKey] = existingID
currentParentID = existingID
}
return currentParentID, nil
}
func (s *orgChartService) guessUnitType(index, total int) string {
if total == 1 {
return "Team"
}
if index == 0 {
return "Division"
}
return "Team"
}

View File

@@ -1,251 +0,0 @@
package service
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"bytes"
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/xuri/excelize/v2"
)
type mockTenantRepo struct {
mock.Mock
repository.TenantRepository
}
func (m *mockTenantRepo) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
args := m.Called(ctx, slug)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*domain.Tenant), args.Error(1)
}
func (m *mockTenantRepo) Create(ctx context.Context, tenant *domain.Tenant) error {
args := m.Called(ctx, tenant)
return args.Error(0)
}
func (m *mockTenantRepo) AddDomain(ctx context.Context, tenantID, domainName string, isPrimary bool) error {
args := m.Called(ctx, tenantID, domainName, isPrimary)
return args.Error(0)
}
type mockUserGroupRepo struct {
mock.Mock
repository.UserGroupRepository
}
func (m *mockUserGroupRepo) ListByTenantID(ctx context.Context, tenantID string) ([]domain.UserGroup, error) {
args := m.Called(ctx, tenantID)
return args.Get(0).([]domain.UserGroup), args.Error(1)
}
func (m *mockUserGroupRepo) Create(ctx context.Context, ug *domain.UserGroup) error {
args := m.Called(ctx, ug)
return args.Error(0)
}
type mockUserRepo struct {
mock.Mock
repository.UserRepository
}
func (m *mockUserRepo) Update(ctx context.Context, user *domain.User) error {
args := m.Called(ctx, user)
return args.Error(0)
}
type mockKetoOutboxRepo struct {
mock.Mock
repository.KetoOutboxRepository
}
func (m *mockKetoOutboxRepo) Create(ctx context.Context, outbox *domain.KetoOutbox) error {
args := m.Called(ctx, outbox)
return args.Error(0)
}
type mockKratosService struct {
mock.Mock
}
func (m *mockKratosService) ListIdentities(ctx context.Context) ([]KratosIdentity, error) {
return nil, nil
}
func (m *mockKratosService) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) {
args := m.Called(ctx, identifier)
return args.String(0), args.Error(1)
}
func (m *mockKratosService) GetIdentity(ctx context.Context, identityID string) (*KratosIdentity, error) {
return nil, nil
}
func (m *mockKratosService) UpdateIdentity(ctx context.Context, id string, traits map[string]interface{}, state string) (*KratosIdentity, error) {
args := m.Called(ctx, id, traits, state)
return nil, args.Error(1)
}
func (m *mockKratosService) UpdateIdentityPassword(ctx context.Context, id, pw string) error {
return nil
}
func (m *mockKratosService) DeleteIdentity(ctx context.Context, id string) error {
return nil
}
func (m *mockKratosService) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
args := m.Called(ctx, user, password)
return args.String(0), args.Error(1)
}
func setupMocksForImport(tenantRepo *mockTenantRepo, ugRepo *mockUserGroupRepo, userRepo *mockUserRepo, ketoRepo *mockKetoOutboxRepo, kratos *mockKratosService, ctx context.Context, companySlug, email string) {
tenantRepo.On("FindBySlug", ctx, companySlug).Return(&domain.Tenant{ID: companySlug + "-id", Slug: companySlug}, nil).Maybe()
tenantRepo.On("FindBySlug", ctx, mock.Anything).Return(&domain.Tenant{ID: "fallback-id", Slug: "fallback"}, nil).Maybe()
tenantRepo.On("AddDomain", ctx, mock.Anything, mock.Anything, true).Return(nil).Maybe()
tenantRepo.On("Create", ctx, mock.Anything).Return(nil).Maybe()
ugRepo.On("ListByTenantID", ctx, mock.Anything).Return([]domain.UserGroup{}, nil).Maybe()
ugRepo.On("Create", ctx, mock.Anything).Return(nil).Maybe()
kratos.On("FindIdentityIDByIdentifier", ctx, email).Return("user-id", nil).Maybe()
kratos.On("UpdateIdentity", ctx, "user-id", mock.Anything, "active").Return(nil, nil).Maybe()
userRepo.On("Update", ctx, mock.Anything).Return(nil).Maybe()
ketoRepo.On("Create", ctx, mock.Anything).Return(nil).Maybe()
}
func TestImportOrgChart_CSV_BOM(t *testing.T) {
tenantRepo := new(mockTenantRepo)
ugRepo := new(mockUserGroupRepo)
userRepo := new(mockUserRepo)
ketoRepo := new(mockKetoOutboxRepo)
kratos := new(mockKratosService)
svc := NewOrgChartService(tenantRepo, ugRepo, userRepo, ketoRepo, kratos)
csvData := "\xef\xbb\xbf이메일,이름,소속,직급,그룹,디비젼,팀,구분\n" +
"test@example.com,홍길동,한맥,사원,엔지니어링,구조,계획,팀원"
ctx := context.Background()
setupMocksForImport(tenantRepo, ugRepo, userRepo, ketoRepo, kratos, ctx, "hanmac", "test@example.com")
res, err := svc.ImportOrgChart(ctx, "root-id", bytes.NewReader([]byte(csvData)), "test.csv", "")
assert.NoError(t, err)
assert.NotNil(t, res)
}
func TestImportOrgChart_XLSX(t *testing.T) {
tenantRepo := new(mockTenantRepo)
ugRepo := new(mockUserGroupRepo)
userRepo := new(mockUserRepo)
ketoRepo := new(mockKetoOutboxRepo)
kratos := new(mockKratosService)
svc := NewOrgChartService(tenantRepo, ugRepo, userRepo, ketoRepo, kratos)
xlsx := excelize.NewFile()
xlsx.SetCellValue("Sheet1", "A1", "이메일")
xlsx.SetCellValue("Sheet1", "B1", "이름")
xlsx.SetCellValue("Sheet1", "C1", "소속")
xlsx.SetCellValue("Sheet1", "A2", "xlsx@example.com")
xlsx.SetCellValue("Sheet1", "B2", "엑셀맨")
xlsx.SetCellValue("Sheet1", "C2", "삼안")
var buf bytes.Buffer
xlsx.Write(&buf)
ctx := context.Background()
setupMocksForImport(tenantRepo, ugRepo, userRepo, ketoRepo, kratos, ctx, "saman", "xlsx@example.com")
res, err := svc.ImportOrgChart(ctx, "root-id", &buf, "test.xlsx", "")
assert.NoError(t, err)
assert.NotNil(t, res)
}
func TestImportOrgChart_MissingColumns(t *testing.T) {
svc := NewOrgChartService(nil, nil, nil, nil, nil)
ctx := context.Background()
csvData := "소속,직급\n한맥,부장"
res, err := svc.ImportOrgChart(ctx, "root", bytes.NewReader([]byte(csvData)), "test.csv", "")
assert.Error(t, err)
assert.Nil(t, res)
}
func TestImportOrgChart_RobustHeader(t *testing.T) {
tenantRepo := new(mockTenantRepo)
ugRepo := new(mockUserGroupRepo)
userRepo := new(mockUserRepo)
ketoRepo := new(mockKetoOutboxRepo)
kratos := new(mockKratosService)
svc := NewOrgChartService(tenantRepo, ugRepo, userRepo, ketoRepo, kratos)
csvData := "\n\n 이 메 일 , 이 름 , 소 속 \n" +
"robust@example.com,로버스트,바론"
ctx := context.Background()
setupMocksForImport(tenantRepo, ugRepo, userRepo, ketoRepo, kratos, ctx, "baron", "robust@example.com")
res, err := svc.ImportOrgChart(ctx, "root-id", bytes.NewReader([]byte(csvData)), "test.csv", "")
assert.NoError(t, err)
assert.NotNil(t, res)
}
func TestImportOrgChart_MultiSheet_ComplexHeaders(t *testing.T) {
tenantRepo := new(mockTenantRepo)
ugRepo := new(mockUserGroupRepo)
userRepo := new(mockUserRepo)
ketoRepo := new(mockKetoOutboxRepo)
kratos := new(mockKratosService)
svc := NewOrgChartService(tenantRepo, ugRepo, userRepo, ketoRepo, kratos)
xlsx := excelize.NewFile()
xlsx.NewSheet("Sheet2")
xlsx.SetCellValue("Sheet2", "A3", " 이 메 일 ")
xlsx.SetCellValue("Sheet2", "B3", " 성 함 / 이 름 ")
xlsx.SetCellValue("Sheet2", "C3", " 소 속 회 사 ")
xlsx.SetCellValue("Sheet2", "A4", "sheet2@example.com")
xlsx.SetCellValue("Sheet2", "B4", "시트투")
xlsx.SetCellValue("Sheet2", "C4", "한맥")
var buf bytes.Buffer
xlsx.Write(&buf)
ctx := context.Background()
setupMocksForImport(tenantRepo, ugRepo, userRepo, ketoRepo, kratos, ctx, "hanmac", "sheet2@example.com")
res, err := svc.ImportOrgChart(ctx, "root-id", &buf, "test.xlsx", "")
assert.NoError(t, err)
assert.NotNil(t, res)
}
func TestImportOrgChart_MessyHeader(t *testing.T) {
tenantRepo := new(mockTenantRepo)
ugRepo := new(mockUserGroupRepo)
userRepo := new(mockUserRepo)
ketoRepo := new(mockKetoOutboxRepo)
kratos := new(mockKratosService)
svc := NewOrgChartService(tenantRepo, ugRepo, userRepo, ketoRepo, kratos)
csvData := " 이메일(ID)* , 성 명 , [소속] \n" +
"messy@example.com,메시,바론"
ctx := context.Background()
setupMocksForImport(tenantRepo, ugRepo, userRepo, ketoRepo, kratos, ctx, "baron", "messy@example.com")
res, err := svc.ImportOrgChart(ctx, "root-id", bytes.NewReader([]byte(csvData)), "test.csv", "")
assert.NoError(t, err)
assert.NotNil(t, res)
}
func (m *mockKratosService) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) {
return nil, nil
}
func (m *mockKratosService) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) {
return nil, nil
}
func (m *mockKratosService) DeleteSession(ctx context.Context, sessionID string) error {
return nil
}