forked from baron/baron-sso
org chart 연동기능 추가
This commit is contained in:
@@ -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})
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user