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:
1214
adminfront/package-lock.json
generated
1214
adminfront/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
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();
|
||||
});
|
||||
28
adminfront/tests/auth.spec.ts
Normal file
28
adminfront/tests/auth.spec.ts
Normal 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$/);
|
||||
});
|
||||
});
|
||||
122
adminfront/tests/tenants.spec.ts
Normal file
122
adminfront/tests/tenants.spec.ts
Normal 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$/);
|
||||
});
|
||||
|
||||
12
adminfront/vitest.config.ts
Normal file
12
adminfront/vitest.config.ts
Normal 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}"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user