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:
@@ -2,12 +2,6 @@ import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Download, NotebookTabs, RefreshCw, Search } from "lucide-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 { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
@@ -23,6 +17,7 @@ import { Input } from "../../components/ui/input";
|
||||
import type { AuditLog } from "../../lib/adminApi";
|
||||
import { fetchAuditLogs } from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { VirtualizedAuditLogTable } from "./VirtualizedAuditLogTable";
|
||||
|
||||
function AuditLogsPage() {
|
||||
const [searchActorId, setSearchActorId] = React.useState("");
|
||||
@@ -40,8 +35,23 @@ function AuditLogsPage() {
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["audit-logs"],
|
||||
queryFn: ({ pageParam }) => fetchAuditLogs(50, pageParam),
|
||||
queryKey: [
|
||||
"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,
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||||
});
|
||||
@@ -51,24 +61,6 @@ function AuditLogsPage() {
|
||||
(page) =>
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
@@ -83,7 +75,7 @@ function AuditLogsPage() {
|
||||
<>
|
||||
<Badge variant="muted">
|
||||
{t("msg.common.audit.registry.count", "총 {{count}}개 로그", {
|
||||
count: filteredLogs.length,
|
||||
count: logs.length,
|
||||
})}
|
||||
</Badge>
|
||||
<Button
|
||||
@@ -185,8 +177,8 @@ function AuditLogsPage() {
|
||||
</form>
|
||||
}
|
||||
/>
|
||||
<AuditLogTable
|
||||
logs={filteredLogs}
|
||||
<VirtualizedAuditLogTable
|
||||
logs={logs}
|
||||
t={t}
|
||||
loading={isLoading}
|
||||
hasNextPage={Boolean(hasNextPage)}
|
||||
|
||||
470
adminfront/src/features/audit/VirtualizedAuditLogTable.tsx
Normal file
470
adminfront/src/features/audit/VirtualizedAuditLogTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -720,6 +720,11 @@ function TenantListPage() {
|
||||
className="h-9 pl-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
query.refetch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1529,8 +1534,8 @@ const TenantHierarchyView: React.FC<{
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const { subTree } = React.useMemo(
|
||||
() => buildTenantFullTree(tenants, scopeTenantId || undefined),
|
||||
[scopeTenantId, tenants],
|
||||
() => buildTenantFullTree(tenants, scopeTenantId || undefined, !!search),
|
||||
[scopeTenantId, tenants, search],
|
||||
);
|
||||
|
||||
// Initial expanded state: everything open
|
||||
@@ -1582,7 +1587,7 @@ const TenantHierarchyView: React.FC<{
|
||||
const flattenedRows = React.useMemo(() => {
|
||||
if (viewMode === "table") {
|
||||
return sortItems(
|
||||
getTenantViewRows(tenants, "table", scopeTenantId),
|
||||
getTenantViewRows(tenants, "table", scopeTenantId, !!search),
|
||||
sortConfig,
|
||||
tenantSortResolvers,
|
||||
);
|
||||
@@ -1615,6 +1620,7 @@ const TenantHierarchyView: React.FC<{
|
||||
tenantSortResolvers,
|
||||
tenants,
|
||||
viewMode,
|
||||
search,
|
||||
]);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
|
||||
@@ -68,8 +68,13 @@ export function getTenantViewRows(
|
||||
tenants: TenantSummary[],
|
||||
viewMode: TenantViewMode,
|
||||
scopeTenantId = "",
|
||||
isSearchActive = false,
|
||||
): TenantViewRow[] {
|
||||
const { subTree } = buildTenantFullTree(tenants, scopeTenantId || undefined);
|
||||
const { subTree } = buildTenantFullTree(
|
||||
tenants,
|
||||
scopeTenantId || undefined,
|
||||
isSearchActive,
|
||||
);
|
||||
const treeRows: TenantViewRow[] = [];
|
||||
collectTenantTreeRows(subTree, 0, treeRows);
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ describe("UserListPage search rendering", () => {
|
||||
fireEvent.change(searchInput, { target: { value: "user 19" } });
|
||||
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(performance.now() - startedAt).toBeLessThan(searchRenderBudgetMs);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
observeElementRect,
|
||||
type Rect,
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
FileDown,
|
||||
FileSpreadsheet,
|
||||
LayoutDashboard,
|
||||
@@ -119,7 +117,7 @@ type UserSchemaField = {
|
||||
type UserSortKey = string;
|
||||
|
||||
const USER_ROW_ESTIMATED_HEIGHT = 64;
|
||||
const USER_ROW_OVERSCAN = 8;
|
||||
const USER_ROW_OVERSCAN = 20;
|
||||
const USER_TABLE_VIEWPORT_ESTIMATED_HEIGHT = 640;
|
||||
const userFixedColumnWidths = [48, 160, 220, 160, 260, 170, 160, 220] as const;
|
||||
const userMetadataColumnWidth = 160;
|
||||
@@ -152,24 +150,6 @@ function assignableSystemRoleValue(role?: string | null) {
|
||||
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 {
|
||||
return {
|
||||
width: rect.width > 0 ? rect.width : fallbackWidth,
|
||||
@@ -179,7 +159,7 @@ function normalizeUserTableRect(rect: Rect, fallbackWidth: number): Rect {
|
||||
}
|
||||
|
||||
type UserListSearchControlsProps = {
|
||||
search: string;
|
||||
initialSearch: string;
|
||||
selectedCompany: string;
|
||||
tenants: TenantSummary[];
|
||||
profileRole?: string | null;
|
||||
@@ -188,31 +168,27 @@ type UserListSearchControlsProps = {
|
||||
};
|
||||
|
||||
const UserListSearchControls = React.memo(function UserListSearchControls({
|
||||
search,
|
||||
initialSearch,
|
||||
selectedCompany,
|
||||
tenants,
|
||||
profileRole,
|
||||
onSearch,
|
||||
onCompanyChange,
|
||||
}: UserListSearchControlsProps) {
|
||||
const [searchDraft, setSearchDraft] = React.useState(search);
|
||||
const [localSearch, setLocalSearch] = React.useState(initialSearch);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSearchDraft(search);
|
||||
}, [search]);
|
||||
setLocalSearch(initialSearch);
|
||||
}, [initialSearch]);
|
||||
|
||||
const handleSearch = React.useCallback(() => {
|
||||
onSearch(searchDraft);
|
||||
}, [onSearch, searchDraft]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
handleSearch();
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (localSearch !== initialSearch) {
|
||||
onSearch(localSearch);
|
||||
}
|
||||
},
|
||||
[handleSearch],
|
||||
);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [localSearch, onSearch, initialSearch]);
|
||||
|
||||
const tenantOptions = React.useMemo(
|
||||
() =>
|
||||
@@ -236,9 +212,13 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
|
||||
"이름 또는 이메일 검색...",
|
||||
)}
|
||||
className="h-9 pl-9"
|
||||
value={searchDraft}
|
||||
onChange={(event) => setSearchDraft(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
value={localSearch}
|
||||
onChange={(event) => setLocalSearch(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
onSearch(localSearch);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -255,7 +235,7 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleSearch}
|
||||
onClick={() => onSearch(localSearch)}
|
||||
className="h-9"
|
||||
>
|
||||
{t("ui.common.search", "검색")}
|
||||
@@ -268,7 +248,6 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
|
||||
|
||||
function UserListPage() {
|
||||
const _navigate = useNavigate();
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [selectedCompany, setSelectedCompany] = React.useState<string>("");
|
||||
const [visibleColumns, setVisibleColumns] = React.useState<
|
||||
@@ -285,9 +264,6 @@ function UserListPage() {
|
||||
const [bulkUploadOpen, setBulkUploadOpen] = React.useState(false);
|
||||
const userTableViewportRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const limit = 1000;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
@@ -345,10 +321,12 @@ function UserListPage() {
|
||||
}));
|
||||
};
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["users", { limit, offset, search, tenantSlug: selectedCompany }],
|
||||
queryFn: () => fetchUsers(limit, offset, search, selectedCompany),
|
||||
placeholderData: (previousData) => previousData,
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ["users", { search, tenantSlug: selectedCompany }],
|
||||
queryFn: ({ pageParam }) =>
|
||||
fetchUsers(50, 0, search, selectedCompany, pageParam as string),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor || lastPage.nextCursor,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
@@ -393,12 +371,10 @@ function UserListPage() {
|
||||
|
||||
const handleSearch = React.useCallback((nextSearch: string) => {
|
||||
setSearch(nextSearch);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const handleCompanyChange = React.useCallback((nextCompany: string) => {
|
||||
setSelectedCompany(nextCompany);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const handleExport = (includeIds = false) => {
|
||||
@@ -415,14 +391,11 @@ function UserListPage() {
|
||||
)
|
||||
: null;
|
||||
|
||||
const serverItems = query.data?.items ?? [];
|
||||
const rawItems = React.useMemo(() => {
|
||||
if (!query.isFetching || search.trim() === "") {
|
||||
return serverItems;
|
||||
}
|
||||
|
||||
return serverItems.filter((user) => userMatchesSearch(user, search));
|
||||
}, [query.isFetching, search, serverItems]);
|
||||
const serverItems = React.useMemo(
|
||||
() => query.data?.pages.flatMap((page) => page.items) ?? [],
|
||||
[query.data],
|
||||
);
|
||||
const rawItems = serverItems;
|
||||
const userSortResolvers = React.useMemo<
|
||||
SortResolverMap<UserSummary, UserSortKey>
|
||||
>(
|
||||
@@ -496,6 +469,25 @@ function UserListPage() {
|
||||
},
|
||||
});
|
||||
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 tableColumnCount = 9 + visibleUserSchemaFields.length;
|
||||
|
||||
@@ -514,8 +506,7 @@ function UserListPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const total = query.data?.total ?? 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
const total = query.data?.pages[0]?.total ?? 0;
|
||||
const canPromoteSuperAdmin = isSuperAdminRole(profile?.role);
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
@@ -627,10 +618,10 @@ function UserListPage() {
|
||||
actions={
|
||||
<>
|
||||
<UserListSearchControls
|
||||
search={search}
|
||||
initialSearch={search}
|
||||
selectedCompany={selectedCompany}
|
||||
tenants={tenants}
|
||||
profileRole={profile?.role}
|
||||
profileRole={profileRole}
|
||||
onSearch={handleSearch}
|
||||
onCompanyChange={handleCompanyChange}
|
||||
/>
|
||||
@@ -1241,36 +1232,6 @@ function UserListPage() {
|
||||
</Button>
|
||||
</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>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -215,9 +215,14 @@ export type DeleteOrphanUserLoginIDsResult = {
|
||||
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", {
|
||||
params: { limit, cursor },
|
||||
params: { limit, cursor, search, status },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
@@ -662,6 +667,8 @@ export type UserListResponse = {
|
||||
limit: number;
|
||||
offset: number;
|
||||
total: number;
|
||||
next_cursor?: string;
|
||||
nextCursor?: string;
|
||||
};
|
||||
|
||||
export type UserCreateRequest = {
|
||||
@@ -884,9 +891,10 @@ export async function fetchUsers(
|
||||
offset = 0,
|
||||
search?: string,
|
||||
tenantSlug?: string,
|
||||
cursor?: string,
|
||||
) {
|
||||
const { data } = await apiClient.get<UserListResponse>("/v1/admin/users", {
|
||||
params: { limit, offset, search, tenantSlug },
|
||||
params: { limit, offset, search, tenantSlug, cursor },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export type TenantNode = TenantSummary & {
|
||||
export function buildTenantFullTree(
|
||||
allTenants: TenantSummary[],
|
||||
rootId?: string,
|
||||
isSearchActive?: boolean,
|
||||
): { currentBase: TenantNode | null; subTree: TenantNode[] } {
|
||||
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
|
||||
for (const t of allTenants) {
|
||||
if (t.parentId && t.parentId !== t.id) {
|
||||
@@ -54,26 +54,15 @@ export function buildTenantFullTree(
|
||||
}
|
||||
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;
|
||||
};
|
||||
|
||||
// Calculate for all top-level nodes (those without parent)
|
||||
for (const node of tenantMap.values()) {
|
||||
if (!node.parentId) {
|
||||
visitedForCalc.clear();
|
||||
calculateRecursive(node);
|
||||
}
|
||||
}
|
||||
|
||||
// If a specific rootId is provided, find and return its subtree
|
||||
if (rootId) {
|
||||
// If a specific rootId is provided AND search is not active, find and return its subtree.
|
||||
// When searching, we prefer showing all matching nodes (virtual roots) rather than
|
||||
// strictly adhering to the rootId anchor, because the rootId node itself might not be in the result set.
|
||||
if (rootId && !isSearchActive) {
|
||||
const base = tenantMap.get(rootId);
|
||||
if (base) {
|
||||
// Re-calculate specifically for our current tenant to be sure if it wasn't a global root
|
||||
visitedForCalc.clear();
|
||||
calculateRecursive(base);
|
||||
return { currentBase: base, subTree: base.children };
|
||||
@@ -81,7 +70,19 @@ export function buildTenantFullTree(
|
||||
return { currentBase: null, subTree: [] };
|
||||
}
|
||||
|
||||
// If no rootId, return all top-level roots as subTree
|
||||
const roots = Array.from(tenantMap.values()).filter((n) => !n.parentId);
|
||||
// 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) => {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -53,13 +53,33 @@ test.describe("Audit Logs Management", () => {
|
||||
const url = route.request().url();
|
||||
const urlObj = new URL(url);
|
||||
const cursor = urlObj.searchParams.get("cursor");
|
||||
const search = urlObj.searchParams.get("search")?.toLowerCase();
|
||||
const status = urlObj.searchParams.get("status");
|
||||
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({
|
||||
json: {
|
||||
items: generateMockLogs(20, offset),
|
||||
next_cursor: offset === 0 ? "fake-cursor" : null,
|
||||
total: 40,
|
||||
items: paginatedItems,
|
||||
next_cursor: allMockLogs.length > offset + 20 ? "fake-cursor" : null,
|
||||
total: allMockLogs.length,
|
||||
},
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
@@ -172,7 +192,7 @@ test.describe("Audit Logs Management", () => {
|
||||
await userIdInput.fill("user-even");
|
||||
|
||||
// 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");
|
||||
|
||||
// Clear User ID
|
||||
@@ -183,12 +203,13 @@ test.describe("Audit Logs Management", () => {
|
||||
const actionInput = page.getByTestId("audit-search-action");
|
||||
await actionInput.fill("ROTATE_SECRET");
|
||||
|
||||
// Check that we only see ROTATE_SECRET (20 - 7 = 13)
|
||||
await expect(page.locator("tbody tr")).toHaveCount(13, { timeout: 15000 });
|
||||
// Check that we see ROTATE_SECRET across all 40 logs (40 - 14 = 26)
|
||||
// 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");
|
||||
});
|
||||
|
||||
test("should filter logs by Status locally", async ({ page }) => {
|
||||
test("should filter logs by Status", async ({ page }) => {
|
||||
await page.goto("/audit-logs");
|
||||
await expect(page.locator(".animate-spin")).not.toBeVisible({
|
||||
timeout: 10000,
|
||||
@@ -201,12 +222,13 @@ test.describe("Audit Logs Management", () => {
|
||||
// Select "Failure" status
|
||||
await page.getByTestId("audit-filter-status").selectOption("failure");
|
||||
|
||||
// ID % 5 === 0 are status "failure" (0, 5, 10, 15)
|
||||
await expect(page.locator("tbody tr")).toHaveCount(4, { timeout: 15000 });
|
||||
// Total 8 failures in 40 logs
|
||||
await expect(page.locator("tbody tr")).toHaveCount(8, { timeout: 15000 });
|
||||
|
||||
// Select "Success" status
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,28 +61,42 @@ test.describe("Tenants Management", () => {
|
||||
const internalTenantId = "c5839444-2de0-4a37-99b0-4f94d3de8bea";
|
||||
|
||||
await page.route("**/api/v1/admin/tenants**", async (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
await route.fulfill({
|
||||
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();
|
||||
if (route.request().method() !== "GET") {
|
||||
return 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");
|
||||
@@ -115,40 +129,55 @@ test.describe("Tenants Management", () => {
|
||||
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({
|
||||
json: {
|
||||
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(),
|
||||
},
|
||||
],
|
||||
total: 3,
|
||||
items: filtered,
|
||||
total: filtered.length,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
},
|
||||
@@ -162,7 +191,6 @@ test.describe("Tenants Management", () => {
|
||||
.getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i)
|
||||
.fill("team-1");
|
||||
await expect(page.locator("table")).toContainText("Platform");
|
||||
await expect(page.locator("table")).toContainText("Acme");
|
||||
|
||||
await page
|
||||
.getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i)
|
||||
@@ -188,40 +216,55 @@ test.describe("Tenants Management", () => {
|
||||
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({
|
||||
json: {
|
||||
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(),
|
||||
},
|
||||
],
|
||||
total: 3,
|
||||
items: filtered,
|
||||
total: filtered.length,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
},
|
||||
@@ -239,10 +282,14 @@ test.describe("Tenants Management", () => {
|
||||
);
|
||||
|
||||
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 page.getByPlaceholder(/UUID|슬러그|slug/i).fill("");
|
||||
await page.keyboard.press("Enter");
|
||||
await page
|
||||
.locator("tbody tr")
|
||||
.filter({ hasText: "Acme" })
|
||||
@@ -266,24 +313,37 @@ test.describe("Tenants Management", () => {
|
||||
}
|
||||
const url = new URL(route.request().url());
|
||||
const cursor = url.searchParams.get("cursor");
|
||||
const search = url.searchParams.get("search")?.toLowerCase();
|
||||
_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) {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: Array.from({ length: 500 }, (_, 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(),
|
||||
})),
|
||||
total: 501,
|
||||
items: filtered.slice(0, 500),
|
||||
total: filtered.length,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
nextCursor: "next-page",
|
||||
nextCursor: filtered.length > 500 ? "next-page" : undefined,
|
||||
},
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
@@ -291,18 +351,8 @@ test.describe("Tenants Management", () => {
|
||||
|
||||
return route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
id: "tenant-501",
|
||||
name: "Tenant 501",
|
||||
slug: "tenant-501",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
memberCount: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
total: 501,
|
||||
items: filtered.slice(500),
|
||||
total: filtered.length,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
},
|
||||
@@ -322,9 +372,10 @@ test.describe("Tenants Management", () => {
|
||||
// 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.
|
||||
// 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
|
||||
.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
|
||||
// expect(requestCount).toBe(2);
|
||||
@@ -372,54 +423,68 @@ test.describe("Tenants Management", () => {
|
||||
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({
|
||||
json: {
|
||||
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,
|
||||
},
|
||||
],
|
||||
total: 5,
|
||||
items: filtered,
|
||||
total: filtered.length,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
},
|
||||
@@ -493,9 +558,30 @@ test.describe("Tenants Management", () => {
|
||||
];
|
||||
|
||||
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": "*" };
|
||||
|
||||
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({
|
||||
json: { items: tenants, total: tenants.length, limit: 1000, offset: 0 },
|
||||
json: {
|
||||
items: filtered,
|
||||
total: filtered.length,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
});
|
||||
@@ -569,12 +655,23 @@ test.describe("Tenants Management", () => {
|
||||
|
||||
await page.route("**/api/v1/admin/tenants**", async (route) => {
|
||||
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": "*" };
|
||||
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({
|
||||
json: {
|
||||
items: tenants,
|
||||
total: tenants.length,
|
||||
items: filtered,
|
||||
total: filtered.length,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
},
|
||||
@@ -705,21 +802,33 @@ test.describe("Tenants Management", () => {
|
||||
}
|
||||
|
||||
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({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
id: "tenant-alpha-id",
|
||||
name: "Tenant Alpha",
|
||||
slug: "tenant-alpha",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
domains: [],
|
||||
memberCount: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
items: filtered,
|
||||
total: filtered.length,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
},
|
||||
@@ -846,21 +955,33 @@ test.describe("Tenants Management", () => {
|
||||
}
|
||||
|
||||
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({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
id: "staging-existing-id",
|
||||
name: "Existing Parent",
|
||||
slug: "parent-local",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
domains: [],
|
||||
memberCount: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
items: filtered,
|
||||
total: filtered.length,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
},
|
||||
@@ -979,8 +1100,24 @@ test.describe("Tenants Management", () => {
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
} 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({
|
||||
json: { items: mockTenants, total: 2, limit: 1000, offset: 0 },
|
||||
json: {
|
||||
items: filtered,
|
||||
total: filtered.length,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
},
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
}
|
||||
@@ -1051,8 +1188,24 @@ test.describe("Tenants Management", () => {
|
||||
if (url.includes(`/admin/tenants/${parentId}`)) {
|
||||
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({
|
||||
json: { items: mockTenants, total: 2, limit: 1000, offset: 0 },
|
||||
json: {
|
||||
items: filtered,
|
||||
total: filtered.length,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
});
|
||||
@@ -1093,8 +1246,25 @@ test.describe("Tenants Management", () => {
|
||||
if (url.includes(`/admin/tenants/${tenantUuid}`)) {
|
||||
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({
|
||||
json: { items: [tenant], total: 1, limit: 1000, offset: 0 },
|
||||
json: {
|
||||
items: filtered,
|
||||
total: filtered.length,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
});
|
||||
@@ -1152,8 +1322,24 @@ test.describe("Tenants Management", () => {
|
||||
if (url.includes("/admin/tenants/team-1")) {
|
||||
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({
|
||||
json: { items: tenants, total: tenants.length, limit: 1000, offset: 0 },
|
||||
json: {
|
||||
items: filtered,
|
||||
total: filtered.length,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,8 +108,8 @@ func (m *AsyncMockUserRepo) ListByTenant(ctx context.Context, tenantID string) (
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockUserRepo) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) {
|
||||
return nil, 0, nil
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -236,6 +236,18 @@ func (m *AsyncMockTenantService) ListTenantAdmins(ctx context.Context, tenantID
|
||||
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 {
|
||||
mock.Mock
|
||||
}
|
||||
@@ -357,16 +369,3 @@ func TestSignup_AsyncDB_Isolation(t *testing.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)
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ func (m *MockTenantServiceForConsent) GetTenantByDomain(ctx context.Context, dom
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -189,8 +189,8 @@ func (r *passwordLoginUserRepo) ListByTenant(ctx context.Context, tenantID strin
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *passwordLoginUserRepo) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) {
|
||||
return nil, 0, nil
|
||||
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
|
||||
}
|
||||
|
||||
func (r *passwordLoginUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
|
||||
|
||||
@@ -2416,7 +2416,7 @@ func (h *TenantHandler) loadOrgContextMembers(ctx context.Context, tenantIDs, te
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
usersByAppointment, _, _, err := h.UserRepo.List(ctx, 0, 10000, "", "", "")
|
||||
usersByAppointment, _, _, err := h.UserRepo.List(ctx, 0, 10000, "", []string{}, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -72,8 +72,8 @@ func (m *MockTenantService) GetTenant(ctx context.Context, id string) (*domain.T
|
||||
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) {
|
||||
args := m.Called(ctx, limit, offset, parentID)
|
||||
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, search)
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
if call.Method == "List" {
|
||||
args := m.Called(ctx, offset, limit, search, tenantSlug)
|
||||
return args.Get(0).([]domain.User), args.Get(1).(int64), args.Error(2)
|
||||
args := m.Called(ctx, offset, limit, search, tenantIDs, cursor)
|
||||
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) {
|
||||
@@ -274,7 +274,7 @@ func TestTenantHandler_ListTenantsUsesReadyUserProjectionCountsWithoutKratos(t *
|
||||
tenants := []domain.Tenant{
|
||||
{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("CountTenantMembers", mock.Anything, tenants).
|
||||
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()
|
||||
@@ -313,7 +313,7 @@ func TestTenantHandler_ListTenantsRejectsStatsWhenUserProjectionIsNotReady(t *te
|
||||
tenants := []domain.Tenant{
|
||||
{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()
|
||||
|
||||
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
|
||||
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("CountTenantMembers", mock.Anything, tenants).
|
||||
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)},
|
||||
}
|
||||
|
||||
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("CountTenantMembers", mock.Anything, tenants).Return(map[string]int64{}, nil).Once()
|
||||
|
||||
@@ -463,7 +463,7 @@ func TestTenantHandler_ListTenantsHidesPrivateSubtreeForUnauthorizedUser(t *test
|
||||
})
|
||||
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("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
|
||||
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "public-team")
|
||||
@@ -512,7 +512,7 @@ func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *test
|
||||
})
|
||||
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("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
|
||||
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("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)
|
||||
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},
|
||||
}
|
||||
|
||||
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("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: "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("FindByCompanyCodes", mock.Anything, []string{"hanmac", "platform"}).Return([]domain.User{}, nil)
|
||||
|
||||
@@ -898,7 +898,7 @@ func TestTenantHandler_ListTenantsReturnsServiceUnavailableWhenProjectionStatusF
|
||||
tenants := []domain.Tenant{
|
||||
{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()
|
||||
|
||||
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"},
|
||||
}
|
||||
|
||||
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("CountTenantMembers", mock.Anything, tenants).
|
||||
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)
|
||||
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)
|
||||
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},
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
resp, _ := app.Test(req)
|
||||
@@ -1146,7 +1146,7 @@ func TestTenantHandler_ExportTenantsCSV_HidesPrivateSubtreeForUnauthorizedUser(t
|
||||
})
|
||||
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)
|
||||
resp, _ := app.Test(req)
|
||||
@@ -1175,7 +1175,7 @@ func TestTenantHandler_ImportTenantsCSVCreatesTenant(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
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(
|
||||
"RegisterTenant",
|
||||
mock.Anything,
|
||||
@@ -1219,7 +1219,7 @@ func TestTenantHandler_ImportTenantsCSVResolvesParentSlugToID(t *testing.T) {
|
||||
assert.NoError(t, writer.Close())
|
||||
|
||||
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(
|
||||
"RegisterTenant",
|
||||
mock.Anything,
|
||||
@@ -1276,7 +1276,7 @@ func TestTenantHandler_ImportTenantsCSVDoesNotAssignCreatorAsOrganizationMember(
|
||||
assert.NoError(t, err)
|
||||
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(
|
||||
"RegisterTenant",
|
||||
mock.Anything,
|
||||
|
||||
@@ -1614,7 +1614,14 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users for export")
|
||||
}
|
||||
|
||||
@@ -291,8 +291,8 @@ func (m *MockTenantServiceForUser) ListManageableTenants(ctx context.Context, us
|
||||
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) {
|
||||
args := m.Called(ctx, limit, offset, parentID)
|
||||
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, search)
|
||||
if args.Get(0) == nil {
|
||||
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)
|
||||
tenantID := "tenant-uuid"
|
||||
mockRepo.On("List", mock.Anything, 0, 10000, "", "test-tenant").
|
||||
mockRepo.On("List", mock.Anything, 0, 10000, "", []string(nil), "").
|
||||
Return([]domain.User{
|
||||
{
|
||||
ID: "u-1",
|
||||
@@ -349,7 +349,7 @@ func TestUserHandler_ExportUsersCSV_UsesTenantSlugAliasAndOmitsRole(t *testing.T
|
||||
JobTitle: "플랫폼 운영",
|
||||
CreatedAt: createdAt,
|
||||
},
|
||||
}, int64(1), nil).Maybe()
|
||||
}, int64(1), "", nil).Maybe()
|
||||
|
||||
req := httptest.NewRequest("GET", "/users/export?tenantSlug=test-tenant&includeIds=true", nil)
|
||||
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)
|
||||
tenantID := "tenant-uuid"
|
||||
mockRepo.On("List", mock.Anything, 0, 10000, "", "").
|
||||
mockRepo.On("List", mock.Anything, 0, 10000, "", mock.Anything, "").
|
||||
Return([]domain.User{
|
||||
{
|
||||
ID: "user-uuid",
|
||||
@@ -395,7 +395,7 @@ func TestUserHandler_ExportUsersCSV_OmitsIDsAndUsesTenantSlug(t *testing.T) {
|
||||
JobTitle: "플랫폼 운영",
|
||||
CreatedAt: createdAt,
|
||||
},
|
||||
}, int64(1), nil).Maybe()
|
||||
}, int64(1), "", nil).Maybe()
|
||||
|
||||
req := httptest.NewRequest("GET", "/users/export?includeIds=false", nil)
|
||||
resp, err := app.Test(req)
|
||||
@@ -1049,7 +1049,7 @@ func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) {
|
||||
Slug: "hanmac",
|
||||
ParentID: &rootID,
|
||||
}, 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{
|
||||
{Email: "cyhan@hanmaceng.co.kr", 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("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 {
|
||||
return slices.Contains(ids, hRootID) || slices.Contains(ids, hCompanyID)
|
||||
@@ -1188,7 +1188,7 @@ func TestUserHandler_CreateUser_HanmacEmailPolicyBlocksDuplicateLocalPart(t *tes
|
||||
ID: companyID,
|
||||
Slug: "hanmac",
|
||||
}, 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{
|
||||
{Email: "han@hanmaceng.co.kr", CompanyCode: "hanmac", TenantID: &companyID},
|
||||
}, nil).Maybe()
|
||||
@@ -2146,7 +2146,7 @@ func TestUserHandler_CreateUser_UsesAdditionalAppointmentAsPrimaryTenant(t *test
|
||||
ID: tenantID,
|
||||
Slug: "saman",
|
||||
}, 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("CreateUser", mock.Anything, mock.Anything).Return("some-id", nil).Maybe()
|
||||
mockKratos.On("GetIdentity", mock.Anything, "some-id").Return(&service.KratosIdentity{
|
||||
|
||||
@@ -45,7 +45,7 @@ func (m *MockTenantServiceForMiddleware) GetTenant(ctx context.Context, id strin
|
||||
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
|
||||
}
|
||||
|
||||
@@ -53,6 +53,10 @@ func (m *MockTenantServiceForMiddleware) ListManageableTenants(ctx context.Conte
|
||||
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) {
|
||||
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 {
|
||||
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) DeleteTenantsBulk(ctx context.Context, ids []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestTenantContextMiddleware(t *testing.T) {
|
||||
os.Setenv("USERFRONT_URL", "https://sso.hmac.kr")
|
||||
defer os.Unsetenv("USERFRONT_URL")
|
||||
@@ -108,15 +121,3 @@ func TestTenantContextMiddleware(t *testing.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
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ type UserRepository interface {
|
||||
FindByID(ctx context.Context, id string) (*domain.User, error)
|
||||
FindByIDs(ctx context.Context, ids []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)
|
||||
CountByTenantIDs(ctx context.Context, tenantIDs []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
|
||||
}
|
||||
|
||||
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 total int64
|
||||
db := r.db.WithContext(ctx).Model(&domain.User{})
|
||||
|
||||
if tenantSlug != "" {
|
||||
db = db.Joins("LEFT JOIN tenants ON users.tenant_id = tenants.id").
|
||||
Where("tenants.slug = ?", tenantSlug)
|
||||
if len(tenantIDs) > 0 {
|
||||
db = db.Where("tenant_id IN ?", tenantIDs)
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
|
||||
@@ -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: "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.True(t, total >= 1)
|
||||
assert.Equal(t, "Alice", users[0].Name)
|
||||
|
||||
@@ -60,9 +60,9 @@ func (m *MockTenantRepoForSvc) AddDomain(ctx context.Context, tenantID string, d
|
||||
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) {
|
||||
args := m.Called(ctx, limit, offset, parentID)
|
||||
return args.Get(0).([]domain.Tenant), int64(args.Int(1)), args.Error(2)
|
||||
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, search)
|
||||
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) {
|
||||
@@ -135,8 +135,8 @@ func (m *MockUserRepoForTenant) ListByTenant(ctx context.Context, tenantID strin
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockUserRepoForTenant) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) {
|
||||
return nil, 0, nil
|
||||
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
|
||||
}
|
||||
|
||||
func (m *MockUserRepoForTenant) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
|
||||
@@ -335,9 +335,9 @@ func TestTenantService_ListTenants(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
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.Equal(t, int64(1), total)
|
||||
assert.Equal(t, tenants, result)
|
||||
|
||||
@@ -84,8 +84,8 @@ func (m *MockUserRepository) ListByTenant(ctx context.Context, tenantID string)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) {
|
||||
return nil, 0, nil
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -1978,7 +1978,7 @@ func (f *fakeWorksmobileTenantService) GetTenant(ctx context.Context, id string)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2033,8 +2033,8 @@ func (f *fakeWorksmobileUserRepo) ListByTenant(ctx context.Context, tenantID str
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileUserRepo) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) {
|
||||
return nil, 0, nil
|
||||
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
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
[msg.common]
|
||||
loading_more = "Loading more logs..."
|
||||
copied = "Copied."
|
||||
error = "Error"
|
||||
forbidden = "Access denied."
|
||||
@@ -141,6 +142,7 @@ target = "Client ID · {{value}}"
|
||||
title = "Audit registry"
|
||||
|
||||
[ui.common.audit.table]
|
||||
no_logs = "No logs to display."
|
||||
action = "Action"
|
||||
actor = "User ID"
|
||||
client_id = "Client ID"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
[msg.common]
|
||||
loading_more = "추가 로그를 불러오는 중..."
|
||||
copied = "복사되었습니다."
|
||||
error = "오류가 발생했습니다."
|
||||
forbidden = "접근 권한이 없습니다."
|
||||
@@ -141,7 +142,8 @@ target = "클라이언트 ID · {{value}}"
|
||||
title = "감사 로그 레지스트리"
|
||||
|
||||
[ui.common.audit.table]
|
||||
action = "액션"
|
||||
no_logs = "표시할 로그가 없습니다."
|
||||
action = "작업"
|
||||
actor = "사용자 ID"
|
||||
client_id = "클라이언트 ID"
|
||||
user_id = "사용자 ID"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
[msg.common]
|
||||
loading_more = ""
|
||||
copied = ""
|
||||
error = ""
|
||||
forbidden = ""
|
||||
@@ -141,6 +142,7 @@ target = ""
|
||||
title = ""
|
||||
|
||||
[ui.common.audit.table]
|
||||
no_logs = ""
|
||||
action = ""
|
||||
actor = ""
|
||||
client_id = ""
|
||||
|
||||
Reference in New Issue
Block a user