Files
clinicpro/assets/admin/pages/MySecretariesPage.test.tsx
hamedandClaude Opus 5 7c3407b0b3 refactor(admin): split the secretary form from its permissions into two modals
Adding a secretary meant deciding all 17 permission resources in the same
dialog. The full-height capture showed the form running past 1300px with the
save button below 16 accordions, and the dynamic registry makes that worse: every
page added in future lengthens this one modal.

The add/edit modal now carries only doctors, profile and address, and fits on
screen with its footer visible. Permissions move to SecretaryPermissionsModal,
reachable from a row action and opened automatically right after a successful
add, since a new secretary starts on the role defaults and the owner usually
wants to set them.

Neither create nor update sends permissions any more — the backend seeds the role
defaults on create, and the permissions modal owns the writes, fanning out over
every link row so a secretary shared across doctors stays consistent.

PermissionAccordions moves to components/ui as a shared component. Sections now
start collapsed with a granted/total badge on each header, so the panel opens at
a fixed height and still says which sections are on.

Two design-system slips caught by re-screenshotting rather than by the audit:
- a text button as a third row action pushed the name column out of the table, so
  the desktop row uses an icon with a title and the mobile card keeps the label
- .btn.secondary is not defined in styles.css (variants are primary/ghost/soft/
  danger/accent), so it renders as a bare .btn. Used ghost here. 25 other files
  have the same dead class; left alone as a separate sweep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 19:55:32 +03:30

