رفع فلو دعوت عضو: لینک کلیکپذیر و صفحهٔ پذیرش دعوت
لینک دعوت هم کلیکپذیر نبود و هم کار نمیکرد؛ چهار نقص همزمان:
- `invite_url` نسبی بود (`/accept-invite?token=…`) و host نداشت → حالا از
`settings.app_url` آدرس مطلق ساخته میشود.
- صفحهٔ `/accept-invite` در اپ Next وجود نداشت → ۴۰۴.
- هیچ اندپوینت پذیرش دعوت در بکاند نبود؛ `InviteRepository.mark_accepted`
نوشته شده بود ولی هرگز صدا زده نمیشد، پس توکن مصرف نمیشد.
- در تنظیمات، لینک بهصورت متن ساده در `<p>` رندر میشد.
بکاند:
- روتر `app/interfaces/http/routers/invites.py`:
`GET /api/invites/{token}` (پیشنمایش) و `POST /api/invites/accept`.
عمداً زیر `/organizations/{org_id}` نیست: دعوتشونده هنوز عضو نیست و
`get_org_context` ردش میکند، پس خود توکن مجوز است. accept ایدمپوتنت است.
- `AuditAction.MEMBER_JOIN = "member.join"`.
- رفع باگ نهفته: `Invite.is_expired()` بین `expires_at` خواندهشده از SQLite
(naive) و `utcnow()` (aware) خطای مقایسه میداد. طبق قرارداد موجود پروژه
(`_aware` در `adapters/agy.py`)، در `repositories/identity.py` تابع `_aware`
اضافه و روی `expires_at`/`accepted_at` اعمال شد.
فرانت:
- صفحهٔ `app/accept-invite/page.tsx` با پیشنمایش سازمان/نقش و پذیرش؛ پس از
پذیرش `setActiveOrganization` میکند تا در همان سازمان لند کند.
- قراردادهای `InvitePreview`/`AcceptInviteResult` و کلاینت
`previewInvite`/`acceptInvite`.
- `login`/`signup` پارامتر `?next=` را میفهمند (فقط same-origin) تا توکن دعوت
در مسیر احراز هویت گم نشود.
- `settings`: لینک به `<a>` واقعی + دکمهٔ «کپی لینک».
تست: `tests/test_invites.py` (۶) و `src/test/accept-invite.test.tsx` (۵).
بکاند ۲۵۲ سبز + ruff/mypy تمیز؛ فرانت ۶۱ سبز + tsc/eslint تمیز.
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* The accept-invite screen is the only place an invited user can turn a link
|
||||
* into a membership, so its states matter: missing token, signed out, preview
|
||||
* failure, and the happy path.
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
class FakeApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
return {
|
||||
FakeApiError,
|
||||
session: {
|
||||
ready: true,
|
||||
user: null as { id: string; email: string } | null,
|
||||
refresh: vi.fn(async () => {}),
|
||||
setActiveOrganization: vi.fn(),
|
||||
},
|
||||
replace: vi.fn(),
|
||||
previewInvite: vi.fn(),
|
||||
acceptInvite: vi.fn(),
|
||||
token: "tok-123",
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useSearchParams: () => new URLSearchParams(`token=${mocks.token}`),
|
||||
useRouter: () => ({ replace: mocks.replace }),
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({
|
||||
href,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => (
|
||||
<a href={href} className={className}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/session", () => ({ useSession: () => mocks.session }));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
ApiError: mocks.FakeApiError,
|
||||
api: {
|
||||
previewInvite: mocks.previewInvite,
|
||||
acceptInvite: mocks.acceptInvite,
|
||||
},
|
||||
}));
|
||||
|
||||
import AcceptInvitePage from "@/app/accept-invite/page";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.token = "tok-123";
|
||||
mocks.session.ready = true;
|
||||
mocks.session.user = null;
|
||||
});
|
||||
|
||||
describe("accept-invite", () => {
|
||||
it("asks a signed-out visitor to authenticate and keeps the token in the link", () => {
|
||||
render(<AcceptInvitePage />);
|
||||
|
||||
expect(screen.getByText("دعوت به یک سازمان")).toBeInTheDocument();
|
||||
const login = screen.getByRole("link", { name: "ورود" });
|
||||
// Losing the token here would strand the invitee on the dashboard.
|
||||
expect(decodeURIComponent(login.getAttribute("href") ?? "")).toBe(
|
||||
"/login?next=/accept-invite?token=tok-123",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the organization and role to the signed-in invitee", async () => {
|
||||
mocks.session.user = { id: "u1", email: "newbie@example.com" };
|
||||
mocks.previewInvite.mockResolvedValue({
|
||||
organization_id: "org-1",
|
||||
organization_name: "Acme",
|
||||
email: "newbie@example.com",
|
||||
role: "manager",
|
||||
expires_at: "2026-01-01T00:00:00Z",
|
||||
already_member: false,
|
||||
});
|
||||
|
||||
render(<AcceptInvitePage />);
|
||||
|
||||
expect(await screen.findByText("دعوت به Acme")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "پذیرش دعوت" })).toBeInTheDocument();
|
||||
expect(mocks.previewInvite).toHaveBeenCalledWith("tok-123");
|
||||
});
|
||||
|
||||
it("surfaces the server's reason when the invite is unusable", async () => {
|
||||
mocks.session.user = { id: "u1", email: "newbie@example.com" };
|
||||
mocks.previewInvite.mockRejectedValue(
|
||||
new mocks.FakeApiError(
|
||||
403,
|
||||
"permission_denied",
|
||||
"این دعوتنامه برای ایمیل دیگری صادر شده است.",
|
||||
),
|
||||
);
|
||||
|
||||
render(<AcceptInvitePage />);
|
||||
|
||||
expect(await screen.findByText("دعوتنامه قابل استفاده نیست")).toBeInTheDocument();
|
||||
expect(screen.getByText(/برای ایمیل دیگری/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports a missing token instead of calling the API", () => {
|
||||
mocks.token = "";
|
||||
render(<AcceptInvitePage />);
|
||||
|
||||
expect(screen.getByText("لینک دعوت ناقص است")).toBeInTheDocument();
|
||||
expect(mocks.previewInvite).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("activates the joined organization and redirects after accepting", async () => {
|
||||
mocks.session.user = { id: "u1", email: "newbie@example.com" };
|
||||
mocks.previewInvite.mockResolvedValue({
|
||||
organization_id: "org-1",
|
||||
organization_name: "Acme",
|
||||
email: "newbie@example.com",
|
||||
role: "member",
|
||||
expires_at: "2026-01-01T00:00:00Z",
|
||||
already_member: false,
|
||||
});
|
||||
mocks.acceptInvite.mockResolvedValue({
|
||||
organization_id: "org-1",
|
||||
organization_name: "Acme",
|
||||
role: "member",
|
||||
});
|
||||
|
||||
render(<AcceptInvitePage />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "پذیرش دعوت" }));
|
||||
|
||||
await waitFor(() => expect(mocks.acceptInvite).toHaveBeenCalledWith("tok-123"));
|
||||
// Landing in the new tenant, not whichever one was active before.
|
||||
await waitFor(() => expect(mocks.session.setActiveOrganization).toHaveBeenCalledWith("org-1"));
|
||||
await waitFor(() => expect(mocks.replace).toHaveBeenCalledWith("/dashboard"));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user