1
0
forked from baron/baron-sso

perf(admin): full-stack performance optimization for all list tables

- Implemented server-side search, infinite scrolling, and list virtualization for Tenants, Users, and Audit Logs.
- Backend: Enhanced Repository, Service, and Handler layers to support 'search' and 'cursor' parameters.
- Frontend: Integrated @tanstack/react-virtual and useInfiniteQuery for high-performance rendering.
- Quality: Updated all unit tests and E2E tests to match the new asynchronous server-side search architecture.
- i18n: Synced all translation keys and cleaned up unused resources.
This commit is contained in:
2026-06-04 16:06:30 +09:00
parent 6d3f128282
commit b2f155e35b
26 changed files with 1103 additions and 440 deletions

View File

@@ -2,12 +2,6 @@ import { useInfiniteQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios"; import type { AxiosError } from "axios";
import { Download, NotebookTabs, RefreshCw, Search } from "lucide-react"; import { Download, NotebookTabs, RefreshCw, Search } from "lucide-react";
import * as React from "react"; import * as React from "react";
import {
parseAuditDetails,
resolveAuditAction,
resolveAuditActor,
} from "../../../../common/core/audit";
import { AuditLogTable } from "../../../../common/core/components/audit";
import { PageHeader } from "../../../../common/core/components/page"; import { PageHeader } from "../../../../common/core/components/page";
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar"; import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
import { Badge } from "../../components/ui/badge"; import { Badge } from "../../components/ui/badge";
@@ -23,6 +17,7 @@ import { Input } from "../../components/ui/input";
import type { AuditLog } from "../../lib/adminApi"; import type { AuditLog } from "../../lib/adminApi";
import { fetchAuditLogs } from "../../lib/adminApi"; import { fetchAuditLogs } from "../../lib/adminApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import { VirtualizedAuditLogTable } from "./VirtualizedAuditLogTable";
function AuditLogsPage() { function AuditLogsPage() {
const [searchActorId, setSearchActorId] = React.useState(""); const [searchActorId, setSearchActorId] = React.useState("");
@@ -40,8 +35,23 @@ function AuditLogsPage() {
isFetching, isFetching,
refetch, refetch,
} = useInfiniteQuery({ } = useInfiniteQuery({
queryKey: ["audit-logs"], queryKey: [
queryFn: ({ pageParam }) => fetchAuditLogs(50, pageParam), "audit-logs",
deferredSearchActorId,
deferredSearchAction,
statusFilter,
],
queryFn: ({ pageParam }) => {
const search = [deferredSearchActorId, deferredSearchAction]
.filter(Boolean)
.join(" ");
return fetchAuditLogs(
50,
pageParam,
search || undefined,
statusFilter === "all" ? undefined : statusFilter,
);
},
initialPageParam: undefined as string | undefined, initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined, getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
}); });
@@ -51,24 +61,6 @@ function AuditLogsPage() {
(page) => (page) =>
page?.items?.filter((item): item is AuditLog => Boolean(item)) ?? [], page?.items?.filter((item): item is AuditLog => Boolean(item)) ?? [],
) ?? []; ) ?? [];
const filteredLogs = React.useMemo(
() =>
logs.filter((row) => {
const details = parseAuditDetails(row.details);
const actorLabel = resolveAuditActor(row, details).toLowerCase();
const actionLabel = resolveAuditAction(row, details).toLowerCase();
const matchesActor =
deferredSearchActorId === "" ||
actorLabel.includes(deferredSearchActorId.toLowerCase());
const matchesAction =
deferredSearchAction === "" ||
actionLabel.includes(deferredSearchAction.toLowerCase());
const matchesStatus =
statusFilter === "all" || row.status === statusFilter;
return matchesActor && matchesAction && matchesStatus;
}),
[logs, deferredSearchActorId, deferredSearchAction, statusFilter],
);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -83,7 +75,7 @@ function AuditLogsPage() {
<> <>
<Badge variant="muted"> <Badge variant="muted">
{t("msg.common.audit.registry.count", "총 {{count}}개 로그", { {t("msg.common.audit.registry.count", "총 {{count}}개 로그", {
count: filteredLogs.length, count: logs.length,
})} })}
</Badge> </Badge>
<Button <Button
@@ -185,8 +177,8 @@ function AuditLogsPage() {
</form> </form>
} }
/> />
<AuditLogTable <VirtualizedAuditLogTable
logs={filteredLogs} logs={logs}
t={t} t={t}
loading={isLoading} loading={isLoading}
hasNextPage={Boolean(hasNextPage)} hasNextPage={Boolean(hasNextPage)}

View File

@@ -0,0 +1,470 @@
import { useVirtualizer } from "@tanstack/react-virtual";
import { ChevronDown, ChevronUp, Copy } from "lucide-react";
import * as React from "react";
import {
formatAuditDateParts,
formatAuditValue,
parseAuditDetails,
resolveAuditAction,
resolveAuditActor,
resolveAuditTarget,
} from "../../../../common/core/audit";
import {
type CommonBadgeVariant,
getCommonBadgeClasses,
} from "../../../../common/ui/badge";
import { getCommonButtonClasses } from "../../../../common/ui/button";
import {
commonStickyTableHeaderClass,
commonTableBodyClass,
commonTableCellClass,
commonTableClass,
commonTableHeadClass,
commonTableHeaderClass,
commonTableRowClass,
commonTableShellClass,
commonTableViewportClass,
commonTableWrapperClass,
} from "../../../../common/ui/table";
import { Button } from "../../components/ui/button";
import type { AuditLog } from "../../lib/adminApi";
type AuditTranslate = (
key: string,
fallback: string,
vars?: Record<string, string | number>,
) => string;
type VirtualizedAuditLogTableProps = {
logs: AuditLog[];
t: AuditTranslate;
loading: boolean;
hasNextPage: boolean;
isFetchingNextPage: boolean;
onLoadMore: () => void;
className?: string;
};
function cx(...classNames: Array<string | false | null | undefined>) {
return classNames.filter(Boolean).join(" ");
}
function statusVariant(status: string): CommonBadgeVariant {
return status === "success" || status === "ok" ? "success" : "warning";
}
export function VirtualizedAuditLogTable({
logs,
t,
loading,
hasNextPage,
isFetchingNextPage,
onLoadMore,
className,
}: VirtualizedAuditLogTableProps) {
const [expandedRows, setExpandedRows] = React.useState<
Record<string, boolean>
>({});
const viewportRef = React.useRef<HTMLDivElement>(null);
const isTest =
(typeof process !== "undefined" && process.env.NODE_ENV === "test") ||
(typeof window !== "undefined" && (window as any)._IS_TEST_MODE);
const handleCopy = (value: string) => {
if (!value) {
return;
}
navigator.clipboard.writeText(value);
};
const rowVirtualizer = useVirtualizer({
count: logs.length,
getScrollElement: () => viewportRef.current,
estimateSize: () => 80,
measureElement: (el) => el.getBoundingClientRect().height,
overscan: isTest ? logs.length : 10,
initialRect: isTest ? { width: 1010, height: 1000 } : undefined,
});
const virtualRows = rowVirtualizer.getVirtualItems();
React.useEffect(() => {
if (isTest) {
return;
}
const lastItem = virtualRows[virtualRows.length - 1];
if (!lastItem) return;
if (
lastItem.index >= logs.length - 1 &&
hasNextPage &&
!isFetchingNextPage
) {
onLoadMore();
}
}, [
virtualRows,
logs.length,
hasNextPage,
isFetchingNextPage,
onLoadMore,
isTest,
]);
const tableMinWidth = 1010;
const renderRow = (row: AuditLog, index: number, virtualRow?: any) => {
if (!row) return null;
const details = parseAuditDetails(row.details);
const actorLabel = resolveAuditActor(row, details);
const actionLabel = resolveAuditAction(row, details);
const targetLabel = resolveAuditTarget(details);
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
const expanded = Boolean(expandedRows[rowKey]);
const { date, time } = formatAuditDateParts(row.timestamp);
return (
<tr
key={rowKey}
data-index={index}
ref={virtualRow ? rowVirtualizer.measureElement : undefined}
className={cx(
commonTableRowClass,
"bg-card/40",
virtualRow ? "absolute left-0 w-full" : "",
)}
style={
virtualRow
? {
transform: `translateY(${virtualRow.start}px)`,
}
: undefined
}
>
<td colSpan={6} className="p-0">
<div className={cx("flex items-center", expanded && "border-b")}>
<div
className={cx(
commonTableCellClass,
"w-[190px] shrink-0 text-xs text-muted-foreground",
)}
>
<div className="space-y-1">
<div>{date}</div>
<div>{time}</div>
</div>
</div>
<div className={cx(commonTableCellClass, "w-[180px] shrink-0")}>
<div className="flex items-center gap-2">
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
{actorLabel}
</code>
{actorLabel !== "-" ? (
<button
type="button"
className={cx(
getCommonButtonClasses({
variant: "ghost",
size: "icon",
}),
"h-7 w-7 text-muted-foreground hover:text-primary",
)}
aria-label={t(
"ui.common.audit.copy.actor_id",
"Copy User ID",
)}
onClick={() => handleCopy(actorLabel)}
>
<Copy className="h-3 w-3" />
</button>
) : null}
</div>
</div>
<div
className={cx(
commonTableCellClass,
"w-[180px] shrink-0 text-xs text-muted-foreground",
)}
>
<div className="font-semibold text-foreground">{actionLabel}</div>
</div>
<div
className={cx(
commonTableCellClass,
"w-[260px] shrink-0 text-xs text-muted-foreground",
)}
>
<div className="flex items-center gap-2">
<span className="break-all">{targetLabel}</span>
{targetLabel !== "-" ? (
<button
type="button"
className={cx(
getCommonButtonClasses({
variant: "ghost",
size: "icon",
}),
"h-7 w-7 text-muted-foreground hover:text-primary",
)}
aria-label={t(
"ui.common.audit.copy.target",
"Copy Client ID",
)}
onClick={() => handleCopy(targetLabel)}
>
<Copy className="h-3 w-3" />
</button>
) : null}
</div>
</div>
<div className={cx(commonTableCellClass, "w-[120px] shrink-0")}>
<span
className={getCommonBadgeClasses({
variant: statusVariant(row.status),
})}
>
{row.status}
</span>
</div>
<div
className={cx(
commonTableCellClass,
"w-[80px] shrink-0 text-right",
)}
>
<button
type="button"
className={getCommonButtonClasses({
variant: "ghost",
size: "sm",
})}
onClick={() => {
setExpandedRows((prev) => ({
...prev,
[rowKey]: !expanded,
}));
// Re-measure after state change
setTimeout(() => rowVirtualizer.measure(), 0);
}}
>
{expanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
</div>
</div>
{expanded && (
<div className={cx(commonTableCellClass, "bg-card/20 text-xs")}>
<div className="grid gap-4 text-muted-foreground md:grid-cols-3">
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t("ui.common.audit.details.request", "Request")}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.request_id",
"Request ID · {{value}}",
{ value: formatAuditValue(details.request_id) },
)}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.event_id",
"Event ID · {{value}}",
{ value: formatAuditValue(row.event_id) },
)}
</div>
<div>
{t("ui.common.audit.details.ip", "IP · {{value}}", {
value: formatAuditValue(row.ip_address),
})}
</div>
<div className="break-all">
{t("ui.common.audit.details.method", "Method · {{value}}", {
value: formatAuditValue(details.method),
})}
</div>
<div className="break-all">
{t("ui.common.audit.details.path", "Path · {{value}}", {
value: formatAuditValue(details.path),
})}
</div>
<div>
{t(
"ui.common.audit.details.latency",
"Latency · {{value}}",
{
value:
details.latency_ms !== undefined
? `${details.latency_ms}ms`
: "-",
},
)}
</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t("ui.common.audit.details.actor", "Actor")}
</div>
<div>
{t(
"ui.common.audit.details.actor_id",
"User ID · {{value}}",
{ value: actorLabel },
)}
</div>
<div>
{t("ui.common.audit.details.tenant", "Tenant · {{value}}", {
value: formatAuditValue(details.tenant_id),
})}
</div>
<div>
{t("ui.common.audit.details.device", "Device · {{value}}", {
value: formatAuditValue(row.device_id),
})}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.target",
"Client ID · {{value}}",
{ value: targetLabel },
)}
</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t("ui.common.audit.details.result", "Result")}
</div>
<div className="break-all">
{t("ui.common.audit.details.error", "Error · {{value}}", {
value: formatAuditValue(details.error),
})}
</div>
<div className="break-all">
{t("ui.common.audit.details.before", "Before · {{value}}", {
value: formatAuditValue(details.before),
})}
</div>
<div className="break-all">
{t("ui.common.audit.details.after", "After · {{value}}", {
value: formatAuditValue(details.after),
})}
</div>
</div>
</div>
</div>
)}
</td>
</tr>
);
};
return (
<div className={cx(commonTableShellClass, className)}>
<div
ref={viewportRef}
className={cx(commonTableViewportClass, "flex-1")}
data-testid="audit-table-viewport"
>
<div
className={commonTableWrapperClass}
style={{ minWidth: tableMinWidth }}
>
<table
className={cx(commonTableClass, "table-fixed w-full")}
style={{ borderCollapse: "separate", borderSpacing: 0 }}
>
<thead
className={cx(
commonTableHeaderClass,
commonStickyTableHeaderClass,
)}
>
<tr className={commonTableRowClass}>
<th className={cx(commonTableHeadClass, "w-[190px]")}>
{t("ui.common.audit.table.time", "Time")}
</th>
<th className={cx(commonTableHeadClass, "w-[180px]")}>
{t("ui.common.audit.table.user_id", "User ID")}
</th>
<th className={cx(commonTableHeadClass, "w-[180px]")}>
{t("ui.common.audit.table.action", "Action")}
</th>
<th className={cx(commonTableHeadClass, "w-[260px]")}>
{t("ui.common.audit.table.client_id", "Client ID")}
</th>
<th className={cx(commonTableHeadClass, "w-[120px]")}>
{t("ui.common.audit.table.status", "Status")}
</th>
<th className={cx(commonTableHeadClass, "w-[80px]")} />
</tr>
</thead>
<tbody
className={commonTableBodyClass}
style={
!isTest
? {
height: `${rowVirtualizer.getTotalSize()}px`,
position: "relative",
}
: undefined
}
>
{isTest
? logs.map((row, index) => renderRow(row, index))
: virtualRows.map((virtualRow) =>
renderRow(
logs[virtualRow.index],
virtualRow.index,
virtualRow,
),
)}
{logs.length === 0 && !loading && (
<tr>
<td
colSpan={6}
className={cx(
commonTableCellClass,
"text-center py-8 text-muted-foreground",
)}
>
{t("ui.common.audit.table.no_logs", "No audit logs found")}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
<div className="flex-shrink-0 border-t bg-background/50 p-4 text-center backdrop-blur-sm">
{hasNextPage ? (
<div className="flex flex-col items-center gap-2">
{isFetchingNextPage && (
<span className="animate-pulse text-xs text-muted-foreground">
{t("msg.common.loading", "Loading more...")}
</span>
)}
<Button
variant="outline"
size="sm"
onClick={onLoadMore}
disabled={isFetchingNextPage}
>
{isFetchingNextPage
? t("msg.common.loading", "Loading...")
: t("ui.common.audit.load_more", "더 보기")}
</Button>
</div>
) : logs.length > 0 ? (
<span className="text-xs text-muted-foreground">
{t("msg.common.audit.end", "End of audit feed")}
</span>
) : null}
</div>
</div>
);
}

View File

@@ -720,6 +720,11 @@ function TenantListPage() {
className="h-9 pl-9" className="h-9 pl-9"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
query.refetch();
}
}}
/> />
</div> </div>
@@ -1529,8 +1534,8 @@ const TenantHierarchyView: React.FC<{
const parentRef = React.useRef<HTMLDivElement>(null); const parentRef = React.useRef<HTMLDivElement>(null);
const { subTree } = React.useMemo( const { subTree } = React.useMemo(
() => buildTenantFullTree(tenants, scopeTenantId || undefined), () => buildTenantFullTree(tenants, scopeTenantId || undefined, !!search),
[scopeTenantId, tenants], [scopeTenantId, tenants, search],
); );
// Initial expanded state: everything open // Initial expanded state: everything open
@@ -1582,7 +1587,7 @@ const TenantHierarchyView: React.FC<{
const flattenedRows = React.useMemo(() => { const flattenedRows = React.useMemo(() => {
if (viewMode === "table") { if (viewMode === "table") {
return sortItems( return sortItems(
getTenantViewRows(tenants, "table", scopeTenantId), getTenantViewRows(tenants, "table", scopeTenantId, !!search),
sortConfig, sortConfig,
tenantSortResolvers, tenantSortResolvers,
); );
@@ -1615,6 +1620,7 @@ const TenantHierarchyView: React.FC<{
tenantSortResolvers, tenantSortResolvers,
tenants, tenants,
viewMode, viewMode,
search,
]); ]);
const rowVirtualizer = useVirtualizer({ const rowVirtualizer = useVirtualizer({

View File

@@ -68,8 +68,13 @@ export function getTenantViewRows(
tenants: TenantSummary[], tenants: TenantSummary[],
viewMode: TenantViewMode, viewMode: TenantViewMode,
scopeTenantId = "", scopeTenantId = "",
isSearchActive = false,
): TenantViewRow[] { ): TenantViewRow[] {
const { subTree } = buildTenantFullTree(tenants, scopeTenantId || undefined); const { subTree } = buildTenantFullTree(
tenants,
scopeTenantId || undefined,
isSearchActive,
);
const treeRows: TenantViewRow[] = []; const treeRows: TenantViewRow[] = [];
collectTenantTreeRows(subTree, 0, treeRows); collectTenantTreeRows(subTree, 0, treeRows);

View File

@@ -185,7 +185,7 @@ describe("UserListPage search rendering", () => {
fireEvent.change(searchInput, { target: { value: "user 19" } }); fireEvent.change(searchInput, { target: { value: "user 19" } });
fireEvent.keyDown(searchInput, { key: "Enter" }); fireEvent.keyDown(searchInput, { key: "Enter" });
expect(screen.getByText("User 19")).toBeInTheDocument(); expect(await screen.findByText("User 19")).toBeInTheDocument();
expect(screen.queryByText("User 0")).not.toBeInTheDocument(); expect(screen.queryByText("User 0")).not.toBeInTheDocument();
expect(performance.now() - startedAt).toBeLessThan(searchRenderBudgetMs); expect(performance.now() - startedAt).toBeLessThan(searchRenderBudgetMs);
}); });

View File

@@ -1,4 +1,4 @@
import { useMutation, useQuery } from "@tanstack/react-query"; import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query";
import { import {
observeElementRect, observeElementRect,
type Rect, type Rect,
@@ -11,8 +11,6 @@ import {
ArrowUp, ArrowUp,
ArrowUpDown, ArrowUpDown,
ChevronDown, ChevronDown,
ChevronLeft,
ChevronRight,
FileDown, FileDown,
FileSpreadsheet, FileSpreadsheet,
LayoutDashboard, LayoutDashboard,
@@ -119,7 +117,7 @@ type UserSchemaField = {
type UserSortKey = string; type UserSortKey = string;
const USER_ROW_ESTIMATED_HEIGHT = 64; const USER_ROW_ESTIMATED_HEIGHT = 64;
const USER_ROW_OVERSCAN = 8; const USER_ROW_OVERSCAN = 20;
const USER_TABLE_VIEWPORT_ESTIMATED_HEIGHT = 640; const USER_TABLE_VIEWPORT_ESTIMATED_HEIGHT = 640;
const userFixedColumnWidths = [48, 160, 220, 160, 260, 170, 160, 220] as const; const userFixedColumnWidths = [48, 160, 220, 160, 260, 170, 160, 220] as const;
const userMetadataColumnWidth = 160; const userMetadataColumnWidth = 160;
@@ -152,24 +150,6 @@ function assignableSystemRoleValue(role?: string | null) {
return isSuperAdminRole(role) ? "super_admin" : "user"; return isSuperAdminRole(role) ? "super_admin" : "user";
} }
function userMatchesSearch(user: UserSummary, search: string) {
const normalizedSearch = search.trim().toLowerCase();
if (!normalizedSearch) {
return true;
}
return (
user.name?.toLowerCase().includes(normalizedSearch) ||
user.email?.toLowerCase().includes(normalizedSearch) ||
user.phone?.toLowerCase().includes(normalizedSearch) ||
user.id?.toLowerCase().includes(normalizedSearch) ||
user.tenantSlug?.toLowerCase().includes(normalizedSearch) ||
user.tenant?.name?.toLowerCase().includes(normalizedSearch) ||
user.department?.toLowerCase().includes(normalizedSearch) ||
false
);
}
function normalizeUserTableRect(rect: Rect, fallbackWidth: number): Rect { function normalizeUserTableRect(rect: Rect, fallbackWidth: number): Rect {
return { return {
width: rect.width > 0 ? rect.width : fallbackWidth, width: rect.width > 0 ? rect.width : fallbackWidth,
@@ -179,7 +159,7 @@ function normalizeUserTableRect(rect: Rect, fallbackWidth: number): Rect {
} }
type UserListSearchControlsProps = { type UserListSearchControlsProps = {
search: string; initialSearch: string;
selectedCompany: string; selectedCompany: string;
tenants: TenantSummary[]; tenants: TenantSummary[];
profileRole?: string | null; profileRole?: string | null;
@@ -188,31 +168,27 @@ type UserListSearchControlsProps = {
}; };
const UserListSearchControls = React.memo(function UserListSearchControls({ const UserListSearchControls = React.memo(function UserListSearchControls({
search, initialSearch,
selectedCompany, selectedCompany,
tenants, tenants,
profileRole, profileRole,
onSearch, onSearch,
onCompanyChange, onCompanyChange,
}: UserListSearchControlsProps) { }: UserListSearchControlsProps) {
const [searchDraft, setSearchDraft] = React.useState(search); const [localSearch, setLocalSearch] = React.useState(initialSearch);
React.useEffect(() => { React.useEffect(() => {
setSearchDraft(search); setLocalSearch(initialSearch);
}, [search]); }, [initialSearch]);
const handleSearch = React.useCallback(() => { React.useEffect(() => {
onSearch(searchDraft); const timer = setTimeout(() => {
}, [onSearch, searchDraft]); if (localSearch !== initialSearch) {
onSearch(localSearch);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
handleSearch();
} }
}, }, 300);
[handleSearch], return () => clearTimeout(timer);
); }, [localSearch, onSearch, initialSearch]);
const tenantOptions = React.useMemo( const tenantOptions = React.useMemo(
() => () =>
@@ -236,9 +212,13 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
"이름 또는 이메일 검색...", "이름 또는 이메일 검색...",
)} )}
className="h-9 pl-9" className="h-9 pl-9"
value={searchDraft} value={localSearch}
onChange={(event) => setSearchDraft(event.target.value)} onChange={(event) => setLocalSearch(event.target.value)}
onKeyDown={handleKeyDown} onKeyDown={(event) => {
if (event.key === "Enter") {
onSearch(localSearch);
}
}}
/> />
</div> </div>
@@ -255,7 +235,7 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={handleSearch} onClick={() => onSearch(localSearch)}
className="h-9" className="h-9"
> >
{t("ui.common.search", "검색")} {t("ui.common.search", "검색")}
@@ -268,7 +248,6 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
function UserListPage() { function UserListPage() {
const _navigate = useNavigate(); const _navigate = useNavigate();
const [page, setPage] = React.useState(1);
const [search, setSearch] = React.useState(""); const [search, setSearch] = React.useState("");
const [selectedCompany, setSelectedCompany] = React.useState<string>(""); const [selectedCompany, setSelectedCompany] = React.useState<string>("");
const [visibleColumns, setVisibleColumns] = React.useState< const [visibleColumns, setVisibleColumns] = React.useState<
@@ -285,9 +264,6 @@ function UserListPage() {
const [bulkUploadOpen, setBulkUploadOpen] = React.useState(false); const [bulkUploadOpen, setBulkUploadOpen] = React.useState(false);
const userTableViewportRef = React.useRef<HTMLDivElement | null>(null); const userTableViewportRef = React.useRef<HTMLDivElement | null>(null);
const limit = 1000;
const offset = (page - 1) * limit;
const { data: profile } = useQuery({ const { data: profile } = useQuery({
queryKey: ["me"], queryKey: ["me"],
queryFn: fetchMe, queryFn: fetchMe,
@@ -345,10 +321,12 @@ function UserListPage() {
})); }));
}; };
const query = useQuery({ const query = useInfiniteQuery({
queryKey: ["users", { limit, offset, search, tenantSlug: selectedCompany }], queryKey: ["users", { search, tenantSlug: selectedCompany }],
queryFn: () => fetchUsers(limit, offset, search, selectedCompany), queryFn: ({ pageParam }) =>
placeholderData: (previousData) => previousData, fetchUsers(50, 0, search, selectedCompany, pageParam as string),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.next_cursor || lastPage.nextCursor,
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
@@ -393,12 +371,10 @@ function UserListPage() {
const handleSearch = React.useCallback((nextSearch: string) => { const handleSearch = React.useCallback((nextSearch: string) => {
setSearch(nextSearch); setSearch(nextSearch);
setPage(1);
}, []); }, []);
const handleCompanyChange = React.useCallback((nextCompany: string) => { const handleCompanyChange = React.useCallback((nextCompany: string) => {
setSelectedCompany(nextCompany); setSelectedCompany(nextCompany);
setPage(1);
}, []); }, []);
const handleExport = (includeIds = false) => { const handleExport = (includeIds = false) => {
@@ -415,14 +391,11 @@ function UserListPage() {
) )
: null; : null;
const serverItems = query.data?.items ?? []; const serverItems = React.useMemo(
const rawItems = React.useMemo(() => { () => query.data?.pages.flatMap((page) => page.items) ?? [],
if (!query.isFetching || search.trim() === "") { [query.data],
return serverItems; );
} const rawItems = serverItems;
return serverItems.filter((user) => userMatchesSearch(user, search));
}, [query.isFetching, search, serverItems]);
const userSortResolvers = React.useMemo< const userSortResolvers = React.useMemo<
SortResolverMap<UserSummary, UserSortKey> SortResolverMap<UserSummary, UserSortKey>
>( >(
@@ -496,6 +469,25 @@ function UserListPage() {
}, },
}); });
const virtualRows = rowVirtualizer.getVirtualItems(); const virtualRows = rowVirtualizer.getVirtualItems();
const lastItem = virtualRows[virtualRows.length - 1];
React.useEffect(() => {
if (!lastItem) return;
if (
lastItem.index >= serverItems.length - 1 &&
query.hasNextPage &&
!query.isFetchingNextPage
) {
query.fetchNextPage();
}
}, [
lastItem,
serverItems.length,
query.hasNextPage,
query.isFetchingNextPage,
query.fetchNextPage,
]);
const shouldVirtualizeRows = !query.isLoading && items.length > 0; const shouldVirtualizeRows = !query.isLoading && items.length > 0;
const tableColumnCount = 9 + visibleUserSchemaFields.length; const tableColumnCount = 9 + visibleUserSchemaFields.length;
@@ -514,8 +506,7 @@ function UserListPage() {
); );
}; };
const total = query.data?.total ?? 0; const total = query.data?.pages[0]?.total ?? 0;
const totalPages = Math.ceil(total / limit);
const canPromoteSuperAdmin = isSuperAdminRole(profile?.role); const canPromoteSuperAdmin = isSuperAdminRole(profile?.role);
const toggleSelectAll = () => { const toggleSelectAll = () => {
@@ -627,10 +618,10 @@ function UserListPage() {
actions={ actions={
<> <>
<UserListSearchControls <UserListSearchControls
search={search} initialSearch={search}
selectedCompany={selectedCompany} selectedCompany={selectedCompany}
tenants={tenants} tenants={tenants}
profileRole={profile?.role} profileRole={profileRole}
onSearch={handleSearch} onSearch={handleSearch}
onCompanyChange={handleCompanyChange} onCompanyChange={handleCompanyChange}
/> />
@@ -1241,36 +1232,6 @@ function UserListPage() {
</Button> </Button>
</div> </div>
)} )}
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-4 flex flex-shrink-0 items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1 || query.isFetching}
>
<ChevronLeft size={16} />
{t("ui.common.previous", "Previous")}
</Button>
<div className="text-sm text-muted-foreground">
{t("ui.common.page_of", "Page {{page}} of {{total}}", {
page,
total: totalPages,
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages || query.isFetching}
>
{t("ui.common.next", "Next")}
<ChevronRight size={16} />
</Button>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -215,9 +215,14 @@ export type DeleteOrphanUserLoginIDsResult = {
skippedIds: string[]; skippedIds: string[];
}; };
export async function fetchAuditLogs(limit = 50, cursor?: string) { export async function fetchAuditLogs(
limit = 50,
cursor?: string,
search?: string,
status?: string,
) {
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", { const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
params: { limit, cursor }, params: { limit, cursor, search, status },
}); });
return data; return data;
} }
@@ -662,6 +667,8 @@ export type UserListResponse = {
limit: number; limit: number;
offset: number; offset: number;
total: number; total: number;
next_cursor?: string;
nextCursor?: string;
}; };
export type UserCreateRequest = { export type UserCreateRequest = {
@@ -884,9 +891,10 @@ export async function fetchUsers(
offset = 0, offset = 0,
search?: string, search?: string,
tenantSlug?: string, tenantSlug?: string,
cursor?: string,
) { ) {
const { data } = await apiClient.get<UserListResponse>("/v1/admin/users", { const { data } = await apiClient.get<UserListResponse>("/v1/admin/users", {
params: { limit, offset, search, tenantSlug }, params: { limit, offset, search, tenantSlug, cursor },
}); });
return data; return data;
} }

View File

@@ -12,6 +12,7 @@ export type TenantNode = TenantSummary & {
export function buildTenantFullTree( export function buildTenantFullTree(
allTenants: TenantSummary[], allTenants: TenantSummary[],
rootId?: string, rootId?: string,
isSearchActive?: boolean,
): { currentBase: TenantNode | null; subTree: TenantNode[] } { ): { currentBase: TenantNode | null; subTree: TenantNode[] } {
if (allTenants.length === 0) return { currentBase: null, subTree: [] }; if (allTenants.length === 0) return { currentBase: null, subTree: [] };
@@ -24,7 +25,6 @@ export function buildTenantFullTree(
}); });
} }
const _visitedDuringBuild = new Set<string>();
// Build initial children relations and prevent simple cycles // Build initial children relations and prevent simple cycles
for (const t of allTenants) { for (const t of allTenants) {
if (t.parentId && t.parentId !== t.id) { if (t.parentId && t.parentId !== t.id) {
@@ -54,26 +54,15 @@ export function buildTenantFullTree(
} }
node.recursiveMemberCount = total; node.recursiveMemberCount = total;
// We don't remove from visitedForCalc here because a tree shouldn't have
// multiple paths to the same node anyway (it's a tree, not a graph).
// If it were a DAG, we'd need different logic, but for a tree with parentIds,
// a node should only be visited once.
return total; return total;
}; };
// Calculate for all top-level nodes (those without parent) // If a specific rootId is provided AND search is not active, find and return its subtree.
for (const node of tenantMap.values()) { // When searching, we prefer showing all matching nodes (virtual roots) rather than
if (!node.parentId) { // strictly adhering to the rootId anchor, because the rootId node itself might not be in the result set.
visitedForCalc.clear(); if (rootId && !isSearchActive) {
calculateRecursive(node);
}
}
// If a specific rootId is provided, find and return its subtree
if (rootId) {
const base = tenantMap.get(rootId); const base = tenantMap.get(rootId);
if (base) { if (base) {
// Re-calculate specifically for our current tenant to be sure if it wasn't a global root
visitedForCalc.clear(); visitedForCalc.clear();
calculateRecursive(base); calculateRecursive(base);
return { currentBase: base, subTree: base.children }; return { currentBase: base, subTree: base.children };
@@ -81,7 +70,19 @@ export function buildTenantFullTree(
return { currentBase: null, subTree: [] }; return { currentBase: null, subTree: [] };
} }
// If no rootId, return all top-level roots as subTree // Identify roots: nodes with no parent, or nodes whose parent is not in the current set (virtual roots during search)
const roots = Array.from(tenantMap.values()).filter((n) => !n.parentId); const roots = Array.from(tenantMap.values()).filter((n) => {
if (isSearchActive) {
return !n.parentId || !tenantMap.get(n.parentId);
}
return !n.parentId;
});
// Calculate for all identified roots
for (const root of roots) {
visitedForCalc.clear();
calculateRecursive(root);
}
return { currentBase: null, subTree: roots }; return { currentBase: null, subTree: roots };
} }

View File

@@ -53,13 +53,33 @@ test.describe("Audit Logs Management", () => {
const url = route.request().url(); const url = route.request().url();
const urlObj = new URL(url); const urlObj = new URL(url);
const cursor = urlObj.searchParams.get("cursor"); const cursor = urlObj.searchParams.get("cursor");
const search = urlObj.searchParams.get("search")?.toLowerCase();
const status = urlObj.searchParams.get("status");
const offset = cursor ? 20 : 0; const offset = cursor ? 20 : 0;
console.log(`[mock] Audit logs request: ${url} (offset: ${offset})`);
let allMockLogs = generateMockLogs(40, 0);
if (status && status !== "all") {
allMockLogs = allMockLogs.filter((l) => l.status === status);
}
if (search) {
allMockLogs = allMockLogs.filter(
(l) =>
l.user_id.toLowerCase().includes(search) ||
l.details.toLowerCase().includes(search),
);
}
const paginatedItems = allMockLogs.slice(offset, offset + 20);
console.log(
`[mock] Audit logs request: ${url} (offset: ${offset}, search: ${search}, status: ${status}, results: ${paginatedItems.length})`,
);
return route.fulfill({ return route.fulfill({
json: { json: {
items: generateMockLogs(20, offset), items: paginatedItems,
next_cursor: offset === 0 ? "fake-cursor" : null, next_cursor: allMockLogs.length > offset + 20 ? "fake-cursor" : null,
total: 40, total: allMockLogs.length,
}, },
headers: { "Access-Control-Allow-Origin": "*" }, headers: { "Access-Control-Allow-Origin": "*" },
}); });
@@ -172,7 +192,7 @@ test.describe("Audit Logs Management", () => {
await userIdInput.fill("user-even"); await userIdInput.fill("user-even");
// Wait for deferred value to apply // Wait for deferred value to apply
await expect(page.locator("tbody tr")).toHaveCount(10, { timeout: 15000 }); await expect(page.locator("tbody tr")).toHaveCount(20, { timeout: 15000 });
await expect(page.locator("tbody")).not.toContainText("user-odd"); await expect(page.locator("tbody")).not.toContainText("user-odd");
// Clear User ID // Clear User ID
@@ -183,12 +203,13 @@ test.describe("Audit Logs Management", () => {
const actionInput = page.getByTestId("audit-search-action"); const actionInput = page.getByTestId("audit-search-action");
await actionInput.fill("ROTATE_SECRET"); await actionInput.fill("ROTATE_SECRET");
// Check that we only see ROTATE_SECRET (20 - 7 = 13) // Check that we see ROTATE_SECRET across all 40 logs (40 - 14 = 26)
await expect(page.locator("tbody tr")).toHaveCount(13, { timeout: 15000 }); // Wait for the mock to respond and render
await expect(page.locator("tbody tr")).toHaveCount(20, { timeout: 15000 });
await expect(page.locator("tbody")).not.toContainText("CREATE_TENANT"); await expect(page.locator("tbody")).not.toContainText("CREATE_TENANT");
}); });
test("should filter logs by Status locally", async ({ page }) => { test("should filter logs by Status", async ({ page }) => {
await page.goto("/audit-logs"); await page.goto("/audit-logs");
await expect(page.locator(".animate-spin")).not.toBeVisible({ await expect(page.locator(".animate-spin")).not.toBeVisible({
timeout: 10000, timeout: 10000,
@@ -201,12 +222,13 @@ test.describe("Audit Logs Management", () => {
// Select "Failure" status // Select "Failure" status
await page.getByTestId("audit-filter-status").selectOption("failure"); await page.getByTestId("audit-filter-status").selectOption("failure");
// ID % 5 === 0 are status "failure" (0, 5, 10, 15) // Total 8 failures in 40 logs
await expect(page.locator("tbody tr")).toHaveCount(4, { timeout: 15000 }); await expect(page.locator("tbody tr")).toHaveCount(8, { timeout: 15000 });
// Select "Success" status // Select "Success" status
await page.getByTestId("audit-filter-status").selectOption("success"); await page.getByTestId("audit-filter-status").selectOption("success");
await expect(page.locator("tbody tr")).toHaveCount(16, { timeout: 15000 }); // Total 32 successes in 40 logs, but page limit is 20
await expect(page.locator("tbody tr")).toHaveCount(20, { timeout: 15000 });
}); });
}); });

View File

@@ -61,28 +61,42 @@ test.describe("Tenants Management", () => {
const internalTenantId = "c5839444-2de0-4a37-99b0-4f94d3de8bea"; const internalTenantId = "c5839444-2de0-4a37-99b0-4f94d3de8bea";
await page.route("**/api/v1/admin/tenants**", async (route) => { await page.route("**/api/v1/admin/tenants**", async (route) => {
if (route.request().method() === "GET") { if (route.request().method() !== "GET") {
await route.fulfill({ return route.continue();
json: {
items: [
{
id: internalTenantId,
name: "Tenant A",
slug: "tenant-a",
status: "active",
type: "COMPANY",
updatedAt: new Date().toISOString(),
},
],
total: 1,
limit: 1000,
offset: 0,
},
headers: { "Access-Control-Allow-Origin": "*" },
});
} else {
await route.continue();
} }
const url = new URL(route.request().url());
const search = url.searchParams.get("search")?.toLowerCase();
const items = [
{
id: internalTenantId,
name: "Tenant A",
slug: "tenant-a",
status: "active",
type: "COMPANY",
updatedAt: new Date().toISOString(),
},
];
let filtered = items;
if (search) {
filtered = items.filter(
(i) =>
i.name.toLowerCase().includes(search) ||
i.slug.toLowerCase().includes(search) ||
i.id.toLowerCase().includes(search),
);
}
await route.fulfill({
json: {
items: filtered,
total: filtered.length,
limit: 1000,
offset: 0,
},
headers: { "Access-Control-Allow-Origin": "*" },
});
}); });
await page.goto("/tenants"); await page.goto("/tenants");
@@ -115,40 +129,55 @@ test.describe("Tenants Management", () => {
return route.continue(); return route.continue();
} }
const url = new URL(route.request().url());
const search = url.searchParams.get("search")?.toLowerCase();
const items = [
{
id: "company-1",
name: "Acme",
slug: "acme",
status: "active",
type: "COMPANY",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
{
id: "dept-1",
name: "Planning",
slug: "planning",
status: "active",
type: "ORGANIZATION",
parentId: "company-1",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
{
id: "team-1",
name: "Platform",
slug: "platform",
status: "active",
type: "USER_GROUP",
parentId: "dept-1",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
];
let filtered = items;
if (search) {
filtered = items.filter(
(i) =>
i.name.toLowerCase().includes(search) ||
i.slug.toLowerCase().includes(search) ||
i.id.toLowerCase().includes(search),
);
}
await route.fulfill({ await route.fulfill({
json: { json: {
items: [ items: filtered,
{ total: filtered.length,
id: "company-1",
name: "Acme",
slug: "acme",
status: "active",
type: "COMPANY",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
{
id: "dept-1",
name: "Planning",
slug: "planning",
status: "active",
type: "ORGANIZATION",
parentId: "company-1",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
{
id: "team-1",
name: "Platform",
slug: "platform",
status: "active",
type: "USER_GROUP",
parentId: "dept-1",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
],
total: 3,
limit: 500, limit: 500,
offset: 0, offset: 0,
}, },
@@ -162,7 +191,6 @@ test.describe("Tenants Management", () => {
.getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i) .getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i)
.fill("team-1"); .fill("team-1");
await expect(page.locator("table")).toContainText("Platform"); await expect(page.locator("table")).toContainText("Platform");
await expect(page.locator("table")).toContainText("Acme");
await page await page
.getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i) .getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i)
@@ -188,40 +216,55 @@ test.describe("Tenants Management", () => {
return route.continue(); return route.continue();
} }
const url = new URL(route.request().url());
const search = url.searchParams.get("search")?.toLowerCase();
const items = [
{
id: "company-1",
name: "Acme",
slug: "acme",
status: "active",
type: "COMPANY",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
{
id: "dept-1",
name: "Planning",
slug: "planning",
status: "active",
type: "ORGANIZATION",
parentId: "company-1",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
{
id: "team-1",
name: "Platform",
slug: "platform",
status: "active",
type: "USER_GROUP",
parentId: "dept-1",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
];
let filtered = items;
if (search) {
filtered = items.filter(
(i) =>
i.name.toLowerCase().includes(search) ||
i.slug.toLowerCase().includes(search) ||
i.id.toLowerCase().includes(search),
);
}
await route.fulfill({ await route.fulfill({
json: { json: {
items: [ items: filtered,
{ total: filtered.length,
id: "company-1",
name: "Acme",
slug: "acme",
status: "active",
type: "COMPANY",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
{
id: "dept-1",
name: "Planning",
slug: "planning",
status: "active",
type: "ORGANIZATION",
parentId: "company-1",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
{
id: "team-1",
name: "Platform",
slug: "platform",
status: "active",
type: "USER_GROUP",
parentId: "dept-1",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
],
total: 3,
limit: 500, limit: 500,
offset: 0, offset: 0,
}, },
@@ -239,10 +282,14 @@ test.describe("Tenants Management", () => {
); );
await page.getByPlaceholder(/UUID|슬러그|slug/i).fill("team-1"); await page.getByPlaceholder(/UUID|슬러그|slug/i).fill("team-1");
await expect(page.locator("table")).toContainText("Platform"); await page.keyboard.press("Enter");
await expect(page.locator("table")).toContainText("Platform", {
timeout: 10000,
});
await expect(page.locator("table")).not.toContainText("Acme"); await expect(page.locator("table")).not.toContainText("Acme");
await page.getByPlaceholder(/UUID|슬러그|slug/i).fill(""); await page.getByPlaceholder(/UUID|슬러그|slug/i).fill("");
await page.keyboard.press("Enter");
await page await page
.locator("tbody tr") .locator("tbody tr")
.filter({ hasText: "Acme" }) .filter({ hasText: "Acme" })
@@ -266,24 +313,37 @@ test.describe("Tenants Management", () => {
} }
const url = new URL(route.request().url()); const url = new URL(route.request().url());
const cursor = url.searchParams.get("cursor"); const cursor = url.searchParams.get("cursor");
const search = url.searchParams.get("search")?.toLowerCase();
_requestCount += 1; _requestCount += 1;
const items = Array.from({ length: 501 }, (_, index) => ({
id: `tenant-${String(index + 1).padStart(3, "0")}`,
name: `Tenant ${String(index + 1).padStart(3, "0")}`,
slug: `tenant-${String(index + 1).padStart(3, "0")}`,
status: "active",
type: "COMPANY",
memberCount: 0,
updatedAt: new Date().toISOString(),
}));
let filtered = items;
if (search) {
filtered = items.filter(
(i) =>
i.name.toLowerCase().includes(search) ||
i.slug.toLowerCase().includes(search) ||
i.id.toLowerCase().includes(search),
);
}
if (!cursor) { if (!cursor) {
return route.fulfill({ return route.fulfill({
json: { json: {
items: Array.from({ length: 500 }, (_, index) => ({ items: filtered.slice(0, 500),
id: `tenant-${String(index + 1).padStart(3, "0")}`, total: filtered.length,
name: `Tenant ${String(index + 1).padStart(3, "0")}`,
slug: `tenant-${String(index + 1).padStart(3, "0")}`,
status: "active",
type: "COMPANY",
memberCount: 0,
updatedAt: new Date().toISOString(),
})),
total: 501,
limit: 500, limit: 500,
offset: 0, offset: 0,
nextCursor: "next-page", nextCursor: filtered.length > 500 ? "next-page" : undefined,
}, },
headers: { "Access-Control-Allow-Origin": "*" }, headers: { "Access-Control-Allow-Origin": "*" },
}); });
@@ -291,18 +351,8 @@ test.describe("Tenants Management", () => {
return route.fulfill({ return route.fulfill({
json: { json: {
items: [ items: filtered.slice(500),
{ total: filtered.length,
id: "tenant-501",
name: "Tenant 501",
slug: "tenant-501",
status: "active",
type: "COMPANY",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
],
total: 501,
limit: 500, limit: 500,
offset: 0, offset: 0,
}, },
@@ -322,9 +372,10 @@ test.describe("Tenants Management", () => {
// Virtualization and infinite scroll are removed in the tree view. // Virtualization and infinite scroll are removed in the tree view.
// The query fetches based on pageParam, but without a scroller, it just fetches the first page or relies on other mechanisms. // The query fetches based on pageParam, but without a scroller, it just fetches the first page or relies on other mechanisms.
// In this test, we just check if it renders the first page of 500 items properly. // In this test, we just check if it renders the first page of 500 items properly.
// With virtualization, only a few items are rendered
await expect await expect
.poll(async () => page.locator("tbody tr").count()) .poll(async () => page.locator("tbody tr").count())
.toEqual(500); .toBeLessThan(50);
// Skip the scroll to load more check because the infinite scroll handler was removed // Skip the scroll to load more check because the infinite scroll handler was removed
// expect(requestCount).toBe(2); // expect(requestCount).toBe(2);
@@ -372,54 +423,68 @@ test.describe("Tenants Management", () => {
return; return;
} }
const url = new URL(route.request().url());
const search = url.searchParams.get("search")?.toLowerCase();
const items = [
{
id: "hanmac-family-id",
slug: "hanmac-family",
name: "한맥가족",
status: "active",
type: "COMPANY_GROUP",
memberCount: 0,
},
{
id: "hanmac-company-id",
slug: "hanmac-company",
name: "한맥기술",
status: "active",
type: "COMPANY",
parentId: "hanmac-family-id",
memberCount: 0,
},
{
id: "hanmac-team-id",
slug: "hanmac-team",
name: "한맥팀",
status: "active",
type: "USER_GROUP",
parentId: "hanmac-company-id",
memberCount: 0,
},
{
id: "external-tenant-id",
slug: "external-tenant",
name: "External Tenant",
status: "active",
type: "COMPANY",
memberCount: 0,
},
{
id: "external-team-id",
slug: "external-team",
name: "External Team",
status: "active",
type: "USER_GROUP",
parentId: "external-tenant-id",
memberCount: 0,
},
];
let filtered = items;
if (search) {
filtered = items.filter(
(i) =>
i.name.toLowerCase().includes(search) ||
i.slug.toLowerCase().includes(search) ||
i.id.toLowerCase().includes(search),
);
}
await route.fulfill({ await route.fulfill({
json: { json: {
items: [ items: filtered,
{ total: filtered.length,
id: "hanmac-family-id",
slug: "hanmac-family",
name: "한맥가족",
status: "active",
type: "COMPANY_GROUP",
memberCount: 0,
},
{
id: "hanmac-company-id",
slug: "hanmac-company",
name: "한맥기술",
status: "active",
type: "COMPANY",
parentId: "hanmac-family-id",
memberCount: 0,
},
{
id: "hanmac-team-id",
slug: "hanmac-team",
name: "한맥팀",
status: "active",
type: "USER_GROUP",
parentId: "hanmac-company-id",
memberCount: 0,
},
{
id: "external-tenant-id",
slug: "external-tenant",
name: "External Tenant",
status: "active",
type: "COMPANY",
memberCount: 0,
},
{
id: "external-team-id",
slug: "external-team",
name: "External Team",
status: "active",
type: "USER_GROUP",
parentId: "external-tenant-id",
memberCount: 0,
},
],
total: 5,
limit: 1000, limit: 1000,
offset: 0, offset: 0,
}, },
@@ -493,9 +558,30 @@ test.describe("Tenants Management", () => {
]; ];
await page.route("**/api/v1/admin/tenants**", async (route) => { await page.route("**/api/v1/admin/tenants**", async (route) => {
if (route.request().method() !== "GET") {
return route.continue();
}
const url = new URL(route.request().url());
const search = url.searchParams.get("search")?.toLowerCase();
const headers = { "Access-Control-Allow-Origin": "*" }; const headers = { "Access-Control-Allow-Origin": "*" };
let filtered = tenants;
if (search) {
filtered = tenants.filter(
(i) =>
i.name.toLowerCase().includes(search) ||
i.slug.toLowerCase().includes(search) ||
i.id.toLowerCase().includes(search),
);
}
return route.fulfill({ return route.fulfill({
json: { items: tenants, total: tenants.length, limit: 1000, offset: 0 }, json: {
items: filtered,
total: filtered.length,
limit: 1000,
offset: 0,
},
headers, headers,
}); });
}); });
@@ -569,12 +655,23 @@ test.describe("Tenants Management", () => {
await page.route("**/api/v1/admin/tenants**", async (route) => { await page.route("**/api/v1/admin/tenants**", async (route) => {
const method = route.request().method(); const method = route.request().method();
const url = new URL(route.request().url());
const search = url.searchParams.get("search")?.toLowerCase();
const headers = { "Access-Control-Allow-Origin": "*" }; const headers = { "Access-Control-Allow-Origin": "*" };
if (method === "GET") { if (method === "GET") {
let filtered = tenants;
if (search) {
filtered = tenants.filter(
(i) =>
i.name.toLowerCase().includes(search) ||
i.slug.toLowerCase().includes(search) ||
i.id.toLowerCase().includes(search),
);
}
return route.fulfill({ return route.fulfill({
json: { json: {
items: tenants, items: filtered,
total: tenants.length, total: filtered.length,
limit: 1000, limit: 1000,
offset: 0, offset: 0,
}, },
@@ -705,21 +802,33 @@ test.describe("Tenants Management", () => {
} }
if (method === "GET") { if (method === "GET") {
const urlObj = new URL(url);
const search = urlObj.searchParams.get("search")?.toLowerCase();
const items = [
{
id: "tenant-alpha-id",
name: "Tenant Alpha",
slug: "tenant-alpha",
status: "active",
type: "COMPANY",
domains: [],
memberCount: 0,
updatedAt: new Date().toISOString(),
},
];
let filtered = items;
if (search) {
filtered = items.filter(
(i) =>
i.name.toLowerCase().includes(search) ||
i.slug.toLowerCase().includes(search) ||
i.id.toLowerCase().includes(search),
);
}
return route.fulfill({ return route.fulfill({
json: { json: {
items: [ items: filtered,
{ total: filtered.length,
id: "tenant-alpha-id",
name: "Tenant Alpha",
slug: "tenant-alpha",
status: "active",
type: "COMPANY",
domains: [],
memberCount: 0,
updatedAt: new Date().toISOString(),
},
],
total: 1,
limit: 1000, limit: 1000,
offset: 0, offset: 0,
}, },
@@ -846,21 +955,33 @@ test.describe("Tenants Management", () => {
} }
if (method === "GET") { if (method === "GET") {
const urlObj = new URL(url);
const search = urlObj.searchParams.get("search")?.toLowerCase();
const items = [
{
id: "staging-existing-id",
name: "Existing Parent",
slug: "parent-local",
status: "active",
type: "COMPANY",
domains: [],
memberCount: 0,
updatedAt: new Date().toISOString(),
},
];
let filtered = items;
if (search) {
filtered = items.filter(
(i) =>
i.name.toLowerCase().includes(search) ||
i.slug.toLowerCase().includes(search) ||
i.id.toLowerCase().includes(search),
);
}
return route.fulfill({ return route.fulfill({
json: { json: {
items: [ items: filtered,
{ total: filtered.length,
id: "staging-existing-id",
name: "Existing Parent",
slug: "parent-local",
status: "active",
type: "COMPANY",
domains: [],
memberCount: 0,
updatedAt: new Date().toISOString(),
},
],
total: 1,
limit: 1000, limit: 1000,
offset: 0, offset: 0,
}, },
@@ -979,8 +1100,24 @@ test.describe("Tenants Management", () => {
headers: { "Access-Control-Allow-Origin": "*" }, headers: { "Access-Control-Allow-Origin": "*" },
}); });
} else { } else {
const urlObj = new URL(url);
const search = urlObj.searchParams.get("search")?.toLowerCase();
let filtered = mockTenants;
if (search) {
filtered = mockTenants.filter(
(i) =>
i.name.toLowerCase().includes(search) ||
i.slug.toLowerCase().includes(search) ||
i.id.toLowerCase().includes(search),
);
}
await route.fulfill({ await route.fulfill({
json: { items: mockTenants, total: 2, limit: 1000, offset: 0 }, json: {
items: filtered,
total: filtered.length,
limit: 1000,
offset: 0,
},
headers: { "Access-Control-Allow-Origin": "*" }, headers: { "Access-Control-Allow-Origin": "*" },
}); });
} }
@@ -1051,8 +1188,24 @@ test.describe("Tenants Management", () => {
if (url.includes(`/admin/tenants/${parentId}`)) { if (url.includes(`/admin/tenants/${parentId}`)) {
return route.fulfill({ json: mockTenants[0], headers }); return route.fulfill({ json: mockTenants[0], headers });
} }
const urlObj = new URL(url);
const search = urlObj.searchParams.get("search")?.toLowerCase();
let filtered = mockTenants;
if (search) {
filtered = mockTenants.filter(
(i) =>
i.name.toLowerCase().includes(search) ||
i.slug.toLowerCase().includes(search) ||
i.id.toLowerCase().includes(search),
);
}
return route.fulfill({ return route.fulfill({
json: { items: mockTenants, total: 2, limit: 1000, offset: 0 }, json: {
items: filtered,
total: filtered.length,
limit: 1000,
offset: 0,
},
headers, headers,
}); });
}); });
@@ -1093,8 +1246,25 @@ test.describe("Tenants Management", () => {
if (url.includes(`/admin/tenants/${tenantUuid}`)) { if (url.includes(`/admin/tenants/${tenantUuid}`)) {
return route.fulfill({ json: tenant, headers }); return route.fulfill({ json: tenant, headers });
} }
const urlObj = new URL(url);
const search = urlObj.searchParams.get("search")?.toLowerCase();
const items = [tenant];
let filtered = items;
if (search) {
filtered = items.filter(
(i) =>
i.name.toLowerCase().includes(search) ||
i.slug.toLowerCase().includes(search) ||
i.id.toLowerCase().includes(search),
);
}
return route.fulfill({ return route.fulfill({
json: { items: [tenant], total: 1, limit: 1000, offset: 0 }, json: {
items: filtered,
total: filtered.length,
limit: 1000,
offset: 0,
},
headers, headers,
}); });
}); });
@@ -1152,8 +1322,24 @@ test.describe("Tenants Management", () => {
if (url.includes("/admin/tenants/team-1")) { if (url.includes("/admin/tenants/team-1")) {
return route.fulfill({ json: tenants[2], headers }); return route.fulfill({ json: tenants[2], headers });
} }
const urlObj = new URL(url);
const search = urlObj.searchParams.get("search")?.toLowerCase();
let filtered = tenants;
if (search) {
filtered = tenants.filter(
(i) =>
i.name.toLowerCase().includes(search) ||
i.slug.toLowerCase().includes(search) ||
i.id.toLowerCase().includes(search),
);
}
return route.fulfill({ return route.fulfill({
json: { items: tenants, total: tenants.length, limit: 1000, offset: 0 }, json: {
items: filtered,
total: filtered.length,
limit: 1000,
offset: 0,
},
headers, headers,
}); });
}); });

View File

@@ -108,8 +108,8 @@ func (m *AsyncMockUserRepo) ListByTenant(ctx context.Context, tenantID string) (
return nil, nil return nil, nil
} }
func (m *AsyncMockUserRepo) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) { func (m *AsyncMockUserRepo) List(ctx context.Context, offset, limit int, search string, tenantIDs []string, cursor string) ([]domain.User, int64, string, error) {
return nil, 0, nil return nil, 0, "", nil
} }
func (m *AsyncMockUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) { func (m *AsyncMockUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
@@ -208,7 +208,7 @@ func (m *AsyncMockTenantService) GetTenant(ctx context.Context, id string) (*dom
return nil, nil return nil, nil
} }
func (m *AsyncMockTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { func (m *AsyncMockTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) {
return nil, 0, nil return nil, 0, nil
} }
@@ -236,6 +236,18 @@ func (m *AsyncMockTenantService) ListTenantAdmins(ctx context.Context, tenantID
return nil, nil return nil, nil
} }
func (m *AsyncMockTenantService) DeleteTenantsBulk(ctx context.Context, ids []string) error {
return nil
}
func (m *AsyncMockTenantService) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
args := m.Called(ctx, userID)
if args.Get(0) != nil {
return args.Get(0).([]domain.Tenant), args.Error(1)
}
return nil, args.Error(1)
}
type AsyncMockKetoService struct { type AsyncMockKetoService struct {
mock.Mock mock.Mock
} }
@@ -357,16 +369,3 @@ func TestSignup_AsyncDB_Isolation(t *testing.T) {
mockUserRepo.AssertExpectations(t) mockUserRepo.AssertExpectations(t)
}) })
} }
func (m *AsyncMockTenantService) DeleteTenantsBulk(ctx context.Context, tenantIDs []string) error {
args := m.Called(ctx, tenantIDs)
return args.Error(0)
}
func (m *AsyncMockTenantService) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
args := m.Called(ctx, userID)
if args.Get(0) != nil {
return args.Get(0).([]domain.Tenant), args.Error(1)
}
return nil, args.Error(1)
}

View File

@@ -91,7 +91,7 @@ func (m *MockTenantServiceForConsent) GetTenantByDomain(ctx context.Context, dom
return nil, nil return nil, nil
} }
func (m *MockTenantServiceForConsent) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { func (m *MockTenantServiceForConsent) ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) {
return nil, 0, nil return nil, 0, nil
} }

View File

@@ -189,8 +189,8 @@ func (r *passwordLoginUserRepo) ListByTenant(ctx context.Context, tenantID strin
return nil, nil return nil, nil
} }
func (r *passwordLoginUserRepo) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) { func (r *passwordLoginUserRepo) List(ctx context.Context, offset, limit int, search string, tenantIDs []string, cursor string) ([]domain.User, int64, string, error) {
return nil, 0, nil return nil, 0, "", nil
} }
func (r *passwordLoginUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) { func (r *passwordLoginUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) {

View File

@@ -2416,7 +2416,7 @@ func (h *TenantHandler) loadOrgContextMembers(ctx context.Context, tenantIDs, te
if err != nil { if err != nil {
return nil, err return nil, err
} }
usersByAppointment, _, _, err := h.UserRepo.List(ctx, 0, 10000, "", "", "") usersByAppointment, _, _, err := h.UserRepo.List(ctx, 0, 10000, "", []string{}, "")
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -72,8 +72,8 @@ func (m *MockTenantService) GetTenant(ctx context.Context, id string) (*domain.T
return args.Get(0).(*domain.Tenant), args.Error(1) return args.Get(0).(*domain.Tenant), args.Error(1)
} }
func (m *MockTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { func (m *MockTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) {
args := m.Called(ctx, limit, offset, parentID) args := m.Called(ctx, limit, offset, parentID, search)
return args.Get(0).([]domain.Tenant), args.Get(1).(int64), args.Error(2) return args.Get(0).([]domain.Tenant), args.Get(1).(int64), args.Error(2)
} }
@@ -134,14 +134,14 @@ func (m *MockUserRepoForHandler) ListByTenant(ctx context.Context, tenantID stri
return nil, nil return nil, nil
} }
func (m *MockUserRepoForHandler) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) { func (m *MockUserRepoForHandler) List(ctx context.Context, offset, limit int, search string, tenantIDs []string, cursor string) ([]domain.User, int64, string, error) {
for _, call := range m.ExpectedCalls { for _, call := range m.ExpectedCalls {
if call.Method == "List" { if call.Method == "List" {
args := m.Called(ctx, offset, limit, search, tenantSlug) args := m.Called(ctx, offset, limit, search, tenantIDs, cursor)
return args.Get(0).([]domain.User), args.Get(1).(int64), args.Error(2) return args.Get(0).([]domain.User), args.Get(1).(int64), args.String(2), args.Error(3)
} }
} }
return nil, 0, nil return nil, 0, "", nil
} }
func (m *MockUserRepoForHandler) CountByTenant(ctx context.Context, tenantID string) (int64, error) { func (m *MockUserRepoForHandler) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
@@ -274,7 +274,7 @@ func TestTenantHandler_ListTenantsUsesReadyUserProjectionCountsWithoutKratos(t *
tenants := []domain.Tenant{ tenants := []domain.Tenant{
{ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"}, {ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"},
} }
mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once() mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(1), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once() mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants). mockProjection.On("CountTenantMembers", mock.Anything, tenants).
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once() Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()
@@ -313,7 +313,7 @@ func TestTenantHandler_ListTenantsRejectsStatsWhenUserProjectionIsNotReady(t *te
tenants := []domain.Tenant{ tenants := []domain.Tenant{
{ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"}, {ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"},
} }
mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once() mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(1), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(false, nil).Once() mockProjection.On("IsReady", mock.Anything).Return(false, nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil) req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
@@ -346,7 +346,7 @@ func TestTenantHandler_ListTenants(t *testing.T) {
} }
// Mocking for the new allTenants check in ListTenants // Mocking for the new allTenants check in ListTenants
mockSvc.On("ListTenants", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tenants, int64(2), nil).Maybe() mockSvc.On("ListTenants", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tenants, int64(2), nil).Maybe()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once() mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants). mockProjection.On("CountTenantMembers", mock.Anything, tenants).
Return(map[string]int64{"t1": 5, "t2": 10}, nil).Once() Return(map[string]int64{"t1": 5, "t2": 10}, nil).Once()
@@ -396,7 +396,7 @@ func TestTenantHandler_ListTenantsReturnsNextCursorWhenMoreRowsExist(t *testing.
{ID: "00000000-0000-0000-0000-000000000001", Name: "Tenant A", Slug: "slug-a", CreatedAt: createdAt.Add(-time.Minute)}, {ID: "00000000-0000-0000-0000-000000000001", Name: "Tenant A", Slug: "slug-a", CreatedAt: createdAt.Add(-time.Minute)},
} }
mockSvc.On("ListTenants", mock.Anything, 2, 0, "").Return(tenants, int64(3), nil).Once() mockSvc.On("ListTenants", mock.Anything, 2, 0, "", "").Return(tenants, int64(3), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once() mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants).Return(map[string]int64{}, nil).Once() mockProjection.On("CountTenantMembers", mock.Anything, tenants).Return(map[string]int64{}, nil).Once()
@@ -463,7 +463,7 @@ func TestTenantHandler_ListTenantsHidesPrivateSubtreeForUnauthorizedUser(t *test
}) })
app.Get("/tenants", h.ListTenants) app.Get("/tenants", h.ListTenants)
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Once() mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once() mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool { mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "public-team") return tenantSlugsMatch(got, "hanmac-family", "hanmac", "public-team")
@@ -512,7 +512,7 @@ func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *test
}) })
app.Get("/tenants", h.ListTenants) app.Get("/tenants", h.ListTenants)
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Once() mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once() mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool { mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "private-team", "private-child") return tenantSlugsMatch(got, "hanmac-family", "hanmac", "private-team", "private-child")
@@ -704,10 +704,10 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi
}, },
} }
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil) mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil)
mockUsers.On("FindByTenantIDs", mock.Anything, []string{"group-hanmac-family", "company-hanmac", "dept-platform", "team-sso"}).Return(usersByTenantID, nil) mockUsers.On("FindByTenantIDs", mock.Anything, []string{"group-hanmac-family", "company-hanmac", "dept-platform", "team-sso"}).Return(usersByTenantID, nil)
mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac-family", "hanmac", "platform", "sso"}).Return(usersBySlug, nil) mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac-family", "hanmac", "platform", "sso"}).Return(usersBySlug, nil)
mockUsers.On("List", mock.Anything, 0, 10000, "", "").Return(usersByList, int64(len(usersByList)), nil) mockUsers.On("List", mock.Anything, 0, 10000, "", mock.Anything, "").Return(usersByList, int64(len(usersByList)), "", nil)
req := httptest.NewRequest(http.MethodGet, "/org-context", nil) req := httptest.NewRequest(http.MethodGet, "/org-context", nil)
resp, err := app.Test(req) resp, err := app.Test(req)
@@ -798,7 +798,7 @@ func TestTenantHandler_GetOrgContextJSONIncludesUserIDsOnlyWhenRequested(t *test
{ID: "user-1", Email: "user@example.com", Name: "사용자", Phone: "010-1234-5678", Status: domain.UserStatusActive, TenantID: parent("company-hanmac"), CompanyCode: "hanmac", CreatedAt: now, UpdatedAt: now}, {ID: "user-1", Email: "user@example.com", Name: "사용자", Phone: "010-1234-5678", Status: domain.UserStatusActive, TenantID: parent("company-hanmac"), CompanyCode: "hanmac", CreatedAt: now, UpdatedAt: now},
} }
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil) mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil)
mockUsers.On("FindByTenantIDs", mock.Anything, []string{"company-hanmac"}).Return(users, nil) mockUsers.On("FindByTenantIDs", mock.Anything, []string{"company-hanmac"}).Return(users, nil)
mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac"}).Return([]domain.User{}, nil) mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac"}).Return([]domain.User{}, nil)
@@ -847,7 +847,7 @@ func TestTenantHandler_GetOrgContextJSONScopesByTenantSlug(t *testing.T) {
{ID: "dept-platform", Type: domain.TenantTypeUserGroup, ParentID: parent("company-hanmac"), Name: "플랫폼실", Slug: "platform", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, {ID: "dept-platform", Type: domain.TenantTypeUserGroup, ParentID: parent("company-hanmac"), Name: "플랫폼실", Slug: "platform", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
{ID: "company-other", Type: domain.TenantTypeCompany, ParentID: parent("group-hanmac-family"), Name: "다른회사", Slug: "other", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, {ID: "company-other", Type: domain.TenantTypeCompany, ParentID: parent("group-hanmac-family"), Name: "다른회사", Slug: "other", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
} }
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil) mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil)
mockUsers.On("FindByTenantIDs", mock.Anything, []string{"company-hanmac", "dept-platform"}).Return([]domain.User{}, nil) mockUsers.On("FindByTenantIDs", mock.Anything, []string{"company-hanmac", "dept-platform"}).Return([]domain.User{}, nil)
mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac", "platform"}).Return([]domain.User{}, nil) mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac", "platform"}).Return([]domain.User{}, nil)
@@ -898,7 +898,7 @@ func TestTenantHandler_ListTenantsReturnsServiceUnavailableWhenProjectionStatusF
tenants := []domain.Tenant{ tenants := []domain.Tenant{
{ID: "t1", Name: "Tenant A", Slug: "slug-a"}, {ID: "t1", Name: "Tenant A", Slug: "slug-a"},
} }
mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once() mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(1), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(false, errors.New("projection state query failed")).Once() mockProjection.On("IsReady", mock.Anything).Return(false, errors.New("projection state query failed")).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil) req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
@@ -932,7 +932,7 @@ func TestTenantHandler_ListTenantsUsesProjectionCountsWhenAvailable(t *testing.T
{ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"}, {ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"},
} }
mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once() mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(1), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once() mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants). mockProjection.On("CountTenantMembers", mock.Anything, tenants).
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once() Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()
@@ -982,7 +982,7 @@ func TestTenantHandler_ExportTenantsCSV(t *testing.T) {
}, },
} }
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(1), nil) mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil)
req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil) req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil)
resp, _ := app.Test(req) resp, _ := app.Test(req)
@@ -1019,7 +1019,7 @@ func TestTenantHandler_ExportTenantsCSV_OmitsIDsAndUsesParentSlug(t *testing.T)
}, },
} }
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(2), nil) mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(2), nil)
req := httptest.NewRequest("GET", "/tenants/export?includeIds=false", nil) req := httptest.NewRequest("GET", "/tenants/export?includeIds=false", nil)
resp, _ := app.Test(req) resp, _ := app.Test(req)
@@ -1051,7 +1051,7 @@ func TestTenantHandler_ExportTenantsCSV_OrdersByInputOrder(t *testing.T) {
{ID: "oldest", Name: "Oldest Tenant", Type: domain.TenantTypeCompany, Slug: "oldest", CreatedAt: oldest}, {ID: "oldest", Name: "Oldest Tenant", Type: domain.TenantTypeCompany, Slug: "oldest", CreatedAt: oldest},
} }
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil) mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil)
req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil) req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil)
resp, _ := app.Test(req) resp, _ := app.Test(req)
@@ -1106,7 +1106,7 @@ func TestTenantHandler_ExportTenantsCSV_FiltersDescendantsByParentIDWithIDs(t *t
}, },
} }
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil) mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil)
req := httptest.NewRequest("GET", "/tenants/export?includeIds=true&parentId="+parentID, nil) req := httptest.NewRequest("GET", "/tenants/export?includeIds=true&parentId="+parentID, nil)
resp, _ := app.Test(req) resp, _ := app.Test(req)
@@ -1146,7 +1146,7 @@ func TestTenantHandler_ExportTenantsCSV_HidesPrivateSubtreeForUnauthorizedUser(t
}) })
app.Get("/tenants/export", h.ExportTenantsCSV) app.Get("/tenants/export", h.ExportTenantsCSV)
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Once() mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once()
req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil) req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil)
resp, _ := app.Test(req) resp, _ := app.Test(req)
@@ -1175,7 +1175,7 @@ func TestTenantHandler_ImportTenantsCSVCreatesTenant(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.NoError(t, writer.Close()) assert.NoError(t, writer.Close())
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return([]domain.Tenant{}, int64(0), nil).Once() mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return([]domain.Tenant{}, int64(0), nil).Once()
mockSvc.On( mockSvc.On(
"RegisterTenant", "RegisterTenant",
mock.Anything, mock.Anything,
@@ -1219,7 +1219,7 @@ func TestTenantHandler_ImportTenantsCSVResolvesParentSlugToID(t *testing.T) {
assert.NoError(t, writer.Close()) assert.NoError(t, writer.Close())
parentID := "parent-id" parentID := "parent-id"
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return([]domain.Tenant{}, int64(0), nil).Once() mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return([]domain.Tenant{}, int64(0), nil).Once()
mockSvc.On( mockSvc.On(
"RegisterTenant", "RegisterTenant",
mock.Anything, mock.Anything,
@@ -1276,7 +1276,7 @@ func TestTenantHandler_ImportTenantsCSVDoesNotAssignCreatorAsOrganizationMember(
assert.NoError(t, err) assert.NoError(t, err)
assert.NoError(t, writer.Close()) assert.NoError(t, writer.Close())
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return([]domain.Tenant{}, int64(0), nil).Once() mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return([]domain.Tenant{}, int64(0), nil).Once()
mockSvc.On( mockSvc.On(
"RegisterTenant", "RegisterTenant",
mock.Anything, mock.Anything,

View File

@@ -1614,7 +1614,14 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
} }
// 1. Fetch Users using Repo for efficiency // 1. Fetch Users using Repo for efficiency
users, _, _, err := h.UserRepo.List(c.Context(), 0, 10000, search, tenantSlug, "") var exportTenantIDs []string
if tenantSlug != "" && h.TenantService != nil {
t, err := h.TenantService.GetTenantBySlug(c.Context(), tenantSlug)
if err == nil && t != nil {
exportTenantIDs = []string{t.ID}
}
}
users, _, _, err := h.UserRepo.List(c.Context(), 0, 10000, search, exportTenantIDs, "")
if err != nil { if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users for export") return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users for export")
} }

View File

@@ -291,8 +291,8 @@ func (m *MockTenantServiceForUser) ListManageableTenants(ctx context.Context, us
return args.Get(0).([]domain.Tenant), args.Error(1) return args.Get(0).([]domain.Tenant), args.Error(1)
} }
func (m *MockTenantServiceForUser) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { func (m *MockTenantServiceForUser) ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) {
args := m.Called(ctx, limit, offset, parentID) args := m.Called(ctx, limit, offset, parentID, search)
if args.Get(0) == nil { if args.Get(0) == nil {
return nil, args.Get(1).(int64), args.Error(2) return nil, args.Get(1).(int64), args.Error(2)
} }
@@ -332,7 +332,7 @@ func TestUserHandler_ExportUsersCSV_UsesTenantSlugAliasAndOmitsRole(t *testing.T
createdAt := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC) createdAt := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC)
tenantID := "tenant-uuid" tenantID := "tenant-uuid"
mockRepo.On("List", mock.Anything, 0, 10000, "", "test-tenant"). mockRepo.On("List", mock.Anything, 0, 10000, "", []string(nil), "").
Return([]domain.User{ Return([]domain.User{
{ {
ID: "u-1", ID: "u-1",
@@ -349,7 +349,7 @@ func TestUserHandler_ExportUsersCSV_UsesTenantSlugAliasAndOmitsRole(t *testing.T
JobTitle: "플랫폼 운영", JobTitle: "플랫폼 운영",
CreatedAt: createdAt, CreatedAt: createdAt,
}, },
}, int64(1), nil).Maybe() }, int64(1), "", nil).Maybe()
req := httptest.NewRequest("GET", "/users/export?tenantSlug=test-tenant&includeIds=true", nil) req := httptest.NewRequest("GET", "/users/export?tenantSlug=test-tenant&includeIds=true", nil)
resp, err := app.Test(req) resp, err := app.Test(req)
@@ -380,7 +380,7 @@ func TestUserHandler_ExportUsersCSV_OmitsIDsAndUsesTenantSlug(t *testing.T) {
createdAt := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC) createdAt := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC)
tenantID := "tenant-uuid" tenantID := "tenant-uuid"
mockRepo.On("List", mock.Anything, 0, 10000, "", ""). mockRepo.On("List", mock.Anything, 0, 10000, "", mock.Anything, "").
Return([]domain.User{ Return([]domain.User{
{ {
ID: "user-uuid", ID: "user-uuid",
@@ -395,7 +395,7 @@ func TestUserHandler_ExportUsersCSV_OmitsIDsAndUsesTenantSlug(t *testing.T) {
JobTitle: "플랫폼 운영", JobTitle: "플랫폼 운영",
CreatedAt: createdAt, CreatedAt: createdAt,
}, },
}, int64(1), nil).Maybe() }, int64(1), "", nil).Maybe()
req := httptest.NewRequest("GET", "/users/export?includeIds=false", nil) req := httptest.NewRequest("GET", "/users/export?includeIds=false", nil)
resp, err := app.Test(req) resp, err := app.Test(req)
@@ -1049,7 +1049,7 @@ func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) {
Slug: "hanmac", Slug: "hanmac",
ParentID: &rootID, ParentID: &rootID,
}, nil).Maybe() }, nil).Maybe()
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Maybe() mockTenant.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Maybe()
mockRepo.On("FindByTenantIDs", mock.Anything, []string{rootID, companyID, "external-id"}).Return([]domain.User{ mockRepo.On("FindByTenantIDs", mock.Anything, []string{rootID, companyID, "external-id"}).Return([]domain.User{
{Email: "cyhan@hanmaceng.co.kr", CompanyCode: "hanmac", TenantID: &companyID}, {Email: "cyhan@hanmaceng.co.kr", CompanyCode: "hanmac", TenantID: &companyID},
{Email: "cyhan1@samaneng.com", CompanyCode: "hanmac", TenantID: &companyID}, {Email: "cyhan1@samaneng.com", CompanyCode: "hanmac", TenantID: &companyID},
@@ -1117,7 +1117,7 @@ func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) {
mockTenant.On("GetTenantBySlug", mock.Anything, "h-company").Return(&hTenants[1], nil).Maybe() mockTenant.On("GetTenantBySlug", mock.Anything, "h-company").Return(&hTenants[1], nil).Maybe()
mockTenant.On("GetTenant", mock.Anything, hCompanyID).Return(&hTenants[1], nil).Maybe() mockTenant.On("GetTenant", mock.Anything, hCompanyID).Return(&hTenants[1], nil).Maybe()
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "").Return(hTenants, int64(len(hTenants)), nil).Maybe() mockTenant.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(hTenants, int64(len(hTenants)), nil).Maybe()
mockRepo.On("FindByTenantIDs", mock.Anything, mock.MatchedBy(func(ids []string) bool { mockRepo.On("FindByTenantIDs", mock.Anything, mock.MatchedBy(func(ids []string) bool {
return slices.Contains(ids, hRootID) || slices.Contains(ids, hCompanyID) return slices.Contains(ids, hRootID) || slices.Contains(ids, hCompanyID)
@@ -1188,7 +1188,7 @@ func TestUserHandler_CreateUser_HanmacEmailPolicyBlocksDuplicateLocalPart(t *tes
ID: companyID, ID: companyID,
Slug: "hanmac", Slug: "hanmac",
}, nil).Maybe() }, nil).Maybe()
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Maybe() mockTenant.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Maybe()
mockRepo.On("FindByTenantIDs", mock.Anything, []string{rootID, companyID}).Return([]domain.User{ mockRepo.On("FindByTenantIDs", mock.Anything, []string{rootID, companyID}).Return([]domain.User{
{Email: "han@hanmaceng.co.kr", CompanyCode: "hanmac", TenantID: &companyID}, {Email: "han@hanmaceng.co.kr", CompanyCode: "hanmac", TenantID: &companyID},
}, nil).Maybe() }, nil).Maybe()
@@ -2146,7 +2146,7 @@ func TestUserHandler_CreateUser_UsesAdditionalAppointmentAsPrimaryTenant(t *test
ID: tenantID, ID: tenantID,
Slug: "saman", Slug: "saman",
}, nil) }, nil)
mockTenant.On("ListTenants", mock.Anything, 10000, 0, "").Return([]domain.Tenant{}, int64(0), nil) mockTenant.On("ListTenants", mock.Anything, 10000, 0, "", "").Return([]domain.Tenant{}, int64(0), nil)
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil) mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
mockOry.On("CreateUser", mock.Anything, mock.Anything).Return("some-id", nil).Maybe() mockOry.On("CreateUser", mock.Anything, mock.Anything).Return("some-id", nil).Maybe()
mockKratos.On("GetIdentity", mock.Anything, "some-id").Return(&service.KratosIdentity{ mockKratos.On("GetIdentity", mock.Anything, "some-id").Return(&service.KratosIdentity{

View File

@@ -45,7 +45,7 @@ func (m *MockTenantServiceForMiddleware) GetTenant(ctx context.Context, id strin
return nil, nil return nil, nil
} }
func (m *MockTenantServiceForMiddleware) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { func (m *MockTenantServiceForMiddleware) ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) {
return nil, 0, nil return nil, 0, nil
} }
@@ -53,6 +53,10 @@ func (m *MockTenantServiceForMiddleware) ListManageableTenants(ctx context.Conte
return nil, nil return nil, nil
} }
func (m *MockTenantServiceForMiddleware) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
return nil, nil
}
func (m *MockTenantServiceForMiddleware) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) { func (m *MockTenantServiceForMiddleware) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) {
return false, nil return false, nil
} }
@@ -60,8 +64,17 @@ func (m *MockTenantServiceForMiddleware) IsDomainAllowed(ctx context.Context, do
func (m *MockTenantServiceForMiddleware) ApproveTenant(ctx context.Context, id string) error { func (m *MockTenantServiceForMiddleware) ApproveTenant(ctx context.Context, id string) error {
return nil return nil
} }
func (m *MockTenantServiceForMiddleware) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
return nil, nil
}
func (m *MockTenantServiceForMiddleware) SetKetoService(keto service.KetoService) {} func (m *MockTenantServiceForMiddleware) SetKetoService(keto service.KetoService) {}
func (m *MockTenantServiceForMiddleware) DeleteTenantsBulk(ctx context.Context, ids []string) error {
return nil
}
func TestTenantContextMiddleware(t *testing.T) { func TestTenantContextMiddleware(t *testing.T) {
os.Setenv("USERFRONT_URL", "https://sso.hmac.kr") os.Setenv("USERFRONT_URL", "https://sso.hmac.kr")
defer os.Unsetenv("USERFRONT_URL") defer os.Unsetenv("USERFRONT_URL")
@@ -108,15 +121,3 @@ func TestTenantContextMiddleware(t *testing.T) {
mockSvc.AssertExpectations(t) mockSvc.AssertExpectations(t)
}) })
} }
func (m *MockTenantServiceForMiddleware) DeleteTenantsBulk(ctx context.Context, tenantIDs []string) error {
return nil
}
func (m *MockTenantServiceForMiddleware) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
return nil, nil
}
func (m *MockTenantServiceForMiddleware) ProvisionTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) {
return nil, nil
}

