forked from baron/baron-sso
test: 프론트엔드/백엔드 테스트 커버리지 및 시나리오 보강 (Issue #291)
- FE: Vitest 환경 구축 및 공통 UI 컴포넌트(Badge, Button) 테스트 추가 - FE: Playwright E2E 테스트(Auth, Tenant CRUD 및 Validation) 시나리오 보강 - BE: Testcontainers 기반 Repository 통합 테스트(PostgreSQL) 추가 - BE: TenantRepository 계층 구조(Hierarchy), DB 제약조건(Unique) 테스트 - BE: UserRepository 통합 테스트(CRUD, Delete) 추가 - BE: PasswordPolicy 유틸리티 테스트 보강 - BE: TenantService 엣지 케이스(중복 슬러그, 권한 등) 검증 로직 추가 - Fix: 하위 테넌트 생성 시 ParentID 누락 문제 해결
This commit is contained in:
26
adminfront/src/components/ui/badge.test.tsx
Normal file
26
adminfront/src/components/ui/badge.test.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Badge } from "./badge";
|
||||
|
||||
describe("Badge Component", () => {
|
||||
it("renders correctly with children", () => {
|
||||
render(<Badge>Active</Badge>);
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies variant classes correctly", () => {
|
||||
const { rerender } = render(<Badge variant="secondary">Secondary</Badge>);
|
||||
let badge = screen.getByText("Secondary");
|
||||
expect(badge).toHaveClass("bg-secondary");
|
||||
|
||||
rerender(<Badge variant="outline">Default</Badge>);
|
||||
badge = screen.getByText("Default");
|
||||
expect(badge).toHaveClass("text-foreground");
|
||||
});
|
||||
|
||||
it("applies custom className", () => {
|
||||
render(<Badge className="custom-class">Custom</Badge>);
|
||||
const badge = screen.getByText("Custom");
|
||||
expect(badge).toHaveClass("custom-class");
|
||||
});
|
||||
});
|
||||
36
adminfront/src/components/ui/button.test.tsx
Normal file
36
adminfront/src/components/ui/button.test.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Button } from "./button";
|
||||
|
||||
describe("Button Component", () => {
|
||||
it("renders correctly with children", () => {
|
||||
render(<Button>Click me</Button>);
|
||||
expect(screen.getByRole("button", { name: /click me/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies variant classes correctly", () => {
|
||||
const { rerender } = render(<Button variant="destructive">Delete</Button>);
|
||||
const button = screen.getByRole("button", { name: /delete/i });
|
||||
expect(button).toHaveClass("bg-destructive");
|
||||
|
||||
rerender(<Button variant="outline">Cancel</Button>);
|
||||
const outlineButton = screen.getByRole("button", { name: /cancel/i });
|
||||
expect(outlineButton).toHaveClass("border-input");
|
||||
});
|
||||
|
||||
it("calls onClick when clicked", async () => {
|
||||
const onClick = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<Button onClick={onClick}>Click me</Button>);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /click me/i }));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("is disabled when the disabled prop is passed", () => {
|
||||
render(<Button disabled>Disabled Button</Button>);
|
||||
const button = screen.getByRole("button", { name: /disabled button/i });
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { CheckCircle2, Search, ShieldAlert, XCircle } from "lucide-react";
|
||||
import { CheckCircle2, ShieldAlert, XCircle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
@@ -106,7 +106,7 @@ function PermissionChecker() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{checkMutation.isSuccess && (
|
||||
{checkMutation.isSuccess && result && (
|
||||
<div
|
||||
className={`p-6 rounded-xl border-2 flex flex-col items-center justify-center gap-3 animate-in zoom-in duration-300 ${
|
||||
result.allowed
|
||||
|
||||
@@ -88,7 +88,7 @@ export function TenantAdminsTab() {
|
||||
};
|
||||
|
||||
const handleRemoveAdmin = (userId: string, userName: string) => {
|
||||
if (window.confirm(t("msg.admin.tenants.admins.remove_confirm", { name: userName }))) {
|
||||
if (window.confirm(t("msg.admin.tenants.admins.remove_confirm", "관리자를 삭제하시겠습니까?", { name: userName }))) {
|
||||
removeMutation.mutate(userId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import { Building2, Sparkles } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import { Textarea } from "../../../components/ui/textarea";
|
||||
import { createTenant } from "../../../lib/adminApi";
|
||||
import { createTenant, fetchTenants } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
function TenantCreatePage() {
|
||||
@@ -23,16 +23,23 @@ function TenantCreatePage() {
|
||||
const [name, setName] = useState("");
|
||||
const [type, setType] = useState("COMPANY");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [parentId, setParentId] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [status, setStatus] = useState("active");
|
||||
const [domains, setDomains] = useState("");
|
||||
|
||||
const parentQuery = useQuery({
|
||||
queryKey: ["tenants", { limit: 100 }],
|
||||
queryFn: () => fetchTenants(100, 0),
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createTenant({
|
||||
name,
|
||||
type,
|
||||
slug: slug || undefined,
|
||||
parentId: parentId || undefined,
|
||||
description: description || undefined,
|
||||
status,
|
||||
domains: domains
|
||||
@@ -99,21 +106,41 @@ function TenantCreatePage() {
|
||||
</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.create.form.type", "테넌트 유형")}
|
||||
</Label>
|
||||
<select
|
||||
id="type"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
>
|
||||
<option value="COMPANY">{t("domain.tenant_type.company", "COMPANY (일반 기업)")}</option>
|
||||
<option value="COMPANY_GROUP">{t("domain.tenant_type.company_group", "COMPANY_GROUP (그룹사/지주사)")}</option>
|
||||
<option value="USER_GROUP">{t("domain.tenant_type.user_group", "USER_GROUP (내부 부서/팀)")}</option>
|
||||
<option value="PERSONAL">{t("domain.tenant_type.personal", "PERSONAL (개인 워크스페이스)")}</option>
|
||||
</select>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.create.form.type", "테넌트 유형")}
|
||||
</Label>
|
||||
<select
|
||||
id="type"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
>
|
||||
<option value="COMPANY">{t("domain.tenant_type.company", "COMPANY (일반 기업)")}</option>
|
||||
<option value="COMPANY_GROUP">{t("domain.tenant_type.company_group", "COMPANY_GROUP (그룹사/지주사)")}</option>
|
||||
<option value="USER_GROUP">{t("domain.tenant_type.user_group", "USER_GROUP (내부 부서/팀)")}</option>
|
||||
<option value="PERSONAL">{t("domain.tenant_type.personal", "PERSONAL (개인 워크스페이스)")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.create.form.parent", "상위 테넌트 (선택)")}
|
||||
</Label>
|
||||
<select
|
||||
id="parentId"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={parentId}
|
||||
onChange={(e) => setParentId(e.target.value)}
|
||||
>
|
||||
<option value="">{t("ui.common.none", "없음")}</option>
|
||||
{parentQuery.data?.items?.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name} ({t.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
|
||||
@@ -65,19 +65,19 @@ function TenantGroupsPage() {
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteGroup(id),
|
||||
mutationFn: (id: string) => deleteGroup(tenantId, id),
|
||||
onSuccess: () => groupsQuery.refetch(),
|
||||
});
|
||||
|
||||
const addMemberMutation = useMutation({
|
||||
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
|
||||
addGroupMember(groupId, userId),
|
||||
addGroupMember(tenantId, groupId, userId),
|
||||
onSuccess: () => groupsQuery.refetch(),
|
||||
});
|
||||
|
||||
const removeMemberMutation = useMutation({
|
||||
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
|
||||
removeGroupMember(groupId, userId),
|
||||
removeGroupMember(tenantId, groupId, userId),
|
||||
onSuccess: () => groupsQuery.refetch(),
|
||||
});
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ export function TenantProfilePage() {
|
||||
?.response?.data?.error;
|
||||
|
||||
const handleDelete = () => {
|
||||
if (window.confirm(t("msg.admin.tenants.delete_confirm", { name: tenantQuery.data?.name }))) {
|
||||
if (window.confirm(t("msg.admin.tenants.delete_confirm", "삭제하시겠습니까?", { name: tenantQuery.data?.name ?? "" }))) {
|
||||
deleteMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -75,7 +75,7 @@ export function TenantUserGroupsTab() {
|
||||
toast.success(t("msg.admin.groups.list.create_success", "조직 단위가 생성되었습니다."));
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(t("msg.admin.groups.list.create_error", { error: error.message }));
|
||||
toast.error(t("msg.admin.groups.list.create_error", "생성 실패", { error: String(error.message) }));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -88,7 +88,7 @@ export function TenantUserGroupsTab() {
|
||||
toast.success(t("msg.admin.groups.list.import_success", "조직도가 임포트되었습니다."));
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(t("msg.admin.groups.list.import_error", { error: error.message }));
|
||||
toast.error(t("msg.admin.groups.list.import_error", "가져오기 실패", { error: String(error.message) }));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Plus, Shield, Trash2, UserPlus, Users } from "lucide-react";
|
||||
import { ArrowLeft, Shield, Trash2, UserPlus, Users } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
@@ -327,7 +327,7 @@ export function UserGroupDetailPage() {
|
||||
size="icon"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
if (confirm(t("msg.admin.groups.members.remove_confirm", { name: member.name }))) {
|
||||
if (confirm(t("msg.admin.groups.members.remove_confirm", "제거하시겠습니까?", { name: member.name }))) {
|
||||
removeMemberMutation.mutate(member.id);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -36,6 +36,7 @@ export type TenantCreateRequest = {
|
||||
name: string;
|
||||
type?: string;
|
||||
slug?: string;
|
||||
parentId?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
domains?: string[];
|
||||
|
||||
8
adminfront/src/test/setup.ts
Normal file
8
adminfront/src/test/setup.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
|
||||
// 각 테스트가 끝날 때마다 DOM을 정리합니다.
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
Reference in New Issue
Block a user