188 lines
8.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, beforeEach, vi } from "vitest";
import { screen, fireEvent } from "@testing-library/react";
import { renderWithProviders } from "../test/utils";
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("../lib/api", () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
vi.mock("../stores/authStore", () => ({
useAuthStore: () => ({ doctorUuid: "doc-1", dbUuid: "doc-1", primaryRole: "doctor" }),
}));
vi.mock("../hooks/useSubscription", () => ({
useSubscription: () => ({ maxSecretaries: 5 }),
}));
import { api } from "../lib/api";
import MySecretariesPage from "./MySecretariesPage";
const get = api.get as ReturnType<typeof vi.fn>;
const fullPerms = {
appointments: { view: true, create: false, cancel: false, update_status: false },
patients: { view: false, create: false, update: false, delete: false },
payments: { view: false, create: false, update: false, delete: false },
insurances: { view: false, create: false, update: false, delete: false },
addresses: { view: false, create: false, update: false, delete: false },
clinic_info: { view: false, update: false },
};
const activeSecretary = {
uuid: "sec-1",
user_name: "سارا احمدی",
mobile_number: "09121234567",
doctor_name: "دکتر تست",
doctor_uuid: "doc-1",
is_active: true,
national_code: "1234567890",
address: "یزد",
permissions: fullPerms,
created_at: 1700000000,
};
const previousSecretary = {
...activeSecretary,
uuid: "sec-2",
user_name: "مینا رضایی",
mobile_number: "09129876543",
is_active: false,
national_code: "9999999999",
};
/** فهرست بخش‌ها از GET /api/v1/permission-catalog می‌آید، نه از کد کامپوننت. */
const CATALOG = [
{
key: "appointments", label: "مدیریت نوبت‌ها", clinic_only: false,
actions: [{ key: "view", label: "مشاهده نوبت‌ها" }, { key: "create", label: "ایجاد نوبت" }],
},
{
key: "patients", label: "پرونده بیماران", clinic_only: false,
actions: [{ key: "view", label: "مشاهده بیماران" }],
},
{
key: "clinic_doctors", label: "مدیریت پزشکان کلینیک", clinic_only: true,
actions: [{ key: "view", label: "مشاهده پزشکان" }],
},
];
function mockData(rows = [activeSecretary, previousSecretary], catalog: unknown[] = CATALOG) {
get.mockImplementation((url: string) => {
if (url.includes("permission-catalog")) {
return Promise.resolve({ success: true, data: { version: 1, resources: catalog } });
}
if (url.includes("/secretaries/")) return Promise.resolve({ success: true, data: rows });
return Promise.resolve({ success: true, data: [] });
});
}
beforeEach(() => {
get.mockReset();
mockData();
});
describe("MySecretariesPage", () => {
it("renders the title and both tabs", async () => {
renderWithProviders(<MySecretariesPage />, { route: "/admin/my-secretaries" });
expect(await screen.findByText("لیست منشی ها")).toBeInTheDocument();
expect(screen.getByText("منشی های فعلی")).toBeInTheDocument();
expect(screen.getByText("منشی های قبلی")).toBeInTheDocument();
});
it("shows active secretaries with national code on the default tab", async () => {
renderWithProviders(<MySecretariesPage />, { route: "/admin/my-secretaries" });
expect(await screen.findAllByText("سارا احمدی")).not.toHaveLength(0);
expect(screen.getAllByText("1234567890").length).toBeGreaterThan(0);
// inactive secretary is hidden on the active tab
expect(screen.queryByText("مینا رضایی")).not.toBeInTheDocument();
});
it("switches to the previous tab and lists inactive secretaries", async () => {
renderWithProviders(<MySecretariesPage />, { route: "/admin/my-secretaries" });
await screen.findAllByText("سارا احمدی");
fireEvent.click(screen.getByText("منشی های قبلی"));
expect(await screen.findAllByText("مینا رضایی")).not.toHaveLength(0);
expect(screen.queryByText("سارا احمدی")).not.toBeInTheDocument();
});
it("shows an empty state when there are no active secretaries", async () => {
mockData([previousSecretary]);
renderWithProviders(<MySecretariesPage />, { route: "/admin/my-secretaries" });
expect(await screen.findByText("هنوز منشی فعالی اضافه نشده است")).toBeInTheDocument();
});
// ── جداسازی فرمِ منشی از دسترسی‌ها ──────────────────────────────────────
// مودالِ افزودن دیگر مجوز ندارد؛ مجوزها مودالِ خودشان را دارند تا افزودنِ یک
// منشی به تصمیم‌گیری دربارهٔ همهٔ منابع گره نخورد.
it("keeps permissions out of the add form", async () => {
renderWithProviders(<MySecretariesPage />, { route: "/admin/my-secretaries" });
await screen.findAllByText("سارا احمدی");
fireEvent.click(screen.getByText("اضافه کردن منشی"));
expect(await screen.findByText("اضافه کردن منشی جدید")).toBeInTheDocument();
expect(screen.queryByText("مدیریت نوبت‌ها")).not.toBeInTheDocument();
expect(screen.queryByText("پرونده بیماران")).not.toBeInTheDocument();
});
it("opens a separate permissions modal from the row action", async () => {
renderWithProviders(<MySecretariesPage />, { route: "/admin/my-secretaries" });
await screen.findAllByText("سارا احمدی");
fireEvent.click(screen.getAllByRole("button", { name: "دسترسی‌ها" })[0]);
expect(await screen.findByText("دسترسی‌های سارا احمدی")).toBeInTheDocument();
expect(await screen.findByText("مدیریت نوبت‌ها")).toBeInTheDocument();
expect(screen.getByText("پرونده بیماران")).toBeInTheDocument();
});
/**
* همان تضمینِ «داینامیک بودن»: منبع تازه فقط به mock اضافه می‌شود و هیچ خطی از
* کد کامپوننت عوض نمی‌شود.
*/
it("shows a resource added to the catalog with no code change", async () => {
mockData(undefined, [
...CATALOG,
{ key: "brand_new_page", label: "صفحهٔ کاملاً تازه", clinic_only: false, actions: [{ key: "view", label: "مشاهده" }] },
]);
renderWithProviders(<MySecretariesPage />, { route: "/admin/my-secretaries" });
await screen.findAllByText("سارا احمدی");
fireEvent.click(screen.getAllByRole("button", { name: "دسترسی‌ها" })[0]);
expect(await screen.findByText("صفحهٔ کاملاً تازه")).toBeInTheDocument();
});
/** منبعِ clinic_only برای پزشکِ مستقل (primaryRole=doctor) نباید دیده شود. */
it("hides clinic-only resources for a standalone doctor", async () => {
renderWithProviders(<MySecretariesPage />, { route: "/admin/my-secretaries" });
await screen.findAllByText("سارا احمدی");
fireEvent.click(screen.getAllByRole("button", { name: "دسترسی‌ها" })[0]);
expect(await screen.findByText("مدیریت نوبت‌ها")).toBeInTheDocument();
expect(screen.queryByText("مدیریت پزشکان کلینیک")).not.toBeInTheDocument();
});
/** آکاردئون‌ها بسته باز می‌شوند و شمارِ روشن‌ها روی هدر دیده می‌شود. */
it("starts every section collapsed with a granted counter", async () => {
renderWithProviders(<MySecretariesPage />, { route: "/admin/my-secretaries" });
await screen.findAllByText("سارا احمدی");
fireEvent.click(screen.getAllByRole("button", { name: "دسترسی‌ها" })[0]);
await screen.findByText("مدیریت نوبت‌ها");
// appointments.view=true در fullPerms → «۱ از ۲» با ارقام فارسی
expect(screen.getByText("۱ از ۲")).toBeInTheDocument();
// بسته است، پس سوییچِ داخلش رندر نشده
expect(screen.queryByLabelText("مدیریت نوبت‌ها — مشاهده نوبت‌ها")).not.toBeInTheDocument();
fireEvent.click(screen.getByText("مدیریت نوبت‌ها"));
expect(await screen.findByLabelText("مدیریت نوبت‌ها — مشاهده نوبت‌ها")).toBeInTheDocument();
});
});