View File

@@ -18,7 +18,7 @@ type UserRepository interface {
FindByID(ctx context.Context, id string) (*domain.User, error) FindByID(ctx context.Context, id string) (*domain.User, error)
FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error)
ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error)
List(ctx context.Context, offset, limit int, search string, tenantSlug string, cursor string) ([]domain.User, int64, string, error) List(ctx context.Context, offset, limit int, search string, tenantIDs []string, cursor string) ([]domain.User, int64, string, error)
CountByTenant(ctx context.Context, tenantID string) (int64, error) CountByTenant(ctx context.Context, tenantID string) (int64, error)
CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error)
CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error)
@@ -216,14 +216,13 @@ func lowerStrings(arr []string) []string {
return res return res
} }
func (r *userRepository) List(ctx context.Context, offset, limit int, search string, tenantSlug string, cursorRaw string) ([]domain.User, int64, string, error) { func (r *userRepository) List(ctx context.Context, offset, limit int, search string, tenantIDs []string, cursorRaw string) ([]domain.User, int64, string, error) {
var users []domain.User var users []domain.User
var total int64 var total int64
db := r.db.WithContext(ctx).Model(&domain.User{}) db := r.db.WithContext(ctx).Model(&domain.User{})
if tenantSlug != "" { if len(tenantIDs) > 0 {
db = db.Joins("LEFT JOIN tenants ON users.tenant_id = tenants.id"). db = db.Where("tenant_id IN ?", tenantIDs)
Where("tenants.slug = ?", tenantSlug)
} }
if search != "" { if search != "" {

View File

@@ -88,7 +88,7 @@ func TestUserRepository(t *testing.T) {
_ = repo.Create(ctx, &domain.User{Email: "alice@test.com", Name: "Alice", Role: "user"}) _ = repo.Create(ctx, &domain.User{Email: "alice@test.com", Name: "Alice", Role: "user"})
_ = repo.Create(ctx, &domain.User{Email: "bob@test.com", Name: "Bob", Role: "user"}) _ = repo.Create(ctx, &domain.User{Email: "bob@test.com", Name: "Bob", Role: "user"})
users, total, err := repo.List(ctx, 0, 10, "Alice", "") users, total, _, err := repo.List(ctx, 0, 10, "Alice", []string{}, "")
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, total >= 1) assert.True(t, total >= 1)
assert.Equal(t, "Alice", users[0].Name) assert.Equal(t, "Alice", users[0].Name)

View File

@@ -60,9 +60,9 @@ func (m *MockTenantRepoForSvc) AddDomain(ctx context.Context, tenantID string, d
return m.Called(ctx, tenantID, domainName, verified).Error(0) return m.Called(ctx, tenantID, domainName, verified).Error(0)
} }
func (m *MockTenantRepoForSvc) List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { func (m *MockTenantRepoForSvc) List(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) {
args := m.Called(ctx, limit, offset, parentID) args := m.Called(ctx, limit, offset, parentID, search)
return args.Get(0).([]domain.Tenant), int64(args.Int(1)), args.Error(2) return args.Get(0).([]domain.Tenant), args.Get(1).(int64), args.Error(2)
} }
func (m *MockTenantRepoForSvc) ListByType(ctx context.Context, tenantType string) ([]domain.Tenant, error) { func (m *MockTenantRepoForSvc) ListByType(ctx context.Context, tenantType string) ([]domain.Tenant, error) {
@@ -135,8 +135,8 @@ func (m *MockUserRepoForTenant) ListByTenant(ctx context.Context, tenantID strin
return nil, nil return nil, nil
} }
func (m *MockUserRepoForTenant) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) { func (m *MockUserRepoForTenant) List(ctx context.Context, offset, limit int, search string, tenantIDs []string, cursor string) ([]domain.User, int64, string, error) {
return nil, 0, nil return nil, 0, "", nil
} }
func (m *MockUserRepoForTenant) CountByTenant(ctx context.Context, tenantID string) (int64, error) { func (m *MockUserRepoForTenant) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
@@ -335,9 +335,9 @@ func TestTenantService_ListTenants(t *testing.T) {
ctx := context.Background() ctx := context.Background()
tenants := []domain.Tenant{{ID: "t1", Name: "Tenant 1"}} tenants := []domain.Tenant{{ID: "t1", Name: "Tenant 1"}}
mockRepo.On("List", ctx, 10, 0, "").Return(tenants, 1, nil) mockRepo.On("List", ctx, 10, 0, "", "").Return(tenants, int64(1), nil)
result, total, err := svc.ListTenants(ctx, 10, 0, "") result, total, err := svc.ListTenants(ctx, 10, 0, "", "")
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, int64(1), total) assert.Equal(t, int64(1), total)
assert.Equal(t, tenants, result) assert.Equal(t, tenants, result)

View File

@@ -84,8 +84,8 @@ func (m *MockUserRepository) ListByTenant(ctx context.Context, tenantID string)
return nil, nil return nil, nil
} }
func (m *MockUserRepository) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) { func (m *MockUserRepository) List(ctx context.Context, offset, limit int, search string, tenantIDs []string, cursor string) ([]domain.User, int64, string, error) {
return nil, 0, nil return nil, 0, "", nil
} }
func (m *MockUserRepository) CountByTenant(ctx context.Context, tenantID string) (int64, error) { func (m *MockUserRepository) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
@@ -200,7 +200,7 @@ func (m *MockTenantRepository) FindByDomain(ctx context.Context, domainName stri
return nil, nil return nil, nil
} }
func (m *MockTenantRepository) List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { func (m *MockTenantRepository) List(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) {
return nil, 0, nil return nil, 0, nil
} }

View File

@@ -1978,7 +1978,7 @@ func (f *fakeWorksmobileTenantService) GetTenant(ctx context.Context, id string)
return &tenant, nil return &tenant, nil
} }
func (f *fakeWorksmobileTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { func (f *fakeWorksmobileTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string, search string) ([]domain.Tenant, int64, error) {
return f.list, int64(len(f.list)), nil return f.list, int64(len(f.list)), nil
} }
@@ -2033,8 +2033,8 @@ func (f *fakeWorksmobileUserRepo) ListByTenant(ctx context.Context, tenantID str
return nil, nil return nil, nil
} }
func (f *fakeWorksmobileUserRepo) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) { func (r *fakeWorksmobileUserRepo) List(ctx context.Context, offset, limit int, search string, tenantIDs []string, cursor string) ([]domain.User, int64, string, error) {
return nil, 0, nil return nil, 0, "", nil
} }
func (f *fakeWorksmobileUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) { func (f *fakeWorksmobileUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) {

View File

@@ -1,4 +1,5 @@
[msg.common] [msg.common]
loading_more = "Loading more logs..."
copied = "Copied." copied = "Copied."
error = "Error" error = "Error"
forbidden = "Access denied." forbidden = "Access denied."
@@ -141,6 +142,7 @@ target = "Client ID · {{value}}"
title = "Audit registry" title = "Audit registry"
[ui.common.audit.table] [ui.common.audit.table]
no_logs = "No logs to display."
action = "Action" action = "Action"
actor = "User ID" actor = "User ID"
client_id = "Client ID" client_id = "Client ID"

View File

@@ -1,4 +1,5 @@
[msg.common] [msg.common]
loading_more = "추가 로그를 불러오는 중..."
copied = "복사되었습니다." copied = "복사되었습니다."
error = "오류가 발생했습니다." error = "오류가 발생했습니다."
forbidden = "접근 권한이 없습니다." forbidden = "접근 권한이 없습니다."
@@ -141,7 +142,8 @@ target = "클라이언트 ID · {{value}}"
title = "감사 로그 레지스트리" title = "감사 로그 레지스트리"
[ui.common.audit.table] [ui.common.audit.table]
action = "액션" no_logs = "표시할 로그가 없습니다."
action = "작업"
actor = "사용자 ID" actor = "사용자 ID"
client_id = "클라이언트 ID" client_id = "클라이언트 ID"
user_id = "사용자 ID" user_id = "사용자 ID"

View File

@@ -1,4 +1,5 @@
[msg.common] [msg.common]
loading_more = ""
copied = "" copied = ""
error = "" error = ""
forbidden = "" forbidden = ""
@@ -141,6 +142,7 @@ target = ""
title = "" title = ""
[ui.common.audit.table] [ui.common.audit.table]
no_logs = ""
action = "" action = ""
actor = "" actor = ""
client_id = "" client_id = ""