1
0
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:
2026-02-23 11:23:48 +09:00
parent 919bcd27e8
commit 0ccd1db649
32 changed files with 2173 additions and 40 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@
"format": "biome format . --write",
"preview": "vite preview",
"test": "playwright test",
"test:unit": "vitest run",
"test:ui": "playwright test --ui"
},
"dependencies": {
@@ -39,16 +40,21 @@
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@playwright/test": "^1.58.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23",
"jsdom": "^28.1.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.14",
"tailwindcss-animate": "^1.0.7",
"typescript": "~5.9.3",
"vite": "npm:rolldown-vite@7.2.5"
"vite": "npm:rolldown-vite@7.2.5",
"vitest": "^4.0.18"
},
"overrides": {
"vite": "npm:rolldown-vite@7.2.5"

View File

@@ -82,4 +82,4 @@ Error generating stack: `+a.message+`
<div id='root'></div>
</body>
</html>
<script id="playwrightReportBase64" type="application/zip">data:application/zip;base64,UEsDBBQAAAgIACxPVFzoqOKUygAAABoBAAALAAAAcmVwb3J0Lmpzb25Vj7FOw0AQBX9ltfXJsiFO4Pq0CAk6lGJzXsNhn9faW5Mgy/+O7EBBN/OKJ82MiY0aMkI/Lw6zkdprTIy+OhyqevdY7up9eeewmZQsyoD+vn4oqnLvsI09Z/RvJ4ejyicHe6L0t2Qjy+hnNDHq0ZcO+TpyMG42mYZ/2vbUfW+UuziOv6t06E0nXhyyquj6jceVPDyrBM4ZWpUEQYY2vhcXPr+wfrHChTIMYkDnnsEEtrACjtdoEKRhDxWeHMq4NuVbfKLwEYdbwPIDUEsBAj8DFAAACAgALE9UXOio4pTKAAAAGgEAAAsAAAAAAAAAAAAAALSBAAAAAHJlcG9ydC5qc29uUEsFBgAAAAABAAEAOQAAAPMAAAAAAA==</script>
<script id="playwrightReportBase64" type="application/zip">data:application/zip;base64,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</script>

View 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");
});
});

View 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();
});
});

View File

@@ -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

View File

@@ -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);
}
};

View File

@@ -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">

View File

@@ -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(),
});

View File

@@ -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();
}
};

View File

@@ -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) }));
},
});

View File

@@ -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);
}
}}

View File

@@ -36,6 +36,7 @@ export type TenantCreateRequest = {
name: string;
type?: string;
slug?: string;
parentId?: string;
description?: string;
status?: string;
domains?: string[];

View File

@@ -0,0 +1,8 @@
import "@testing-library/jest-dom";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
// 각 테스트가 끝날 때마다 DOM을 정리합니다.
afterEach(() => {
cleanup();
});

View File

@@ -0,0 +1,28 @@
import { expect, test } from "@playwright/test";
test.describe("Auth Flow", () => {
test("unauthenticated user is redirected to login", async ({ page }) => {
// Navigate to a protected route without setting localStorage
await page.goto("/");
// Check if it redirects to login
await expect(page).toHaveURL(/\/login$/);
// Verify login page content
await expect(page.getByText("Baron SSO")).toBeVisible();
await expect(page.getByText("관리자 로그인")).toBeVisible();
await expect(page.getByRole("button", { name: "SSO 계정으로 로그인" })).toBeVisible();
});
test("authenticated user can access dashboard", async ({ page }) => {
// Inject mock session
await page.addInitScript(() => {
window.localStorage.setItem("admin_session", "playwright-admin-session");
});
await page.goto("/");
// Should stay on dashboard (or another protected route) and not redirect to login
await expect(page).not.toHaveURL(/\/login$/);
});
});

View File

@@ -0,0 +1,122 @@
import { expect, test } from "@playwright/test";
import type { TenantCreateRequest, TenantSummary } from "../src/lib/adminApi";
test.use({
storageState: {
cookies: [],
origins: [
{
origin: "http://localhost:5173",
localStorage: [
{
name: "admin_session",
value: "playwright-admin-session",
},
],
},
],
},
});
test("tenant create and delete flow", async ({ page }) => {
const tenants: TenantSummary[] = [];
let idSeq = 1;
await page.route("**/api/v1/admin/tenants**", async (route) => {
const request = route.request();
const url = new URL(request.url());
const path = url.pathname;
const isCollection = path.endsWith("/api/v1/admin/tenants");
const isItem = path.includes("/api/v1/admin/tenants/");
if (request.method() === "GET" && isCollection) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: tenants,
limit: 50,
offset: 0,
total: tenants.length,
}),
});
return;
}
if (request.method() === "POST" && isCollection) {
const payload = request.postDataJSON() as TenantCreateRequest;
const now = new Date().toISOString();
const tenant: TenantSummary = {
id: `tenant-${idSeq++}`,
name: payload.name,
type: payload.type || "COMPANY",
slug: payload.slug || `slug-${idSeq}`,
description: payload.description || "",
status: payload.status || "active",
domains: payload.domains || [],
createdAt: now,
updatedAt: now,
};
tenants.unshift(tenant);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(tenant),
});
return;
}
if (request.method() === "DELETE" && isItem) {
const tenantId = path.split("/").pop();
const index = tenants.findIndex((t) => t.id === tenantId);
if (index !== -1) {
tenants.splice(index, 1);
}
await route.fulfill({ status: 204, body: "" });
return;
}
await route.fallback();
});
await page.goto("/tenants");
await expect(page).toHaveURL(/\/tenants$/);
await expect(page.getByRole("heading", { name: "테넌트 목록" })).toBeVisible();
// Create
const addTenantLink = page.getByRole("link", { name: "테넌트 추가" });
await addTenantLink.click();
await expect(page).toHaveURL(/\/tenants\/new$/);
const uniqueName = `Test Tenant ${Date.now()}`;
await page.getByLabel("테넌트 이름 *").fill(uniqueName);
await page.getByLabel("테넌트 유형").selectOption("COMPANY");
await page.getByLabel("슬러그 (Slug)").fill("test-tenant");
await page.getByLabel("설명").fill("This is an E2E test tenant");
await page.getByLabel("허용된 도메인 (콤마로 구분)").fill("test.com, example.com");
await page.getByRole("button", { name: "생성" }).click();
await expect(page).toHaveURL(/\/tenants$/);
// Verify created
const createdRow = page.locator("tbody tr").filter({ hasText: uniqueName });
await expect(createdRow).toBeVisible();
// Delete
page.once("dialog", (dialog) => dialog.accept());
await createdRow.getByRole("button", { name: "삭제" }).click();
await expect(page.locator("tbody tr").filter({ hasText: uniqueName })).toHaveCount(0);
});
test("tenant creation form validation", async ({ page }) => {
await page.goto("/tenants/new");
// Try to submit empty form
await page.getByRole("button", { name: "생성" }).click();
// Since 'name' is required, we check if button is still disabled or form doesn't navigate
await expect(page).toHaveURL(/\/tenants\/new$/);
});

View File

@@ -0,0 +1,12 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: "jsdom",
setupFiles: "./src/test/setup.ts",
include: ["src/**/*.{test,spec}.{ts,tsx}"],
},
});