From c5781e73577c39218eee4351cb98f32d5297ad21 Mon Sep 17 00:00:00 2001 From: farahsharshar Date: Sat, 2 May 2026 14:07:16 +0300 Subject: [PATCH 01/13] *unit testing --- .../tests/BlockModal.test.tsx | 290 ------- .../tests/ChatProfile.test.tsx | 310 ++++---- .../MessagingComponents/tests/Chats.test.tsx | 361 ++++----- .../tests/CheckBox.test.tsx | 266 ++----- .../tests/ConversationHeader.test.tsx | 708 ++++++------------ .../tests/DeleteConversationButton.test.tsx | 156 ++-- .../tests/DeleteConversationModal.test.tsx | 356 +++++---- .../tests/MessageBox.test.tsx | 503 +++++++------ .../tests/MessageIdPage.test.tsx | 460 ------------ .../tests/MessagesPage.test.tsx | 137 ---- .../tests/MessagingHeader.test.tsx | 131 ++-- .../tests/MiniPlayer.test.tsx | 95 +++ .../MessagingComponents/tests/Modal.test.tsx | 222 +++--- .../tests/ModalNewMessageBody.test.tsx | 270 ------- .../tests/RecipientInputBox.test.tsx | 461 ++++++------ .../tests/ReportModal.test.tsx | 129 ---- .../tests/SendMessageForm.test.tsx | 559 +++++++------- .../tests/SpamModal.test.tsx | 146 ---- .../tests/TrackPlaylistPicker.test.tsx | 216 ++++++ .../tests/emptyMessagesPage.test.tsx | 104 --- .../tests/externalhandler.test.tsx | 120 +-- .../tests/messagecell.test.tsx | 338 ++++++--- .../MessagingBlockModal.coverage.test.tsx | 1 - .../MessagingChatProfile.coverage.test.tsx | 1 - .../tests/MessagingChats.coverage.test.tsx | 1 - .../tests/MessagingCheckBox.coverage.test.tsx | 47 -- ...sagingConversationHeader.coverage.test.tsx | 184 ----- ...DeleteConversationButton.coverage.test.tsx | 1 - ...gDeleteConversationModal.coverage.test.tsx | 1 - ...ssagingEmptyMessagesPage.coverage.test.tsx | 1 - ...MessagingExternalHandler.coverage.test.tsx | 1 - .../tests/MessagingHeader.coverage.test.tsx | 1 - .../MessagingMessageBox.coverage.test.tsx | 1 - .../MessagingMessageCell.coverage.test.tsx | 1 - .../MessagingMessageIdPage.coverage.test.tsx | 1 - .../MessagingMessagesPage.coverage.test.tsx | 1 - .../tests/MessagingModal.coverage.test.tsx | 1 - ...agingModalNewMessageBody.coverage.test.tsx | 1 - ...ssagingRecipientInputBox.coverage.test.tsx | 1 - .../MessagingReportModal.coverage.test.tsx | 1 - ...MessagingSendMessageForm.coverage.test.tsx | 1 - .../MessagingSpamModal.coverage.test.tsx | 1 - .../MessagingTrackPlaylistPicker.test.tsx | 166 ---- .../messages/[messageId]/ModalBlockBody.tsx | 83 -- 44 files changed, 2430 insertions(+), 4406 deletions(-) delete mode 100644 src/components/MessagingComponents/tests/BlockModal.test.tsx delete mode 100644 src/components/MessagingComponents/tests/MessageIdPage.test.tsx delete mode 100644 src/components/MessagingComponents/tests/MessagesPage.test.tsx create mode 100644 src/components/MessagingComponents/tests/MiniPlayer.test.tsx delete mode 100644 src/components/MessagingComponents/tests/ModalNewMessageBody.test.tsx delete mode 100644 src/components/MessagingComponents/tests/ReportModal.test.tsx delete mode 100644 src/components/MessagingComponents/tests/SpamModal.test.tsx create mode 100644 src/components/MessagingComponents/tests/TrackPlaylistPicker.test.tsx delete mode 100644 src/components/MessagingComponents/tests/emptyMessagesPage.test.tsx delete mode 100644 src/components/SearchComponents/tests/MessagingBlockModal.coverage.test.tsx delete mode 100644 src/components/SearchComponents/tests/MessagingChatProfile.coverage.test.tsx delete mode 100644 src/components/SearchComponents/tests/MessagingChats.coverage.test.tsx delete mode 100644 src/components/SearchComponents/tests/MessagingCheckBox.coverage.test.tsx delete mode 100644 src/components/SearchComponents/tests/MessagingConversationHeader.coverage.test.tsx delete mode 100644 src/components/SearchComponents/tests/MessagingDeleteConversationButton.coverage.test.tsx delete mode 100644 src/components/SearchComponents/tests/MessagingDeleteConversationModal.coverage.test.tsx delete mode 100644 src/components/SearchComponents/tests/MessagingEmptyMessagesPage.coverage.test.tsx delete mode 100644 src/components/SearchComponents/tests/MessagingExternalHandler.coverage.test.tsx delete mode 100644 src/components/SearchComponents/tests/MessagingHeader.coverage.test.tsx delete mode 100644 src/components/SearchComponents/tests/MessagingMessageBox.coverage.test.tsx delete mode 100644 src/components/SearchComponents/tests/MessagingMessageCell.coverage.test.tsx delete mode 100644 src/components/SearchComponents/tests/MessagingMessageIdPage.coverage.test.tsx delete mode 100644 src/components/SearchComponents/tests/MessagingMessagesPage.coverage.test.tsx delete mode 100644 src/components/SearchComponents/tests/MessagingModal.coverage.test.tsx delete mode 100644 src/components/SearchComponents/tests/MessagingModalNewMessageBody.coverage.test.tsx delete mode 100644 src/components/SearchComponents/tests/MessagingRecipientInputBox.coverage.test.tsx delete mode 100644 src/components/SearchComponents/tests/MessagingReportModal.coverage.test.tsx delete mode 100644 src/components/SearchComponents/tests/MessagingSendMessageForm.coverage.test.tsx delete mode 100644 src/components/SearchComponents/tests/MessagingSpamModal.coverage.test.tsx delete mode 100644 src/components/SearchComponents/tests/MessagingTrackPlaylistPicker.test.tsx delete mode 100644 src/pages/social/messages/[messageId]/ModalBlockBody.tsx diff --git a/src/components/MessagingComponents/tests/BlockModal.test.tsx b/src/components/MessagingComponents/tests/BlockModal.test.tsx deleted file mode 100644 index 407c5644..00000000 --- a/src/components/MessagingComponents/tests/BlockModal.test.tsx +++ /dev/null @@ -1,290 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, configure } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { BlockUserModal } from "../../UI/BlockModal"; - -// ✅ Ensure data-test works with getByTestId -configure({ testIdAttribute: "data-test" }); - -// ✅ Mock API -vi.mock("@/services/api/messaging/conversationApi", () => ({ - blockUser: vi.fn(), - submitReport: vi.fn(), -})); - -import { - blockUser, - submitReport, -} from "@/services/api/messaging/conversationApi"; - -const renderBlock = ( - props: { - username?: string; - userId?: string; - onClose?: () => void; - onBlocked?: (data: { - blocker_id: string; - blocked_id: string; - created_at: string; - }) => void; - } = {} -) => - render( - - ); - -describe("BlockUserModal", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - // ── Rendering ────────────────────────────────────────────────────────────── - - it("renders with correct data-test attribute", () => { - renderBlock(); - expect(screen.getByTestId("block-user-modal")).toBeInTheDocument(); - }); - - it("renders the heading with username", () => { - renderBlock({ username: "Charlie" }); - expect(screen.getAllByText(/Block Charlie/i)).toHaveLength(2); - }); - - it("renders all seven consequence list items", () => { - renderBlock(); - expect(screen.getByText(/follow you/i)).toBeInTheDocument(); - expect(screen.getByText(/like your tracks/i)).toBeInTheDocument(); - expect(screen.getByText(/repost your tracks/i)).toBeInTheDocument(); - expect(screen.getByText(/send you messages/i)).toBeInTheDocument(); - expect(screen.getByText(/share tracks with you/i)).toBeInTheDocument(); - expect(screen.getByText(/post new comments/i)).toBeInTheDocument(); - expect( - screen.getByText(/send you new stream or email notifications/i) - ).toBeInTheDocument(); - }); - - it("renders two checkboxes", () => { - renderBlock(); - expect( - screen.getByText(/also permanently remove this user's comments/i) - ).toBeInTheDocument(); - expect( - screen.getByText(/also report Charlie for spam/i) - ).toBeInTheDocument(); - }); - - it("renders Cancel and Block buttons", () => { - renderBlock(); - expect(screen.getByTestId("block-cancel-button")).toBeInTheDocument(); - expect(screen.getByTestId("block-user-button")).toBeInTheDocument(); - }); - - it("block button label includes username", () => { - renderBlock({ username: "Charlie" }); - expect(screen.getByTestId("block-user-button")).toHaveTextContent( - "Block Charlie" - ); - }); - - // ── Cancel ───────────────────────────────────────────────────────────────── - - it("calls onClose when Cancel is clicked", async () => { - const onClose = vi.fn(); - renderBlock({ onClose }); - await userEvent.click(screen.getByTestId("block-cancel-button")); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - // ── Block flow (success) ─────────────────────────────────────────────────── - - it("calls blockUser with the userId on block", async () => { - (blockUser as ReturnType).mockResolvedValue({ - data: { blocker_id: "me", blocked_id: "user-charlie", created_at: "now" }, - }); - renderBlock({ userId: "user-charlie" }); - await userEvent.click(screen.getByTestId("block-user-button")); - expect(blockUser).toHaveBeenCalledWith("user-charlie"); - }); - - it("calls onBlocked with block data on success", async () => { - const blockData = { - blocker_id: "me", - blocked_id: "user-charlie", - created_at: "now", - }; - (blockUser as ReturnType).mockResolvedValue({ - data: blockData, - }); - const onBlocked = vi.fn(); - renderBlock({ onBlocked }); - await userEvent.click(screen.getByTestId("block-user-button")); - expect(onBlocked).toHaveBeenCalledWith(blockData); - }); - - it("calls onClose after block success", async () => { - (blockUser as ReturnType).mockResolvedValue({ - data: { blocker_id: "me", blocked_id: "user-charlie", created_at: "now" }, - }); - const onClose = vi.fn(); - renderBlock({ onClose }); - await userEvent.click(screen.getByTestId("block-user-button")); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - // ✅ NEW: cover else branch (no data in response) - it("calls onBlocked with fallback data when response has no data field", async () => { - (blockUser as ReturnType).mockResolvedValue({}); - const onBlocked = vi.fn(); - - renderBlock({ onBlocked, userId: "user-charlie" }); - await userEvent.click(screen.getByTestId("block-user-button")); - - expect(onBlocked).toHaveBeenCalledWith({ - blocker_id: "", - blocked_id: "user-charlie", - created_at: "", - }); - }); - - // ── Block with spam ──────────────────────────────────────────────────────── - - it("calls submitReport for spam when reportSpam checkbox is checked", async () => { - (blockUser as ReturnType).mockResolvedValue({ - data: { blocker_id: "me", blocked_id: "user-charlie", created_at: "now" }, - }); - (submitReport as ReturnType).mockResolvedValue({}); - renderBlock({ userId: "user-charlie" }); - - const spamLabel = screen - .getByText(/also report charlie for spam/i) - .closest("label")!; - const checkbox = spamLabel.querySelector("div[class*='border']")!; - await userEvent.click(checkbox); - - await userEvent.click(screen.getByTestId("block-user-button")); - - expect(submitReport).toHaveBeenCalledWith({ - resource_type: "user", - resource_id: "user-charlie", - reason: "spam", - }); - }); - - it("does not call submitReport when reportSpam is unchecked", async () => { - (blockUser as ReturnType).mockResolvedValue({ - data: { blocker_id: "me", blocked_id: "user-charlie", created_at: "now" }, - }); - renderBlock(); - await userEvent.click(screen.getByTestId("block-user-button")); - expect(submitReport).not.toHaveBeenCalled(); - }); - - // ✅ NEW: cover removeContent interaction - it("toggles removeContent checkbox", async () => { - renderBlock(); - - const label = screen - .getByText(/also permanently remove this user's comments/i) - .closest("label")!; - const checkbox = label.querySelector("div[class*='border']")!; - - await userEvent.click(checkbox); - }); - - // ── Error handling ───────────────────────────────────────────────────────── - - it("shows 401 error message on auth failure", async () => { - (blockUser as ReturnType).mockRejectedValue({ - response: { status: 401 }, - }); - renderBlock(); - await userEvent.click(screen.getByTestId("block-user-button")); - expect( - screen.getByText(/missing or invalid access token/i) - ).toBeInTheDocument(); - }); - - // ✅ NEW: non-401 error - it("does not show error for non-401 errors", async () => { - (blockUser as ReturnType).mockRejectedValue({ - response: { status: 500 }, - }); - - renderBlock(); - await userEvent.click(screen.getByTestId("block-user-button")); - - expect( - screen.queryByText(/missing or invalid access token/i) - ).not.toBeInTheDocument(); - }); - - // ✅ NEW: error without response - it("handles error without response object", async () => { - (blockUser as ReturnType).mockRejectedValue({}); - - renderBlock(); - await userEvent.click(screen.getByTestId("block-user-button")); - - expect( - screen.queryByText(/missing or invalid access token/i) - ).not.toBeInTheDocument(); - }); - - it("does not show error initially", () => { - renderBlock(); - expect( - screen.queryByText(/missing or invalid access token/i) - ).not.toBeInTheDocument(); - }); - - it("does nothing when userId is undefined", async () => { - render(); - await userEvent.click(screen.getByTestId("block-user-button")); - expect(blockUser).not.toHaveBeenCalled(); - }); - - // ✅ NEW: no onBlocked - it("does not crash if onBlocked is undefined", async () => { - (blockUser as ReturnType).mockResolvedValue({ - data: { blocker_id: "me", blocked_id: "user-charlie", created_at: "now" }, - }); - - render(); - await userEvent.click(screen.getByTestId("block-user-button")); - }); - - // ✅ NEW: no onClose - it("does not crash if onClose is undefined", async () => { - (blockUser as ReturnType).mockResolvedValue({ - data: { blocker_id: "me", blocked_id: "user-charlie", created_at: "now" }, - }); - - render(); - await userEvent.click(screen.getByTestId("block-user-button")); - }); - - it("disables buttons while blocking", async () => { - let resolve!: (value: unknown) => void; - - (blockUser as ReturnType).mockImplementation( - () => - new Promise((res) => { - resolve = res; - }) - ); - - renderBlock(); - await userEvent.click(screen.getByTestId("block-user-button")); - - expect(screen.getByTestId("block-user-button")).toBeDisabled(); - expect(screen.getByTestId("block-cancel-button")).toBeDisabled(); - - resolve(undefined); - }); -}); diff --git a/src/components/MessagingComponents/tests/ChatProfile.test.tsx b/src/components/MessagingComponents/tests/ChatProfile.test.tsx index 6e89e732..a89344fe 100644 --- a/src/components/MessagingComponents/tests/ChatProfile.test.tsx +++ b/src/components/MessagingComponents/tests/ChatProfile.test.tsx @@ -1,139 +1,179 @@ -import { render, screen, fireEvent } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; -import { ChatProfile } from "../ChatProfile"; -import type { Conversation, Message } from "@/services/api/messaging/conversationApi"; - -// 🔹 Mock UserAvatar -vi.mock("@/components/UI/UserAvatar", () => ({ - default: ({ name }: { name: string }) =>
{name}
, -})); - -// 🔹 FULL Message factory (FIXED ✅) -const createMessage = (overrides?: Partial): Message => ({ - id: "msg-1", - conversation_id: "conv-1", - sender_id: "user-1", - body: "Hello there", - embed_type: null, - embed_id: null, - is_read: false, - created_at: new Date().toISOString(), - ...overrides, -}); - -// 🔹 Conversation factory -const createConversation = (overrides?: Partial): Conversation => - ({ - id: "conv-1", - updated_at: new Date().toISOString(), - created_at: new Date().toISOString(), - unread_count: 0, +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +vi.mock('@/components/UI/UserAvatar', () => ({ + default: ({ src, name, alt }: { src?: string | null; name: string; alt: string }) => ( +
+ ), +})) + +import { ChatProfile } from '../ChatProfile' +import type { Conversation } from '@/services/api/messaging/conversationApi' + +function makeConversation(overrides: Partial = {}): Conversation { + return { + id: 'conv-1', participant: { - id: "user-2", - username: "john", - display_name: "John Doe", - bio: "", - location: "", - gender: "", - role: "", - avatar: "avatar.png", - cover_photo: "", - is_private: false, - is_verified: false, - followers_count: 0, - following_count: 0, + id: 'user-2', + username: 'testuser', + display_name: 'Test User', + avatar: 'https://example.com/avatar.jpg', + }, + last_message: { + id: 'msg-1', + body: 'Hello there', + embed_type: null, + embed_id: null, + sender_id: 'user-2', created_at: new Date().toISOString(), }, - last_message: createMessage(), + unread_count: 0, + updated_at: new Date().toISOString(), ...overrides, - } as Conversation); - -describe("ChatProfile", () => { - it("renders participant name", () => { - const conv = createConversation(); - - render(); - expect(screen.getByTestId("chat-profile-name")).toHaveTextContent("John Doe"); - }); - - it("renders message preview (text)", () => { - const conv = createConversation({ - last_message: createMessage({ body: "Test message" }), - }); - - render(); - expect(screen.getByText("Test message")).toBeInTheDocument(); - }); - - it("renders fallback when message body is empty and no embed", () => { - const conv = createConversation({ - last_message: createMessage({ body: "" }), - }); - - render(); - expect(screen.getByText("·")).toBeInTheDocument(); - }); - - it("renders track embed preview", () => { - const conv = createConversation({ - last_message: createMessage({ body: "", embed_type: "track" }), - }); - - render(); - expect(screen.getByText("🎵 Shared a track")).toBeInTheDocument(); - }); - - it("renders playlist embed preview", () => { - const conv = createConversation({ - last_message: createMessage({ body: "", embed_type: "playlist" }), - }); - - render(); - expect(screen.getByText("🎶 Shared a playlist")).toBeInTheDocument(); - }); - - it("shows unread indicator when unread_count > 0", () => { - const conv = createConversation({ unread_count: 2 }); - - render(); - expect(screen.getByTestId("chat-profile-unread-dot")).toBeInTheDocument(); - }); - - it("does not show unread indicator when unread_count = 0", () => { - const conv = createConversation({ unread_count: 0 }); - - render(); - expect( - screen.queryByTestId("chat-profile-unread-dot") - ).not.toBeInTheDocument(); - }); - - it("applies active class when isActive is true", () => { - const conv = createConversation(); - - const { container } = render( - - ); - - expect(container.firstChild).toHaveClass("bg-[#303030]"); - }); - - it("calls onClick when clicked", () => { - const conv = createConversation(); - const onClick = vi.fn(); - - render(); - fireEvent.click(screen.getByTestId("chat-profile-conv-1")); - - expect(onClick).toHaveBeenCalled(); - }); - - it("renders 'just now' for recent time", () => { - const conv = createConversation({ - updated_at: new Date().toISOString(), - }); - - render(); - expect(screen.getByText(/just now/i)).toBeInTheDocument(); - }); -}); + } as Conversation +} + +describe('ChatProfile', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('rendering', () => { + it('renders the chat profile container with correct data-test id', () => { + render() + expect(screen.getByTestId('chat-profile-conv-42')).toBeInTheDocument() + }) + + it('renders the participant display name', () => { + render() + expect(screen.getByTestId('chat-profile-name')).toHaveTextContent('Test User') + }) + + it('renders the last message body as preview', () => { + render() + expect(screen.getByTestId('chat-profile-preview')).toHaveTextContent('Hello there') + }) + + it('renders "·" when last_message is undefined', () => { + render() + expect(screen.getByTestId('chat-profile-preview')).toHaveTextContent('·') + }) + + it('renders "·" when last_message has no body and no embed_type', () => { + render() + expect(screen.getByTestId('chat-profile-preview')).toHaveTextContent('·') + }) + + it('renders track embed fallback when body is empty and embed_type is track', () => { + render() + expect(screen.getByTestId('chat-profile-preview')).toHaveTextContent('🎵 Shared a track') + }) + + it('renders playlist embed fallback when body is empty and embed_type is playlist', () => { + render() + expect(screen.getByTestId('chat-profile-preview')).toHaveTextContent('🎶 Shared a playlist') + }) + + it('renders text body over embed type', () => { + render() + expect(screen.getByTestId('chat-profile-preview')).toHaveTextContent('Check this out') + }) + + it('renders unread dot when unread_count > 0', () => { + render() + expect(screen.getByTestId('chat-profile-unread-dot')).toBeInTheDocument() + }) + + it('does not render unread dot when unread_count is 0', () => { + render() + expect(screen.queryByTestId('chat-profile-unread-dot')).not.toBeInTheDocument() + }) + + it('renders UserAvatar with correct props', () => { + render() + const avatar = screen.getByTestId('user-avatar') + expect(avatar).toHaveAttribute('data-src', 'https://example.com/avatar.jpg') + expect(avatar).toHaveAttribute('data-name', 'Test User') + }) + + it('renders a time string', () => { + render() + expect(screen.getByTestId('chat-profile-time')).toBeInTheDocument() + }) + + it('applies active bg class when isActive is true', () => { + render() + expect(screen.getByTestId('chat-profile-conv-1')).toHaveClass('bg-[#303030]') + }) + + it('applies hover class when isActive is false', () => { + render() + expect(screen.getByTestId('chat-profile-conv-1')).toHaveClass('hover:bg-[#303030]') + }) + }) + + describe('time display', () => { + it('shows "just now" for very recent timestamps', () => { + const recentDate = new Date(Date.now() - 30000).toISOString() + render() + expect(screen.getByTestId('chat-profile-time')).toHaveTextContent('just now') + }) + + it('shows minutes ago for timestamps a few minutes old', () => { + const date = new Date(Date.now() - 3 * 60 * 1000).toISOString() + render() + expect(screen.getByTestId('chat-profile-time')).toHaveTextContent('3 minutes ago') + }) + + it('shows hours ago for timestamps hours old', () => { + const date = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString() + render() + expect(screen.getByTestId('chat-profile-time')).toHaveTextContent('2 hours ago') + }) + + it('shows singular "1 hour ago" correctly', () => { + const date = new Date(Date.now() - 1 * 60 * 60 * 1000 - 1000).toISOString() + render() + expect(screen.getByTestId('chat-profile-time')).toHaveTextContent('1 hour ago') + }) + + it('shows days ago for timestamps days old', () => { + const date = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString() + render() + expect(screen.getByTestId('chat-profile-time')).toHaveTextContent('3 days ago') + }) + + it('shows weeks ago for timestamps weeks old', () => { + const date = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString() + render() + expect(screen.getByTestId('chat-profile-time')).toHaveTextContent('2 weeks ago') + }) + + it('shows months ago for timestamps months old', () => { + const date = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString() + render() + expect(screen.getByTestId('chat-profile-time')).toHaveTextContent('2 months ago') + }) + + it('shows years ago for timestamps years old', () => { + const date = new Date(Date.now() - 400 * 24 * 60 * 60 * 1000).toISOString() + render() + expect(screen.getByTestId('chat-profile-time')).toHaveTextContent('1 year ago') + }) + }) + + describe('interactions', () => { + it('calls onClick when clicked', async () => { + const onClick = vi.fn() + render() + await userEvent.click(screen.getByTestId('chat-profile-conv-1')) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('does not throw when onClick is not provided', async () => { + render() + await userEvent.click(screen.getByTestId('chat-profile-conv-1')) + }) + }) +}) diff --git a/src/components/MessagingComponents/tests/Chats.test.tsx b/src/components/MessagingComponents/tests/Chats.test.tsx index 24b53a59..c2c0b7ab 100644 --- a/src/components/MessagingComponents/tests/Chats.test.tsx +++ b/src/components/MessagingComponents/tests/Chats.test.tsx @@ -1,177 +1,192 @@ -import { render, screen, fireEvent } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; -import { Chats } from "../Chats"; -import type { Conversation } from "@/services/api/messaging/conversationApi"; - -// 🔹 Mock dependencies -vi.mock("@/components/MessagingComponents/ChatProfile", () => ({ - ChatProfile: ({ conversation, onClick }: any) => ( -
- {conversation.participant.display_name} -
- ), -})); - -vi.mock("@/components/UI/Spinner", () => ({ - default: () =>
Loading...
, -})); - -const createConversation = (id: string): Conversation => - ({ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' + +vi.mock('@/components/UI/Spinner', () => ({ + default: () =>
, +})) + +vi.mock('@/components/UI/UserAvatar', () => ({ + default: ({ name }: { name: string }) =>
, +})) + +import { Chats } from '../Chats' +import type { Conversation } from '@/services/api/messaging/conversationApi' + +function makeConversation(id: string): Conversation { + return { id, - updated_at: new Date().toISOString(), + participant: { id: 'u2', username: 'user', display_name: 'User', avatar: null }, + last_message: { id: 'm1', body: 'Hi', embed_type: null, embed_id: null, sender_id: 'u2', created_at: new Date().toISOString() } as any, unread_count: 0, - participant: { - display_name: `User ${id}`, - avatar: "", - }, - last_message: { - body: "Hello", - embed_type: null, - }, - } as Conversation); - -const baseProps = { + updated_at: new Date().toISOString(), + } as Conversation +} + +const defaultProps = { + conversations: [], + loading: false, + loadingMore: false, hasMore: false, + error: null, + activeConversationId: null, + onSelect: vi.fn(), onLoadMore: vi.fn(), -}; - -describe("Chats", () => { - it("renders loading spinner", () => { - render( - - ); - - expect(screen.getByTestId("spinner")).toBeInTheDocument(); - }); - - it("renders error message", () => { - render( - - ); - - expect(screen.getByText("Something went wrong")).toBeInTheDocument(); - }); - - it("renders empty state", () => { - render( - - ); - - expect(screen.getByTestId("chat-empty-state")).toBeInTheDocument(); - }); - - it("renders list of conversations", () => { - const conversations = [createConversation("1"), createConversation("2")]; - - render( - - ); - - expect(screen.getByTestId("chat-1")).toBeInTheDocument(); - expect(screen.getByTestId("chat-2")).toBeInTheDocument(); - }); - - it("calls onSelect when a conversation is clicked", () => { - const conversations = [createConversation("1")]; - const onSelect = vi.fn(); - - render( - - ); - - fireEvent.click(screen.getByTestId("chat-1")); - - expect(onSelect).toHaveBeenCalledWith(conversations[0]); - }); - - it("renders loadingMore spinner", () => { - const conversations = [createConversation("1")]; - - render( - - ); - - expect(screen.getAllByTestId("spinner").length).toBeGreaterThan(0); - }); - - it("calls onLoadMore when scrolled near the bottom and more pages exist", () => { - const conversations = [createConversation("1")]; - const onLoadMore = vi.fn(); - - render( - - ); - - const list = screen.getByTestId("chat-list"); - Object.defineProperties(list, { - scrollHeight: { configurable: true, value: 500 }, - scrollTop: { configurable: true, value: 330 }, - clientHeight: { configurable: true, value: 100 }, - }); - - fireEvent.scroll(list); - - expect(onLoadMore).toHaveBeenCalledTimes(1); - }); -}); +} + +describe('Chats', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('loading state', () => { + it('renders spinner when loading is true', () => { + render() + expect(screen.getByTestId('spinner')).toBeInTheDocument() + }) + + it('does not render chat list when loading', () => { + render() + expect(screen.queryByTestId('chat-list')).not.toBeInTheDocument() + }) + }) + + describe('error state', () => { + it('renders error message when error is set', () => { + render() + expect(screen.getByTestId('chat-error')).toHaveTextContent('Failed to load') + }) + + it('does not render spinner in error state', () => { + render() + expect(screen.queryByTestId('spinner')).not.toBeInTheDocument() + }) + }) + + describe('empty state', () => { + it('renders empty state when conversations is empty', () => { + render() + expect(screen.getByTestId('chat-empty-state')).toBeInTheDocument() + }) + + it('empty state contains "No conversations yet." text', () => { + render() + expect(screen.getByTestId('chat-empty-state')).toHaveTextContent('No conversations yet.') + }) + }) + + describe('list state', () => { + it('renders chat-list when conversations exist', () => { + render() + expect(screen.getByTestId('chat-list')).toBeInTheDocument() + }) + + it('renders a ChatProfile for each conversation', () => { + const convs = [makeConversation('c1'), makeConversation('c2')] + render() + expect(screen.getByTestId('chat-profile-c1')).toBeInTheDocument() + expect(screen.getByTestId('chat-profile-c2')).toBeInTheDocument() + }) + + it('calls onSelect with the conversation when a profile is clicked', async () => { + const onSelect = vi.fn() + const conv = makeConversation('c1') + render() + fireEvent.click(screen.getByTestId('chat-profile-c1')) + expect(onSelect).toHaveBeenCalledWith(conv) + }) + + it('renders loadingMore spinner when loadingMore is true', () => { + render() + expect(screen.getByTestId('chat-loading-more')).toBeInTheDocument() + }) + + it('does not render loadingMore div when loadingMore is false', () => { + render() + expect(screen.queryByTestId('chat-loading-more')).not.toBeInTheDocument() + }) + + it('passes isActive=true to the active conversation', () => { + const convs = [makeConversation('c1'), makeConversation('c2')] + render() + expect(screen.getByTestId('chat-profile-c1')).toHaveClass('bg-[#303030]') + expect(screen.getByTestId('chat-profile-c2')).not.toHaveClass('bg-[#303030]') + }) + }) + + describe('scroll / load more', () => { + it('calls onLoadMore when scrolled near bottom and hasMore is true', () => { + const onLoadMore = vi.fn() + render( + + ) + const list = screen.getByTestId('chat-list') + Object.defineProperty(list, 'scrollHeight', { value: 500, configurable: true }) + Object.defineProperty(list, 'scrollTop', { value: 390, configurable: true }) + Object.defineProperty(list, 'clientHeight', { value: 100, configurable: true }) + fireEvent.scroll(list) + expect(onLoadMore).toHaveBeenCalled() + }) + + it('does not call onLoadMore when not near bottom', () => { + const onLoadMore = vi.fn() + render( + + ) + const list = screen.getByTestId('chat-list') + Object.defineProperty(list, 'scrollHeight', { value: 1000, configurable: true }) + Object.defineProperty(list, 'scrollTop', { value: 0, configurable: true }) + Object.defineProperty(list, 'clientHeight', { value: 100, configurable: true }) + fireEvent.scroll(list) + expect(onLoadMore).not.toHaveBeenCalled() + }) + + it('does not call onLoadMore when hasMore is false', () => { + const onLoadMore = vi.fn() + render( + + ) + const list = screen.getByTestId('chat-list') + Object.defineProperty(list, 'scrollHeight', { value: 200, configurable: true }) + Object.defineProperty(list, 'scrollTop', { value: 100, configurable: true }) + Object.defineProperty(list, 'clientHeight', { value: 100, configurable: true }) + fireEvent.scroll(list) + expect(onLoadMore).not.toHaveBeenCalled() + }) + + it('does not call onLoadMore when already loadingMore', () => { + const onLoadMore = vi.fn() + render( + + ) + const list = screen.getByTestId('chat-list') + Object.defineProperty(list, 'scrollHeight', { value: 200, configurable: true }) + Object.defineProperty(list, 'scrollTop', { value: 100, configurable: true }) + Object.defineProperty(list, 'clientHeight', { value: 100, configurable: true }) + fireEvent.scroll(list) + expect(onLoadMore).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/components/MessagingComponents/tests/CheckBox.test.tsx b/src/components/MessagingComponents/tests/CheckBox.test.tsx index 31c11f57..5acbdaf3 100644 --- a/src/components/MessagingComponents/tests/CheckBox.test.tsx +++ b/src/components/MessagingComponents/tests/CheckBox.test.tsx @@ -1,280 +1,98 @@ -// CheckBox.test.tsx -import { render, screen, fireEvent } from '@testing-library/react' import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' import userEvent from '@testing-library/user-event' import CheckBox from '../CheckBox' describe('CheckBox', () => { const defaultProps = { - label: 'Accept Terms', + label: 'My Label', checked: false, - onChange: vi.fn() + onChange: vi.fn(), } beforeEach(() => { vi.clearAllMocks() }) - describe('Rendering', () => { - it('should render with label', () => { - render() - - const label = screen.getByTestId('checkbox-label') - expect(label).toBeInTheDocument() - expect(label).toHaveTextContent('Accept Terms') + describe('rendering', () => { + it('renders with correct data-test attribute based on label', () => { + render() + expect(screen.getByTestId('checkbox-my-label')).toBeInTheDocument() }) - it('should render checkbox toggle element', () => { - render() - - const toggle = screen.getByTestId('checkbox-toggle') - expect(toggle).toBeInTheDocument() + it('converts label to lowercase kebab-case for data-test', () => { + render() + expect(screen.getByTestId('checkbox-also-report-spam')).toBeInTheDocument() }) - it('should have correct data-test attribute on label', () => { - render() - - const container = screen.getByTestId('checkbox-accept-terms') - expect(container).toBeInTheDocument() + it('renders the label text', () => { + render() + expect(screen.getByTestId('checkbox-label')).toHaveTextContent('My Label') }) - it('should handle labels with multiple spaces', () => { - render() - - const container = screen.getByTestId('checkbox-multiple-spaces-here') - expect(container).toBeInTheDocument() + it('renders the toggle div', () => { + render() + expect(screen.getByTestId('checkbox-toggle')).toBeInTheDocument() }) - it('should render with checked state', () => { - render() - - const toggle = screen.getByTestId('checkbox-toggle') - expect(toggle).toBeInTheDocument() - const svg = toggle.querySelector('svg') - expect(svg).toBeInTheDocument() + it('does not show checkmark svg when unchecked and not hovering', () => { + render() + expect(screen.getByTestId('checkbox-toggle').querySelector('svg')).not.toBeInTheDocument() }) - it('should render with unchecked state', () => { - render() - - const toggle = screen.getByTestId('checkbox-toggle') - const svg = toggle.querySelector('svg') - // SVG should still be visible on hover, but initially it's there due to hovering logic - expect(svg).toBeInTheDocument() + it('shows checkmark svg when checked', () => { + render() + expect(screen.getByTestId('checkbox-toggle').querySelector('svg')).toBeInTheDocument() }) }) - describe('Interaction', () => { - it('should call onChange when clicked', () => { + describe('interactions', () => { + it('calls onChange with true when unchecked toggle is clicked', async () => { const onChange = vi.fn() - render() - - const toggle = screen.getByTestId('checkbox-toggle') - fireEvent.click(toggle) - - expect(onChange).toHaveBeenCalledTimes(1) + render() + await userEvent.click(screen.getByTestId('checkbox-toggle')) expect(onChange).toHaveBeenCalledWith(true) }) - it('should call onChange with false when checked and clicked', () => { + it('calls onChange with false when checked toggle is clicked', async () => { const onChange = vi.fn() render() - - const toggle = screen.getByTestId('checkbox-toggle') - fireEvent.click(toggle) - - expect(onChange).toHaveBeenCalledTimes(1) + await userEvent.click(screen.getByTestId('checkbox-toggle')) expect(onChange).toHaveBeenCalledWith(false) }) - it('should handle multiple clicks', () => { - const onChange = vi.fn() - render() - - const toggle = screen.getByTestId('checkbox-toggle') - fireEvent.click(toggle) - fireEvent.click(toggle) - fireEvent.click(toggle) - - expect(onChange).toHaveBeenCalledTimes(3) - expect(onChange).toHaveBeenNthCalledWith(1, true) - expect(onChange).toHaveBeenNthCalledWith(2, false) - expect(onChange).toHaveBeenNthCalledWith(3, true) - }) - - it('should set hovering state on mouse enter', () => { + it('shows svg on hover even when unchecked', async () => { render() - - const toggle = screen.getByTestId('checkbox-toggle') - fireEvent.mouseEnter(toggle) - - // SVG should be visible due to hovering - const svg = toggle.querySelector('svg') - expect(svg).toBeInTheDocument() + fireEvent.mouseEnter(screen.getByTestId('checkbox-toggle')) + expect(screen.getByTestId('checkbox-toggle').querySelector('svg')).toBeInTheDocument() }) - it('should clear hovering state on mouse leave', () => { + it('hides svg after mouse leaves when unchecked', async () => { render() - const toggle = screen.getByTestId('checkbox-toggle') fireEvent.mouseEnter(toggle) fireEvent.mouseLeave(toggle) - - // SVG should still be visible because when not hovering and unchecked, it's not shown - // but the conditional rendering logic shows SVG when checked OR hovering - const svg = toggle.querySelector('svg') - expect(svg).toBeInTheDocument() // Still there because of the conditional + expect(toggle.querySelector('svg')).not.toBeInTheDocument() }) - it('should apply hover styles on mouse enter', () => { + it('svg color is gray when hovering but unchecked', async () => { render() - - const toggle = screen.getByTestId('checkbox-toggle') - expect(toggle).toHaveClass('bg-transparent', 'border-gray-400') - - fireEvent.mouseEnter(toggle) - expect(toggle).toHaveClass('bg-transparent', 'border-gray-400') - }) - }) - - describe('Styling', () => { - it('should have checked styles when checked', () => { - render() - - const toggle = screen.getByTestId('checkbox-toggle') - expect(toggle).toHaveClass('bg-white', 'border-white') - }) - - it('should have unchecked styles when not checked and not hovering', () => { - render() - - const toggle = screen.getByTestId('checkbox-toggle') - expect(toggle).toHaveClass('bg-transparent', 'border-gray-400') - }) - - it('should have correct text color for checked state', () => { - render() - + fireEvent.mouseEnter(screen.getByTestId('checkbox-toggle')) const svg = screen.getByTestId('checkbox-toggle').querySelector('svg') - expect(svg).toHaveClass('text-black') - }) - - it('should have correct text color for hover state when unchecked', () => { - render() - - const toggle = screen.getByTestId('checkbox-toggle') - fireEvent.mouseEnter(toggle) - - const svg = toggle.querySelector('svg') expect(svg).toHaveClass('text-gray-300') }) - it('should have cursor-pointer class', () => { - render() - - const container = screen.getByTestId('checkbox-accept-terms') - expect(container).toHaveClass('cursor-pointer') - }) - - it('should have flex layout classes', () => { - render() - - const container = screen.getByTestId('checkbox-accept-terms') - expect(container).toHaveClass('flex', 'items-start', 'gap-3') - }) - }) - - describe('SVG rendering', () => { - it('should render SVG when checked', () => { + it('svg color is black when checked', () => { render() - const svg = screen.getByTestId('checkbox-toggle').querySelector('svg') - expect(svg).toBeInTheDocument() - expect(svg).toHaveAttribute('viewBox', '0 0 12 12') - }) - - it('should render SVG on hover even when unchecked', () => { - render() - - const toggle = screen.getByTestId('checkbox-toggle') - expect(toggle.querySelector('svg')).toBeInTheDocument() - - // SVG is always rendered because of the conditional - // (checked || hovering) - when unchecked, hovering is false initially but SVG is still there - // Let's verify it's still the same SVG - const svg = toggle.querySelector('svg') - expect(svg).toHaveAttribute('viewBox', '0 0 12 12') - }) - - it('should have correct SVG path', () => { - render() - - const path = screen.getByTestId('checkbox-toggle').querySelector('path') - expect(path).toBeInTheDocument() - expect(path).toHaveAttribute('d', 'M1.5 6l3 3 6-6') - expect(path).toHaveAttribute('stroke', 'currentColor') - expect(path).toHaveAttribute('strokeWidth', '1.8') - expect(path).toHaveAttribute('fill', 'none') - expect(path).toHaveAttribute('strokeLinecap', 'round') - expect(path).toHaveAttribute('strokeLinejoin', 'round') - }) - }) - - describe('Accessibility', () => { - it('should have label associated with checkbox', () => { - render() - - const label = screen.getByTestId('checkbox-accept-terms') - expect(label).toBeInTheDocument() - }) - - it('should have proper data-test attributes for testing', () => { - render() - - expect(screen.getByTestId('checkbox-accept-terms')).toBeInTheDocument() - expect(screen.getByTestId('checkbox-toggle')).toBeInTheDocument() - expect(screen.getByTestId('checkbox-label')).toBeInTheDocument() - }) - }) - - describe('Edge cases', () => { - it('should handle empty label', () => { - render() - - const container = screen.getByTestId('checkbox-') - expect(container).toBeInTheDocument() - const label = screen.getByTestId('checkbox-label') - expect(label).toHaveTextContent('') - }) - - it('should handle very long label', () => { - const longLabel = 'A'.repeat(1000) - render() - - const label = screen.getByTestId('checkbox-label') - expect(label).toHaveTextContent(longLabel) - }) - - it('should handle special characters in label', () => { - const specialLabel = 'Label!@#$%^&*()' - render() - - const label = screen.getByTestId('checkbox-label') - expect(label).toHaveTextContent(specialLabel) + expect(svg).toHaveClass('text-black') }) - it('should maintain hover state after rapid mouse events', () => { - render() - - const toggle = screen.getByTestId('checkbox-toggle') - - fireEvent.mouseEnter(toggle) - fireEvent.mouseLeave(toggle) - fireEvent.mouseEnter(toggle) - fireEvent.mouseLeave(toggle) - - // Should handle without errors - expect(toggle).toBeInTheDocument() + it('calls onChange exactly once per click', async () => { + const onChange = vi.fn() + render() + await userEvent.click(screen.getByTestId('checkbox-toggle')) + expect(onChange).toHaveBeenCalledTimes(1) }) }) }) diff --git a/src/components/MessagingComponents/tests/ConversationHeader.test.tsx b/src/components/MessagingComponents/tests/ConversationHeader.test.tsx index a244c851..95ea0626 100644 --- a/src/components/MessagingComponents/tests/ConversationHeader.test.tsx +++ b/src/components/MessagingComponents/tests/ConversationHeader.test.tsx @@ -1,590 +1,306 @@ -// ConversationHeader.test.tsx -import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { BrowserRouter } from 'react-router-dom' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import ConversationHeader from '../ConversationHeader' -import * as conversationApi from '@/services/api/messaging/conversationApi' - -// Mock the external dependencies -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom') - return { - ...actual, - useNavigate: () => mockNavigate - } +import { MemoryRouter } from 'react-router-dom' + +const mockNavigate = vi.fn() +const mockMarkMessageReadState = vi.fn() +const mockUnblockUser = vi.fn() +const mockFetchFollowStatus = vi.fn() + +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useNavigate: () => mockNavigate } }) +vi.mock('@/services/api/messaging/conversationApi', () => ({ + markMessageReadState: (...args: unknown[]) => mockMarkMessageReadState(...args), + unblockUser: (...args: unknown[]) => mockUnblockUser(...args), + fetchFollowStatus: (...args: unknown[]) => mockFetchFollowStatus(...args), +})) + vi.mock('@/components/MessagingComponents/DeleteConversationButton', () => ({ - default: ({ conversationId, participantId, onDeleted }: any) => ( - - ) + default: ({ onDeleted, conversationId }: { onDeleted?: (id: string) => void; conversationId: string }) => ( + + ), +})) + +vi.mock('../Modal', () => ({ + Modal: ({ isOpen, onClose, children }: { isOpen: boolean; onClose: () => void; children: React.ReactNode }) => + isOpen ?
{children}
: null, })) -vi.mock('../UI/BlockModal', () => ({ - BlockUserModal: ({ userId, username, onClose, onBlocked }: any) => ( +vi.mock('@/components/UI/BlockModal', () => ({ + BlockUserModal: ({ onClose, onBlocked }: { onClose: () => void; onBlocked: () => void }) => (
- - + +
- ) + ), })) -vi.mock('../UI/ReportModal', () => ({ - ReportModal: ({ userId, username, onClose, onSpamSelected }: any) => ( +vi.mock('@/components/UI/ReportModal', () => ({ + ReportModal: ({ onClose, onSpamSelected }: { onClose: () => void; onSpamSelected: () => void }) => (
- - + +
- ) + ), })) -vi.mock('../UI/SpamModal', () => ({ - SpamModal: ({ userId, username, onClose }: any) => ( +vi.mock('@/components/UI/SpamModal', () => ({ + SpamModal: ({ onClose }: { onClose: () => void }) => (
- +
- ) + ), })) vi.mock('@/components/UI/Tooltip', () => ({ - default: ({ children, text }: any) => ( -
- {children} -
- ) + default: ({ children }: { children: React.ReactNode }) => <>{children}, })) -vi.mock('@/services/api/messaging/conversationApi', () => ({ - markMessageReadState: vi.fn(), - unblockUser: vi.fn(), - fetchFollowStatus: vi.fn() -})) +import ConversationHeader from '../ConversationHeader' -const mockNavigate = vi.fn() +const defaultProps = { + reciepiantId: 'user-2', + conversationId: 'conv-1', + recipientName: 'John', + lastMessageId: 'msg-1', + onReadStateChange: vi.fn(), + onDeleted: vi.fn(), + onBack: vi.fn(), +} + +function renderHeader(props = {}) { + return render( + + + + ) +} describe('ConversationHeader', () => { - const defaultProps = { - reciepiantId: 'user123', - conversationId: 'conv456', - recipientName: 'john_doe', - lastMessageId: 'msg789', - onReadStateChange: vi.fn(), - onDeleted: vi.fn(), - onBack: vi.fn() - } - beforeEach(() => { vi.clearAllMocks() - vi.mocked(conversationApi.fetchFollowStatus).mockResolvedValue({ - data: { is_blocking: false } - } as any) - vi.mocked(conversationApi.markMessageReadState).mockResolvedValue({} as any) - vi.mocked(conversationApi.unblockUser).mockResolvedValue({} as any) + mockFetchFollowStatus.mockResolvedValue({ data: { is_blocking: false } }) + mockMarkMessageReadState.mockResolvedValue({}) + mockUnblockUser.mockResolvedValue({}) }) - const renderWithRouter = (props = {}) => { - return render( - - - - ) - } - - describe('Rendering', () => { - it('should render recipient name', async () => { - renderWithRouter() - - await waitFor(() => { - expect(screen.getByTestId('conversation-profile-button')).toBeInTheDocument() - expect(screen.getByText('john_doe')).toBeInTheDocument() - }) + describe('rendering', () => { + it('renders the header container', async () => { + renderHeader() + expect(screen.getByTestId('conversation-header')).toBeInTheDocument() }) - it('should render back button when onBack is provided', async () => { - renderWithRouter({ onBack: vi.fn() }) - - await waitFor(() => { - expect(screen.getByTestId('conversation-back-button')).toBeInTheDocument() - }) + it('renders the recipient name button', async () => { + renderHeader() + expect(screen.getByTestId('conversation-profile-button')).toHaveTextContent('John') }) - it('should not render back button when onBack is not provided', async () => { - renderWithRouter({ onBack: undefined }) - - await waitFor(() => { - expect(screen.queryByTestId('conversation-back-button')).not.toBeInTheDocument() - }) + it('renders the back button when onBack is provided', async () => { + renderHeader() + expect(screen.getByTestId('conversation-back-button')).toBeInTheDocument() }) - it('should render delete conversation button', async () => { - renderWithRouter() - - await waitFor(() => { - expect(screen.getByTestId('delete-conversation-button')).toBeInTheDocument() - }) + it('does not render back button when onBack is not provided', async () => { + renderHeader({ onBack: undefined }) + expect(screen.queryByTestId('conversation-back-button')).not.toBeInTheDocument() }) - it('should render block button on desktop', async () => { - renderWithRouter() - - await waitFor(() => { - expect(screen.getByTestId('conversation-block-button')).toBeInTheDocument() - }) + it('renders the delete button', async () => { + renderHeader() + expect(screen.getByTestId('delete-conversation-button')).toBeInTheDocument() }) - it('should render report button on desktop', async () => { - renderWithRouter() - - await waitFor(() => { - expect(screen.getByTestId('conversation-report-button')).toBeInTheDocument() - }) + it('renders mobile menu button', async () => { + renderHeader() + expect(screen.getByTestId('conversation-mobile-menu-btn')).toBeInTheDocument() }) - it('should render mark as read button on desktop', async () => { - renderWithRouter() - - await waitFor(() => { - expect(screen.getByTestId('conversation-toggle-read-button')).toBeInTheDocument() - }) + it('initially shows "Mark as unread" on desktop button', async () => { + renderHeader() + expect(screen.getByTestId('conversation-toggle-read-button')).toHaveTextContent('Mark as unread') }) - it('should render mobile menu button', async () => { - renderWithRouter() - - await waitFor(() => { - expect(screen.getByTestId('conversation-mobile-menu-btn')).toBeInTheDocument() - }) + it('loads block status on mount', async () => { + renderHeader() + await waitFor(() => expect(mockFetchFollowStatus).toHaveBeenCalledWith('user-2')) }) - it('should show loading state while fetching block status', async () => { - vi.mocked(conversationApi.fetchFollowStatus).mockImplementation( - () => new Promise(() => {}) - ) - - renderWithRouter() - - await waitFor(() => { - expect(screen.getByText('...')).toBeInTheDocument() - }) + it('shows Block button when user is not blocked', async () => { + mockFetchFollowStatus.mockResolvedValue({ data: { is_blocking: false } }) + renderHeader() + await waitFor(() => expect(screen.getByTestId('conversation-block-button')).toHaveTextContent('Block')) + }) + + it('shows Unblock button when user is blocked', async () => { + mockFetchFollowStatus.mockResolvedValue({ data: { is_blocking: true } }) + renderHeader() + await waitFor(() => expect(screen.getByTestId('conversation-block-button')).toHaveTextContent('Unblock')) }) }) - describe('Navigation', () => { - it('should navigate to user profile when clicking profile button', async () => { - renderWithRouter() - - await waitFor(() => { - const profileButton = screen.getByTestId('conversation-profile-button') - fireEvent.click(profileButton) - expect(mockNavigate).toHaveBeenCalledWith('/john_doe') - }) + describe('navigation', () => { + it('navigates to recipient profile on name click', async () => { + renderHeader({ recipientName: 'John' }) + await userEvent.click(screen.getByTestId('conversation-profile-button')) + expect(mockNavigate).toHaveBeenCalledWith('/John') }) - it('should call onBack when back button is clicked', async () => { + it('calls onBack when back button is clicked', async () => { const onBack = vi.fn() - renderWithRouter({ onBack }) - - await waitFor(() => { - const backButton = screen.getByTestId('conversation-back-button') - fireEvent.click(backButton) - expect(onBack).toHaveBeenCalledTimes(1) - }) + renderHeader({ onBack }) + await userEvent.click(screen.getByTestId('conversation-back-button')) + expect(onBack).toHaveBeenCalled() }) }) - describe('Block functionality', () => { - it('should open block modal when block button is clicked', async () => { - renderWithRouter() - - await waitFor(() => { - const blockButton = screen.getByTestId('conversation-block-button') - fireEvent.click(blockButton) - expect(screen.getByTestId('block-modal')).toBeInTheDocument() - }) + describe('read state toggle', () => { + it('calls markMessageReadState when toggle read button is clicked', async () => { + renderHeader() + await userEvent.click(screen.getByTestId('conversation-toggle-read-button')) + await waitFor(() => expect(mockMarkMessageReadState).toHaveBeenCalledWith('conv-1', 'msg-1', false)) }) - it('should show unblock button when user is blocked', async () => { - vi.mocked(conversationApi.fetchFollowStatus).mockResolvedValue({ - data: { is_blocking: true } - } as any) - - renderWithRouter() - - await waitFor(() => { - expect(screen.getByText('Unblock')).toBeInTheDocument() - }) + it('toggles button text to "Mark as read" after marking unread', async () => { + renderHeader() + await userEvent.click(screen.getByTestId('conversation-toggle-read-button')) + await waitFor(() => expect(screen.getByTestId('conversation-toggle-read-button')).toHaveTextContent('Mark as read')) }) - it('should call unblockUser when unblock button is clicked', async () => { - vi.mocked(conversationApi.fetchFollowStatus).mockResolvedValue({ - data: { is_blocking: true } - } as any) - - renderWithRouter() - - await waitFor(async () => { - const unblockButton = screen.getByTestId('conversation-block-button') - fireEvent.click(unblockButton) - - await waitFor(() => { - expect(conversationApi.unblockUser).toHaveBeenCalledWith('user123') - }) - }) + it('calls onReadStateChange after toggling', async () => { + const onReadStateChange = vi.fn() + renderHeader({ onReadStateChange }) + await userEvent.click(screen.getByTestId('conversation-toggle-read-button')) + await waitFor(() => expect(onReadStateChange).toHaveBeenCalledWith(true)) }) - it('should handle unblock error gracefully', async () => { - vi.mocked(conversationApi.fetchFollowStatus).mockResolvedValue({ - data: { is_blocking: true } - } as any) - vi.mocked(conversationApi.unblockUser).mockRejectedValue(new Error('Unblock failed')) - - renderWithRouter() - - await waitFor(async () => { - const unblockButton = screen.getByTestId('conversation-block-button') - fireEvent.click(unblockButton) - - await waitFor(() => { - expect(conversationApi.unblockUser).toHaveBeenCalled() - }) - }) + it('disables toggle read button when lastMessageId is null', async () => { + renderHeader({ lastMessageId: null }) + expect(screen.getByTestId('conversation-toggle-read-button')).toBeDisabled() }) - it('should update block status after blocking', async () => { - renderWithRouter() - - await waitFor(async () => { - const blockButton = screen.getByTestId('conversation-block-button') - fireEvent.click(blockButton) - - const blockModalBlockButton = screen.getByTestId('block-modal-block') - fireEvent.click(blockModalBlockButton) - - await waitFor(() => { - expect(screen.queryByTestId('block-modal')).not.toBeInTheDocument() - }) - }) + it('resets isUnread to false when conversationId changes', async () => { + const { rerender } = renderHeader() + await userEvent.click(screen.getByTestId('conversation-toggle-read-button')) + await waitFor(() => expect(screen.getByTestId('conversation-toggle-read-button')).toHaveTextContent('Mark as read')) + rerender( + + + + ) + await waitFor(() => expect(screen.getByTestId('conversation-toggle-read-button')).toHaveTextContent('Mark as unread')) }) }) - describe('Report functionality', () => { - it('should open report modal when report button is clicked', async () => { - renderWithRouter() - - await waitFor(() => { - const reportButton = screen.getByTestId('conversation-report-button') - fireEvent.click(reportButton) - expect(screen.getByTestId('report-modal')).toBeInTheDocument() - }) + describe('block flow', () => { + it('opens block modal when Block button is clicked (not blocked)', async () => { + mockFetchFollowStatus.mockResolvedValue({ data: { is_blocking: false } }) + renderHeader() + await waitFor(() => screen.getByTestId('conversation-block-button')) + await userEvent.click(screen.getByTestId('conversation-block-button')) + expect(screen.getByTestId('block-modal')).toBeInTheDocument() }) - it('should open spam modal when spam is selected from report modal', async () => { - renderWithRouter() - - await waitFor(() => { - const reportButton = screen.getByTestId('conversation-report-button') - fireEvent.click(reportButton) - - const spamButton = screen.getByTestId('report-modal-spam') - fireEvent.click(spamButton) - - expect(screen.getByTestId('spam-modal')).toBeInTheDocument() - }) + it('calls unblockUser when Unblock is clicked', async () => { + mockFetchFollowStatus.mockResolvedValue({ data: { is_blocking: true } }) + renderHeader() + await waitFor(() => expect(screen.getByTestId('conversation-block-button')).toHaveTextContent('Unblock')) + await userEvent.click(screen.getByTestId('conversation-block-button')) + await waitFor(() => expect(mockUnblockUser).toHaveBeenCalledWith('user-2')) }) - it('should close modals properly', async () => { - renderWithRouter() - - await waitFor(() => { - const reportButton = screen.getByTestId('conversation-report-button') - fireEvent.click(reportButton) - - const closeButton = screen.getByTestId('report-modal-close') - fireEvent.click(closeButton) - - expect(screen.queryByTestId('report-modal')).not.toBeInTheDocument() - }) + it('sets isBlocked to true after onBlocked callback from BlockUserModal', async () => { + mockFetchFollowStatus.mockResolvedValue({ data: { is_blocking: false } }) + renderHeader() + await waitFor(() => screen.getByTestId('conversation-block-button')) + await userEvent.click(screen.getByTestId('conversation-block-button')) + await userEvent.click(screen.getByTestId('block-confirm')) + await waitFor(() => expect(screen.getByTestId('conversation-block-button')).toHaveTextContent('Unblock')) }) }) - describe('Read/Unread functionality', () => { - it('should call markMessageReadState when mark as read button is clicked', async () => { - renderWithRouter() - - await waitFor(async () => { - const toggleReadButton = screen.getByTestId('conversation-toggle-read-button') - fireEvent.click(toggleReadButton) - - await waitFor(() => { - expect(conversationApi.markMessageReadState).toHaveBeenCalledWith( - 'conv456', - 'msg789', - false - ) - }) - }) - }) - - it('should disable toggle read button when no lastMessageId', async () => { - renderWithRouter({ lastMessageId: null }) - - await waitFor(() => { - const toggleReadButton = screen.getByTestId('conversation-toggle-read-button') - expect(toggleReadButton).toBeDisabled() - }) - }) - - it('should update isUnread state after toggling', async () => { - renderWithRouter() - - await waitFor(async () => { - const toggleReadButton = screen.getByTestId('conversation-toggle-read-button') - expect(toggleReadButton).toHaveTextContent('Mark as unread') - - fireEvent.click(toggleReadButton) - - await waitFor(() => { - expect(toggleReadButton).toHaveTextContent('Mark as read') - }) - }) - }) - - it('should call onReadStateChange when read state toggles', async () => { - renderWithRouter() - - await waitFor(async () => { - const toggleReadButton = screen.getByTestId('conversation-toggle-read-button') - fireEvent.click(toggleReadButton) - - await waitFor(() => { - expect(defaultProps.onReadStateChange).toHaveBeenCalledWith(true) - }) - }) + describe('report flow', () => { + it('opens report modal when Report button is clicked', async () => { + renderHeader() + await userEvent.click(screen.getByTestId('conversation-report-button')) + expect(screen.getByTestId('report-modal')).toBeInTheDocument() }) - it('should handle errors in markMessageReadState', async () => { - vi.mocked(conversationApi.markMessageReadState).mockRejectedValue(new Error('API Error')) - - renderWithRouter() - - await waitFor(async () => { - const toggleReadButton = screen.getByTestId('conversation-toggle-read-button') - fireEvent.click(toggleReadButton) - - await waitFor(() => { - expect(conversationApi.markMessageReadState).toHaveBeenCalled() - }) - }) + it('opens spam modal when spam is selected in report modal', async () => { + renderHeader() + await userEvent.click(screen.getByTestId('conversation-report-button')) + await userEvent.click(screen.getByTestId('report-spam')) + await waitFor(() => expect(screen.getByTestId('spam-modal')).toBeInTheDocument()) }) }) - describe('Mobile menu', () => { - it('should open mobile menu when menu button is clicked', async () => { - renderWithRouter() - - await waitFor(() => { - const menuButton = screen.getByTestId('conversation-mobile-menu-btn') - fireEvent.click(menuButton) - - expect(screen.getByTestId('conversation-block-button-mobile')).toBeInTheDocument() - expect(screen.getByTestId('conversation-report-button-mobile')).toBeInTheDocument() - expect(screen.getByTestId('conversation-toggle-read-button-mobile')).toBeInTheDocument() - }) + describe('mobile menu', () => { + it('opens mobile menu when ... button is clicked', async () => { + renderHeader() + await userEvent.click(screen.getByTestId('conversation-mobile-menu-btn')) + expect(screen.getByTestId('conversation-block-button-mobile')).toBeInTheDocument() }) - it('should close mobile menu after action', async () => { - renderWithRouter() - - await waitFor(() => { - const menuButton = screen.getByTestId('conversation-mobile-menu-btn') - fireEvent.click(menuButton) - - const blockButton = screen.getByTestId('conversation-block-button-mobile') - fireEvent.click(blockButton) - - expect(screen.queryByTestId('conversation-block-button-mobile')).not.toBeInTheDocument() - }) + it('closes mobile menu after clicking block in mobile menu', async () => { + mockFetchFollowStatus.mockResolvedValue({ data: { is_blocking: false } }) + renderHeader() + await waitFor(() => screen.getByTestId('conversation-mobile-menu-btn')) + await userEvent.click(screen.getByTestId('conversation-mobile-menu-btn')) + await userEvent.click(screen.getByTestId('conversation-block-button-mobile')) + expect(screen.queryByTestId('conversation-block-button-mobile')).not.toBeInTheDocument() }) - it('should open block modal from mobile menu', async () => { - renderWithRouter() - - await waitFor(() => { - const menuButton = screen.getByTestId('conversation-mobile-menu-btn') - fireEvent.click(menuButton) - - const blockButton = screen.getByTestId('conversation-block-button-mobile') - fireEvent.click(blockButton) - - expect(screen.getByTestId('block-modal')).toBeInTheDocument() - }) + it('opens report modal from mobile menu', async () => { + renderHeader() + await userEvent.click(screen.getByTestId('conversation-mobile-menu-btn')) + await userEvent.click(screen.getByTestId('conversation-report-button-mobile')) + expect(screen.getByTestId('report-modal')).toBeInTheDocument() }) - it('should open report modal from mobile menu', async () => { - renderWithRouter() - - await waitFor(() => { - const menuButton = screen.getByTestId('conversation-mobile-menu-btn') - fireEvent.click(menuButton) - - const reportButton = screen.getByTestId('conversation-report-button-mobile') - fireEvent.click(reportButton) - - expect(screen.getByTestId('report-modal')).toBeInTheDocument() - }) + it('calls markMessageReadState from mobile menu toggle', async () => { + renderHeader() + await userEvent.click(screen.getByTestId('conversation-mobile-menu-btn')) + await userEvent.click(screen.getByTestId('conversation-toggle-read-button-mobile')) + await waitFor(() => expect(mockMarkMessageReadState).toHaveBeenCalled()) }) - it('should toggle read state from mobile menu', async () => { - renderWithRouter() - - await waitFor(async () => { - const menuButton = screen.getByTestId('conversation-mobile-menu-btn') - fireEvent.click(menuButton) - - const toggleReadButton = screen.getByTestId('conversation-toggle-read-button-mobile') - fireEvent.click(toggleReadButton) - - await waitFor(() => { - expect(conversationApi.markMessageReadState).toHaveBeenCalled() - }) - }) - }) - - it('should show unblock option in mobile menu when blocked', async () => { - vi.mocked(conversationApi.fetchFollowStatus).mockResolvedValue({ - data: { is_blocking: true } - } as any) - - renderWithRouter() - - await waitFor(() => { - const menuButton = screen.getByTestId('conversation-mobile-menu-btn') - fireEvent.click(menuButton) - - const blockButton = screen.getByTestId('conversation-block-button-mobile') - expect(blockButton).toHaveTextContent('Unblock john_doe') - }) - }) - - it('should disable mobile toggle read button when no lastMessageId', async () => { - renderWithRouter({ lastMessageId: null }) - - await waitFor(() => { - const menuButton = screen.getByTestId('conversation-mobile-menu-btn') - fireEvent.click(menuButton) - - const toggleReadButton = screen.getByTestId('conversation-toggle-read-button-mobile') - expect(toggleReadButton).toBeDisabled() - }) + it('calls unblockUser when Unblock clicked in mobile menu', async () => { + mockFetchFollowStatus.mockResolvedValue({ data: { is_blocking: true } }) + renderHeader() + await waitFor(() => screen.getByTestId('conversation-mobile-menu-btn')) + await userEvent.click(screen.getByTestId('conversation-mobile-menu-btn')) + await userEvent.click(screen.getByTestId('conversation-block-button-mobile')) + await waitFor(() => expect(mockUnblockUser).toHaveBeenCalled()) }) }) - describe('Tooltips', () => { - it('should render tooltips for desktop buttons', async () => { - renderWithRouter() - - await waitFor(() => { - const tooltips = screen.getAllByTestId('tooltip') - expect(tooltips.length).toBeGreaterThan(0) - }) + describe('delete', () => { + it('calls onDeleted when delete button fires', async () => { + const onDeleted = vi.fn() + renderHeader({ onDeleted }) + await userEvent.click(screen.getByTestId('delete-conversation-button')) + expect(onDeleted).toHaveBeenCalledWith('conv-1') }) }) - describe('Delete conversation', () => { - it('should call onDeleted when delete button is clicked', async () => { - renderWithRouter() - - await waitFor(() => { - const deleteButton = screen.getByTestId('delete-conversation-button') - fireEvent.click(deleteButton) - - expect(defaultProps.onDeleted).toHaveBeenCalledWith('conv456') - }) + describe('error handling', () => { + it('falls back to isBlocked=false when fetchFollowStatus throws', async () => { + mockFetchFollowStatus.mockRejectedValue(new Error('Network error')) + renderHeader() + await waitFor(() => expect(screen.getByTestId('conversation-block-button')).toHaveTextContent('Block')) }) - }) - describe('Error handling', () => { - it('should handle fetchFollowStatus error', async () => { - vi.mocked(conversationApi.fetchFollowStatus).mockRejectedValue(new Error('Network error')) - - renderWithRouter() - - await waitFor(() => { - const blockButton = screen.getByTestId('conversation-block-button') - expect(blockButton).toHaveTextContent('Block') - }) - }) - - it('should handle missing reciepiantId', async () => { - renderWithRouter({ reciepiantId: '' }) - - await waitFor(() => { - expect(conversationApi.fetchFollowStatus).not.toHaveBeenCalled() - }) - }) - }) - - describe('Cleanup and effects', () => { - it('should reset unread state when conversationId changes', async () => { - const { rerender } = renderWithRouter() - - await waitFor(() => { - const toggleReadButton = screen.getByTestId('conversation-toggle-read-button') - expect(toggleReadButton).toHaveTextContent('Mark as unread') - }) - - rerender( - - - - ) - - await waitFor(() => { - const toggleReadButton = screen.getByTestId('conversation-toggle-read-button') - expect(toggleReadButton).toHaveTextContent('Mark as unread') - }) + it('silently handles unblockUser failure', async () => { + mockFetchFollowStatus.mockResolvedValue({ data: { is_blocking: true } }) + mockUnblockUser.mockRejectedValue(new Error('fail')) + renderHeader() + await waitFor(() => expect(screen.getByTestId('conversation-block-button')).toHaveTextContent('Unblock')) + await userEvent.click(screen.getByTestId('conversation-block-button')) + await waitFor(() => expect(mockUnblockUser).toHaveBeenCalled()) + // no crash }) }) - - describe('Responsive behavior', () => { - it('should show desktop buttons on large screens', async () => { - renderWithRouter() - - await waitFor(() => { - expect(screen.getByTestId('conversation-block-button')).toBeVisible() - expect(screen.getByTestId('conversation-report-button')).toBeVisible() - expect(screen.getByTestId('conversation-toggle-read-button')).toBeVisible() - }) - }) - - it('should show mobile menu on small screens', async () => { - renderWithRouter() - - await waitFor(() => { - expect(screen.getByTestId('conversation-mobile-menu-btn')).toBeVisible() - }) - }) - }) -}) \ No newline at end of file +}) diff --git a/src/components/MessagingComponents/tests/DeleteConversationButton.test.tsx b/src/components/MessagingComponents/tests/DeleteConversationButton.test.tsx index 86bb2164..d35d5853 100644 --- a/src/components/MessagingComponents/tests/DeleteConversationButton.test.tsx +++ b/src/components/MessagingComponents/tests/DeleteConversationButton.test.tsx @@ -1,93 +1,101 @@ -import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import DeleteConversationButton from "../DeleteConversationButton"; +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' -// Mock path is relative to THIS file (inside /tests/), so modal is one level up ("../") -// Button labels match the real modal: "Cancel" to close, "Archive" to confirm -vi.mock("../DeleteConversationModal", () => ({ +vi.mock('../DeleteConversationModal', () => ({ default: ({ + conversationId, + participantId, onClose, onDeleted, - conversationId, }: { - onClose: () => void; - onDeleted?: (id: string) => void; - conversationId: string; + conversationId: string + participantId: string + onClose: () => void + onDeleted?: (id: string) => void }) => ( -
- - +
+ +
), -})); +})) + +import DeleteConversationButton from '../DeleteConversationButton' + +describe('DeleteConversationButton', () => { + const defaultProps = { + conversationId: 'conv-1', + participantId: 'user-2', + onDeleted: vi.fn(), + } -const renderButton = ( - props: { - conversationId?: string; - participantId?: string; - onDeleted?: (id: string) => void; - } = {} -) => - render( - - ); + beforeEach(() => { + vi.clearAllMocks() + }) -describe("DeleteConversationButton", () => { - // ── Rendering ────────────────────────────────────────────────────────────── + describe('rendering', () => { + it('renders the delete button', () => { + render() + expect(screen.getByTestId('delete-conversation-button')).toBeInTheDocument() + }) - it("renders a button with aria-label", () => { - renderButton(); - expect( - screen.getByRole("button", { name: /delete conversation/i }) - ).toBeInTheDocument(); - }); + it('renders the trash SVG icon', () => { + render() + expect(screen.getByTestId('delete-conversation-button').querySelector('svg')).toBeInTheDocument() + }) - it("does not render modal initially", () => { - renderButton(); - expect(screen.queryByTestId("delete-modal")).not.toBeInTheDocument(); - }); + it('does not render modal initially', () => { + render() + expect(screen.queryByTestId('delete-modal')).not.toBeInTheDocument() + }) - it("renders a trash SVG icon inside the button", () => { - const { container } = renderButton(); - expect(container.querySelector("svg")).toBeInTheDocument(); - }); + it('has aria-label "Delete conversation"', () => { + render() + expect(screen.getByTestId('delete-conversation-button')).toHaveAttribute('aria-label', 'Delete conversation') + }) + }) - // ── Open modal ───────────────────────────────────────────────────────────── + describe('interactions', () => { + it('shows modal when delete button is clicked', async () => { + render() + await userEvent.click(screen.getByTestId('delete-conversation-button')) + expect(screen.getByTestId('delete-modal')).toBeInTheDocument() + }) - it("opens the modal when the button is clicked", async () => { - renderButton(); - await userEvent.click( - screen.getByRole("button", { name: /delete conversation/i }) - ); - expect(screen.getByTestId("delete-modal")).toBeInTheDocument(); - }); + it('passes correct conversationId to modal', async () => { + render() + await userEvent.click(screen.getByTestId('delete-conversation-button')) + expect(screen.getByTestId('delete-modal')).toHaveAttribute('data-conv-id', 'conv-99') + }) - // ── Close modal ──────────────────────────────────────────────────────────── + it('passes correct participantId to modal', async () => { + render() + await userEvent.click(screen.getByTestId('delete-conversation-button')) + expect(screen.getByTestId('delete-modal')).toHaveAttribute('data-part-id', 'user-77') + }) - it("closes the modal when Cancel is clicked", async () => { - renderButton(); - await userEvent.click( - screen.getByRole("button", { name: /delete conversation/i }) - ); - await userEvent.click(screen.getByText("Cancel")); - expect(screen.queryByTestId("delete-modal")).not.toBeInTheDocument(); - }); + it('hides modal when modal onClose is called', async () => { + render() + await userEvent.click(screen.getByTestId('delete-conversation-button')) + expect(screen.getByTestId('delete-modal')).toBeInTheDocument() + await userEvent.click(screen.getByTestId('modal-close')) + expect(screen.queryByTestId('delete-modal')).not.toBeInTheDocument() + }) - // ── onDeleted propagation ────────────────────────────────────────────────── + it('calls onDeleted when modal confirms deletion', async () => { + const onDeleted = vi.fn() + render() + await userEvent.click(screen.getByTestId('delete-conversation-button')) + await userEvent.click(screen.getByTestId('modal-confirm')) + expect(onDeleted).toHaveBeenCalledWith('conv-1') + }) - it("calls onDeleted with conversationId when Archive is clicked", async () => { - const onDeleted = vi.fn(); - renderButton({ onDeleted, conversationId: "conv-42" }); - await userEvent.click( - screen.getByRole("button", { name: /delete conversation/i }) - ); - await userEvent.click(screen.getByText("Archive")); - expect(onDeleted).toHaveBeenCalledWith("conv-42"); - }); -}); \ No newline at end of file + it('works without onDeleted prop', async () => { + render() + await userEvent.click(screen.getByTestId('delete-conversation-button')) + await userEvent.click(screen.getByTestId('modal-confirm')) + // no error + }) + }) +}) diff --git a/src/components/MessagingComponents/tests/DeleteConversationModal.test.tsx b/src/components/MessagingComponents/tests/DeleteConversationModal.test.tsx index 53ca935c..bbd35ca9 100644 --- a/src/components/MessagingComponents/tests/DeleteConversationModal.test.tsx +++ b/src/components/MessagingComponents/tests/DeleteConversationModal.test.tsx @@ -1,186 +1,172 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import DeleteConversationModal from "../DeleteConversationModal"; - -vi.mock("@/services/api/messaging/conversationApi", () => ({ - deleteConversation: vi.fn(), - submitReport: vi.fn(), -})); - -import { - deleteConversation, - submitReport, -} from "@/services/api/messaging/conversationApi"; - -const renderModal = ( - props: { - conversationId?: string; - participantId?: string; - onClose?: () => void; - onDeleted?: (id: string) => void; - } = {} -) => - render( - - ); - -describe("DeleteConversationModal", () => { +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +const mockDeleteConversation = vi.fn() +const mockSubmitReport = vi.fn() + +vi.mock('@/services/api/messaging/conversationApi', () => ({ + deleteConversation: (...args: unknown[]) => mockDeleteConversation(...args), + submitReport: (...args: unknown[]) => mockSubmitReport(...args), +})) + +vi.mock('../CheckBox', () => ({ + default: ({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) => ( +
+ onChange(e.target.checked)} + data-test="checkbox-toggle" + /> + +
+ ), +})) + +import DeleteConversationModal from '../DeleteConversationModal' + +const defaultProps = { + conversationId: 'conv-1', + participantId: 'user-2', + onClose: vi.fn(), + onDeleted: vi.fn(), +} + +describe('DeleteConversationModal', () => { beforeEach(() => { - vi.clearAllMocks(); - }); - - // ── Rendering ────────────────────────────────────────────────────────────── - - it("renders the confirmation heading", () => { - renderModal(); - expect(screen.getByText(/are you sure/i)).toBeInTheDocument(); - }); - - it("renders the description text", () => { - renderModal(); - expect(screen.getByText(/archiving a conversation removes it/i)).toBeInTheDocument(); - }); - - it("renders the spam checkbox", () => { - renderModal(); - expect( - screen.getByText(/also report conversation as spam/i) - ).toBeInTheDocument(); - }); - - it("renders Cancel and Archive buttons", () => { - renderModal(); - expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /archive/i })).toBeInTheDocument(); - }); - - // ── Cancel ───────────────────────────────────────────────────────────────── - - it("calls onClose when Cancel is clicked", async () => { - const onClose = vi.fn(); - renderModal({ onClose }); - await userEvent.click(screen.getByRole("button", { name: /cancel/i })); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("calls onClose when backdrop is clicked", async () => { - const onClose = vi.fn(); - renderModal({ onClose }); - const backdrop = screen.getByRole("dialog"); - await userEvent.click(backdrop); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("does not call onClose when modal content is clicked", async () => { - const onClose = vi.fn(); - renderModal({ onClose }); - await userEvent.click(screen.getByText(/are you sure/i)); - expect(onClose).not.toHaveBeenCalled(); - }); - - // ── Archive flow ─────────────────────────────────────────────────────────── - - it("calls deleteConversation with conversationId on archive", async () => { - (deleteConversation as ReturnType).mockResolvedValue({}); - renderModal({ conversationId: "conv-1" }); - await userEvent.click(screen.getByRole("button", { name: /archive/i })); - expect(deleteConversation).toHaveBeenCalledWith("conv-1"); - }); - - it("calls onDeleted with conversationId on success", async () => { - (deleteConversation as ReturnType).mockResolvedValue({}); - const onDeleted = vi.fn(); - renderModal({ onDeleted, conversationId: "conv-1" }); - await userEvent.click(screen.getByRole("button", { name: /archive/i })); - expect(onDeleted).toHaveBeenCalledWith("conv-1"); - }); - - it("calls onClose after successful delete", async () => { - (deleteConversation as ReturnType).mockResolvedValue({}); - const onClose = vi.fn(); - renderModal({ onClose }); - await userEvent.click(screen.getByRole("button", { name: /archive/i })); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - // ── With spam report ─────────────────────────────────────────────────────── - - it("calls submitReport before deleteConversation when spam checkbox is checked", async () => { - const callOrder: string[] = []; - (submitReport as ReturnType).mockImplementation(async () => - callOrder.push("report") - ); - (deleteConversation as ReturnType).mockImplementation(async () => - callOrder.push("delete") - ); - - renderModal({ participantId: "user-1" }); - const checkboxContainer = screen - .getByText(/also report conversation as spam/i) - .closest("label")!.querySelector("div[class*='border']")!; - await userEvent.click(checkboxContainer); - await userEvent.click(screen.getByRole("button", { name: /archive/i })); - - expect(callOrder).toEqual(["report", "delete"]); - expect(submitReport).toHaveBeenCalledWith({ - resource_type: "user", - resource_id: "user-1", - reason: "spam", - }); - }); - - it("does not call submitReport when spam checkbox is unchecked", async () => { - (deleteConversation as ReturnType).mockResolvedValue({}); - renderModal(); - await userEvent.click(screen.getByRole("button", { name: /archive/i })); - expect(submitReport).not.toHaveBeenCalled(); - }); - - // ── Error handling ───────────────────────────────────────────────────────── - - it("shows error message on failure", async () => { - (deleteConversation as ReturnType).mockRejectedValue( - new Error("Server error") - ); - renderModal(); - await userEvent.click(screen.getByRole("button", { name: /archive/i })); - expect( - screen.getByText(/something went wrong/i) - ).toBeInTheDocument(); - }); - - it("does not show error initially", () => { - renderModal(); - expect(screen.queryByText(/something went wrong/i)).not.toBeInTheDocument(); - }); - - it("disables buttons while deleting", async () => { - let resolve!: (value: unknown) => void; - (deleteConversation as ReturnType).mockImplementation( - () => new Promise((res) => { resolve = res; }) - ); - renderModal(); - await userEvent.click(screen.getByRole("button", { name: /archive/i })); - expect(screen.getByRole("button", { name: /archiving/i })).toBeDisabled(); - expect(screen.getByRole("button", { name: /cancel/i })).toBeDisabled(); - resolve(undefined); - }); - - it("shows 'Archiving…' text while in progress", async () => { - let resolve!: (value: unknown) => void; - (deleteConversation as ReturnType).mockImplementation( - () => new Promise((res) => { resolve = res; }) - ); - renderModal(); - await userEvent.click(screen.getByRole("button", { name: /archive/i })); - expect(screen.getByText(/archiving…/i)).toBeInTheDocument(); - resolve(undefined); - }); -}); \ No newline at end of file + vi.clearAllMocks() + mockDeleteConversation.mockResolvedValue({}) + mockSubmitReport.mockResolvedValue({}) + }) + + describe('rendering', () => { + it('renders the modal container', () => { + render() + expect(screen.getByTestId('delete-conversation-modal')).toBeInTheDocument() + }) + + it('renders the title "Are you sure?"', () => { + render() + expect(screen.getByText('Are you sure?')).toBeInTheDocument() + }) + + it('renders the description text', () => { + render() + expect(screen.getByText(/Archiving a conversation/)).toBeInTheDocument() + }) + + it('renders the CheckBox', () => { + render() + expect(screen.getByTestId('checkbox-also-report-conversation-as-spam')).toBeInTheDocument() + }) + + it('renders the Cancel button', () => { + render() + expect(screen.getByTestId('delete-conversation-cancel')).toBeInTheDocument() + }) + + it('renders the Archive button', () => { + render() + expect(screen.getByTestId('delete-conversation-confirm')).toHaveTextContent('Archive') + }) + + it('does not show error initially', () => { + render() + expect(screen.queryByTestId('delete-conversation-error')).not.toBeInTheDocument() + }) + }) + + describe('backdrop click', () => { + it('calls onClose when backdrop is clicked directly', () => { + const onClose = vi.fn() + render() + const modal = screen.getByTestId('delete-conversation-modal') + fireEvent.click(modal) + expect(onClose).toHaveBeenCalled() + }) + + it('does not call onClose when inner content is clicked', async () => { + const onClose = vi.fn() + render() + await userEvent.click(screen.getByText('Are you sure?')) + expect(onClose).not.toHaveBeenCalled() + }) + }) + + describe('cancel button', () => { + it('calls onClose when Cancel is clicked', async () => { + const onClose = vi.fn() + render() + await userEvent.click(screen.getByTestId('delete-conversation-cancel')) + expect(onClose).toHaveBeenCalledTimes(1) + }) + }) + + describe('archive (delete) flow', () => { + it('calls deleteConversation with the conversationId', async () => { + render() + await userEvent.click(screen.getByTestId('delete-conversation-confirm')) + await waitFor(() => expect(mockDeleteConversation).toHaveBeenCalledWith('conv-1')) + }) + + it('calls onDeleted with conversationId after successful delete', async () => { + const onDeleted = vi.fn() + render() + await userEvent.click(screen.getByTestId('delete-conversation-confirm')) + await waitFor(() => expect(onDeleted).toHaveBeenCalledWith('conv-1')) + }) + + it('calls onClose after successful delete', async () => { + const onClose = vi.fn() + render() + await userEvent.click(screen.getByTestId('delete-conversation-confirm')) + await waitFor(() => expect(onClose).toHaveBeenCalled()) + }) + + it('shows "Archiving…" text on the button while deleting', async () => { + mockDeleteConversation.mockReturnValue(new Promise(() => {})) // never resolves + render() + await userEvent.click(screen.getByTestId('delete-conversation-confirm')) + expect(screen.getByTestId('delete-conversation-confirm')).toHaveTextContent('Archiving…') + }) + + it('disables Cancel and Archive buttons while deleting', async () => { + mockDeleteConversation.mockReturnValue(new Promise(() => {})) + render() + await userEvent.click(screen.getByTestId('delete-conversation-confirm')) + expect(screen.getByTestId('delete-conversation-cancel')).toBeDisabled() + expect(screen.getByTestId('delete-conversation-confirm')).toBeDisabled() + }) + + it('shows error message when deleteConversation throws', async () => { + mockDeleteConversation.mockRejectedValue(new Error('fail')) + render() + await userEvent.click(screen.getByTestId('delete-conversation-confirm')) + await waitFor(() => expect(screen.getByTestId('delete-conversation-error')).toBeInTheDocument()) + }) + }) + + describe('spam report flow', () => { + it('calls submitReport when spam checkbox is checked before archiving', async () => { + render() + const checkbox = screen.getByTestId('checkbox-toggle') + fireEvent.change(checkbox, { target: { checked: true } }) + await userEvent.click(screen.getByTestId('delete-conversation-confirm')) + await waitFor(() => + expect(mockSubmitReport).toHaveBeenCalledWith({ + resource_type: 'user', + resource_id: 'user-2', + reason: 'spam', + }) + ) + }) + + it('does not call submitReport when spam checkbox is not checked', async () => { + render() + await userEvent.click(screen.getByTestId('delete-conversation-confirm')) + await waitFor(() => expect(mockDeleteConversation).toHaveBeenCalled()) + expect(mockSubmitReport).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/components/MessagingComponents/tests/MessageBox.test.tsx b/src/components/MessagingComponents/tests/MessageBox.test.tsx index 21917e92..0fd3ad3c 100644 --- a/src/components/MessagingComponents/tests/MessageBox.test.tsx +++ b/src/components/MessagingComponents/tests/MessageBox.test.tsx @@ -1,239 +1,264 @@ -// import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -// import { render, screen, fireEvent, act } from "@testing-library/react"; -// import userEvent from "@testing-library/user-event"; -// import { MessageBox } from "../MessageBox"; - -// vi.mock("@/services/api/messaging/conversationApi", () => ({ -// resolvePermalink: vi.fn(), -// fetchTrack: vi.fn(), -// fetchPlaylist: vi.fn(), -// })); - -// import { -// resolvePermalink, -// fetchTrack, -// fetchPlaylist, -// } from "@/services/api/messaging/conversationApi"; - -// const renderBox = (props = {}) => -// render( -// -// ); - -// // Synchronously sets the textarea value and fires onChange. -// // Used in debounce tests so fake timers control ALL async work — -// // userEvent.type uses its own internal setTimeout delays which -// // conflict with vi.useFakeTimers() and cause indefinite hangs. -// const fireType = (element: HTMLElement, value: string) => -// fireEvent.change(element, { target: { value } }); - -// // Fires all pending timers AND flushes resulting Promise microtasks, -// // then wraps everything in act() so React commits all setState calls to the DOM. -// const runAllTimers = () => act(async () => { await vi.runAllTimersAsync(); }); - -// describe("MessageBox", () => { -// beforeEach(() => { -// vi.clearAllMocks(); -// }); - -// // ── Rendering ────────────────────────────────────────────────────────────── - -// it("renders the textarea", () => { -// renderBox(); -// expect(screen.getByTestId("message-input")).toBeInTheDocument(); -// }); - -// it("does not render title initially", () => { -// renderBox(); -// expect(screen.queryByTestId("message-box-title")).not.toBeInTheDocument(); -// }); - -// it("has correct data-test on wrapper", () => { -// renderBox(); -// expect(screen.getByTestId("message-box")).toBeInTheDocument(); -// }); - -// // ── Typing (real timers + userEvent) ─────────────────────────────────────── - -// it("calls onValueChange when user types", async () => { -// const user = userEvent.setup(); -// const onValueChange = vi.fn(); -// renderBox({ onValueChange }); -// await user.type(screen.getByTestId("message-input"), "Hello"); -// expect(onValueChange).toHaveBeenCalledWith("Hello"); -// }); - -// it("calls onIsEmptyChange(false) when text is entered", async () => { -// const user = userEvent.setup(); -// const onIsEmptyChange = vi.fn(); -// renderBox({ onIsEmptyChange }); -// await user.type(screen.getByTestId("message-input"), "H"); -// expect(onIsEmptyChange).toHaveBeenCalledWith(false); -// }); - -// it("calls onIsEmptyChange(true) when text is cleared", async () => { -// const user = userEvent.setup(); -// const onIsEmptyChange = vi.fn(); -// renderBox({ onIsEmptyChange }); -// const input = screen.getByTestId("message-input"); -// await user.type(input, "H"); -// await user.clear(input); -// expect(onIsEmptyChange).toHaveBeenCalledWith(true); -// }); - -// it("calls onEmbedResolved(null) when no URL is in text", async () => { -// const user = userEvent.setup(); -// const onEmbedResolved = vi.fn(); -// renderBox({ onEmbedResolved }); -// await user.type(screen.getByTestId("message-input"), "Just text"); -// expect(onEmbedResolved).toHaveBeenCalledWith(null); -// }); - -// // ── Error styling ────────────────────────────────────────────────────────── - -// it("applies error border class when hasError is true", () => { -// renderBox({ hasError: true }); -// expect(screen.getByTestId("message-input")).toHaveClass("border-red-500"); -// }); - -// it("applies normal border class when hasError is false", () => { -// renderBox({ hasError: false }); -// expect(screen.getByTestId("message-input")).not.toHaveClass("border-red-500"); -// }); - -// // ── Debounced URL embed detection ────────────────────────────────────────── -// // -// // PROBLEM (the last 2 failures): -// // vi.runAllTimersAsync() fires the debounce setTimeout and drains Promise -// // microtasks (resolvePermalink, fetchTrack chains), but React's setTitle() -// // calls happen INSIDE those async callbacks. React batches setState and -// // only commits them to the DOM when the update is flushed inside act(). -// // Without act(), the DOM never updates — so getByTestId("message-box-title") -// // finds nothing even though setTitle("Awesome Track") was called. -// // -// // FIX: -// // Wrap vi.runAllTimersAsync() inside act(async () => { ... }). -// // act() tells React "flush everything now" — timers fire, promises drain, -// // and all resulting setState calls are committed to the DOM before we assert. -// // -// // Extracted into the runAllTimers() helper above for readability. - -// describe("debounced URL embed detection", () => { -// beforeEach(() => { -// vi.useFakeTimers(); -// }); - -// afterEach(() => { -// vi.useRealTimers(); -// }); - -// it("does not call resolvePermalink immediately on URL input (debounced)", () => { -// renderBox(); -// fireType( -// screen.getByTestId("message-input"), -// "https://rythmify.com/tracks/123" -// ); -// expect(resolvePermalink).not.toHaveBeenCalled(); -// }); - -// it("calls resolvePermalink after debounce when URL is typed", async () => { -// (resolvePermalink as ReturnType).mockResolvedValue({ -// data: { type: "track", id: "t-1" }, -// }); -// (fetchTrack as ReturnType).mockResolvedValue({ -// data: { title: "My Track", id: "t-1" }, -// }); - -// renderBox(); -// fireType( -// screen.getByTestId("message-input"), -// "https://rythmify.com/tracks/t-1" -// ); - -// await runAllTimers(); - -// expect(resolvePermalink).toHaveBeenCalled(); -// }); - -// it("renders track title after successful track embed resolution", async () => { -// (resolvePermalink as ReturnType).mockResolvedValue({ -// data: { type: "track", id: "t-1" }, -// }); -// (fetchTrack as ReturnType).mockResolvedValue({ -// data: { title: "Awesome Track", id: "t-1" }, -// }); - -// renderBox(); -// fireType( -// screen.getByTestId("message-input"), -// "https://rythmify.com/tracks/t-1" -// ); - -// await runAllTimers(); - -// expect(screen.getByTestId("message-box-title")).toHaveTextContent("Awesome Track"); -// }); - -// it("renders playlist title after successful playlist embed resolution", async () => { -// (resolvePermalink as ReturnType).mockResolvedValue({ -// data: { type: "playlist", id: "p-1" }, -// }); -// (fetchPlaylist as ReturnType).mockResolvedValue({ -// data: { title: "My Playlist", id: "p-1" }, -// }); - -// renderBox(); -// fireType( -// screen.getByTestId("message-input"), -// "https://rythmify.com/playlists/p-1" -// ); - -// await runAllTimers(); - -// expect(screen.getByTestId("message-box-title")).toHaveTextContent("My Playlist"); -// }); - -// it("clears title when embed resolution fails", async () => { -// (resolvePermalink as ReturnType).mockRejectedValue( -// new Error("Not found") -// ); - -// renderBox(); -// fireType( -// screen.getByTestId("message-input"), -// "https://rythmify.com/tracks/bad" -// ); - -// await runAllTimers(); - -// expect(screen.queryByTestId("message-box-title")).not.toBeInTheDocument(); -// }); - -// it("calls onEmbedResolved with track embed data on success", async () => { -// const onEmbedResolved = vi.fn(); -// (resolvePermalink as ReturnType).mockResolvedValue({ -// data: { type: "track", id: "t-99" }, -// }); -// (fetchTrack as ReturnType).mockResolvedValue({ -// data: { title: "Track 99", id: "t-99" }, -// }); - -// renderBox({ onEmbedResolved }); -// fireType( -// screen.getByTestId("message-input"), -// "https://rythmify.com/tracks/t-99" -// ); - -// await runAllTimers(); - -// expect(onEmbedResolved).toHaveBeenCalledWith( -// expect.objectContaining({ type: "track", id: "t-99" }) -// ); -// }); -// }); -// }); \ No newline at end of file +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +const mockResolvePermalink = vi.fn() +const mockFetchTrack = vi.fn() +const mockFetchPlaylist = vi.fn() + +vi.mock('@/services/api/messaging/conversationApi', () => ({ + resolvePermalink: (...args: unknown[]) => mockResolvePermalink(...args), + fetchTrack: (...args: unknown[]) => mockFetchTrack(...args), + fetchPlaylist: (...args: unknown[]) => mockFetchPlaylist(...args), +})) + +vi.mock('../MiniPlayer', () => ({ + default: ({ trackName, onClose }: { trackName: string; onClose: () => void }) => ( +
+ +
+ ), +})) + +import { MessageBox } from '../MessageBox' + +const defaultProps = { + onValueChange: vi.fn(), + onIsEmptyChange: vi.fn(), + onEmbedsResolved: vi.fn(), + hasError: false, +} + +const mockTrack = { + id: 'track-1', + title: 'My Track', + artist_name: 'Artist', + cover_image: null, + description: null, + genre: null, + duration: null, + bitrate: null, + status: 'public', + is_public: true, + is_hidden: false, + user_id: 'u1', + play_count: 0, + like_count: 0, + stream_url: null, + preview_url: null, + waveform_url: null, + artists: null, + created_at: '', + updated_at: '', +} + +const mockPlaylist = { + playlist_id: 'pl-1', + name: 'My Playlist', + cover_image: null, + track_count: 3, + tracks: [], + owner_user_id: 'u1', + slug: null, + description: null, + is_public: true, + like_count: 0, + repost_count: 0, + created_at: '', + updated_at: '', +} + +describe('MessageBox', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('rendering', () => { + it('renders the message-box container', () => { + render() + expect(screen.getByTestId('message-box')).toBeInTheDocument() + }) + + it('renders the textarea', () => { + render() + expect(screen.getByTestId('message-input')).toBeInTheDocument() + }) + + it('textarea starts empty', () => { + render() + expect(screen.getByTestId('message-input')).toHaveValue('') + }) + + it('applies error border class when hasError is true', () => { + render() + expect(screen.getByTestId('message-input')).toHaveClass('border-red-500') + }) + + it('applies normal border class when hasError is false', () => { + render() + expect(screen.getByTestId('message-input')).not.toHaveClass('border-red-500') + }) + + it('does not render MiniPlayer when no embeds', () => { + render() + expect(screen.queryByTestId('mini-player')).not.toBeInTheDocument() + }) + + it('renders MiniPlayer for external track embed', () => { + const externalEmbeds = [{ type: 'track' as const, id: 't1', resource: mockTrack, sourceUrl: '' }] + render() + expect(screen.getByTestId('mini-player')).toBeInTheDocument() + }) + + it('renders MiniPlayer for external playlist embed', () => { + const externalEmbeds = [{ type: 'playlist' as const, id: 'pl1', resource: mockPlaylist as any, sourceUrl: '' }] + render() + expect(screen.getByTestId('mini-player')).toBeInTheDocument() + }) + + it('renders multiple MiniPlayers for multiple embeds', () => { + const externalEmbeds = [ + { type: 'track' as const, id: 't1', resource: mockTrack, sourceUrl: '' }, + { type: 'track' as const, id: 't2', resource: { ...mockTrack, id: 't2', title: 'Track 2' }, sourceUrl: '' }, + ] + render() + expect(screen.getAllByTestId('mini-player')).toHaveLength(2) + }) + }) + + describe('typing', () => { + it('calls onValueChange when user types', () => { + const onValueChange = vi.fn() + render() + fireEvent.change(screen.getByTestId('message-input'), { target: { value: 'hello' } }) + expect(onValueChange).toHaveBeenCalledWith('hello') + }) + + it('calls onIsEmptyChange with false when text is entered', () => { + const onIsEmptyChange = vi.fn() + render() + fireEvent.change(screen.getByTestId('message-input'), { target: { value: 'hi' } }) + expect(onIsEmptyChange).toHaveBeenCalledWith(false) + }) + + it('calls onIsEmptyChange with true when text is cleared', () => { + const onIsEmptyChange = vi.fn() + render() + fireEvent.change(screen.getByTestId('message-input'), { target: { value: 'hi' } }) + fireEvent.change(screen.getByTestId('message-input'), { target: { value: '' } }) + expect(onIsEmptyChange).toHaveBeenCalledWith(true) + }) + + it('calls onEmbedsResolved with empty array when no URL in text', () => { + const onEmbedsResolved = vi.fn() + render() + fireEvent.change(screen.getByTestId('message-input'), { target: { value: 'just text' } }) + expect(onEmbedsResolved).toHaveBeenCalled() + }) + }) + + describe('keyboard submit', () => { + it('calls onSubmit when Enter is pressed without Shift', () => { + const onSubmit = vi.fn() + render() + fireEvent.keyDown(screen.getByTestId('message-input'), { key: 'Enter', shiftKey: false }) + expect(onSubmit).toHaveBeenCalledTimes(1) + }) + + it('does not call onSubmit when Shift+Enter is pressed', () => { + const onSubmit = vi.fn() + render() + fireEvent.keyDown(screen.getByTestId('message-input'), { key: 'Enter', shiftKey: true }) + expect(onSubmit).not.toHaveBeenCalled() + }) + }) + + describe('URL resolution (debounced)', () => { + it('calls resolvePermalink for URLs in text after debounce', async () => { + mockResolvePermalink.mockResolvedValue({ data: { type: 'track', id: 'track-1' } }) + mockFetchTrack.mockResolvedValue({ data: mockTrack }) + + render() + fireEvent.change(screen.getByTestId('message-input'), { + target: { value: 'https://soundcloud.com/artist/track' }, + }) + vi.advanceTimersByTime(600) + await waitFor(() => expect(mockResolvePermalink).toHaveBeenCalledWith('https://soundcloud.com/artist/track')) + }) + + it('fetches track data when permalink resolves to track type', async () => { + mockResolvePermalink.mockResolvedValue({ data: { type: 'track', id: 'track-1' } }) + mockFetchTrack.mockResolvedValue({ data: mockTrack }) + + render() + fireEvent.change(screen.getByTestId('message-input'), { + target: { value: 'https://soundcloud.com/artist/track' }, + }) + vi.advanceTimersByTime(600) + await waitFor(() => expect(mockFetchTrack).toHaveBeenCalledWith('track-1')) + }) + + it('fetches playlist data when permalink resolves to playlist type', async () => { + mockResolvePermalink.mockResolvedValue({ data: { type: 'playlist', id: 'pl-1' } }) + mockFetchPlaylist.mockResolvedValue({ data: mockPlaylist }) + + render() + fireEvent.change(screen.getByTestId('message-input'), { + target: { value: 'https://soundcloud.com/artist/sets/playlist' }, + }) + vi.advanceTimersByTime(600) + await waitFor(() => expect(mockFetchPlaylist).toHaveBeenCalledWith('pl-1')) + }) + + it('ignores user type from resolvePermalink', async () => { + mockResolvePermalink.mockResolvedValue({ data: { type: 'user', id: 'u1' } }) + render() + fireEvent.change(screen.getByTestId('message-input'), { + target: { value: 'https://soundcloud.com/artist' }, + }) + vi.advanceTimersByTime(600) + await waitFor(() => expect(mockResolvePermalink).toHaveBeenCalled()) + expect(mockFetchTrack).not.toHaveBeenCalled() + }) + + it('handles resolvePermalink failure gracefully', async () => { + mockResolvePermalink.mockRejectedValue(new Error('fail')) + render() + fireEvent.change(screen.getByTestId('message-input'), { + target: { value: 'https://soundcloud.com/artist/track' }, + }) + vi.advanceTimersByTime(600) + await waitFor(() => expect(mockResolvePermalink).toHaveBeenCalled()) + // no crash + }) + }) + + describe('embed removal', () => { + it('removes MiniPlayer when close button is clicked (external embeds)', async () => { + const onEmbedsResolved = vi.fn() + const externalEmbeds = [{ type: 'track' as const, id: 't1', resource: mockTrack, sourceUrl: '' }] + render() + await userEvent.click(screen.getByTestId('mini-player-close')) + expect(onEmbedsResolved).toHaveBeenCalledWith([]) + }) + + it('removes only the clicked embed when multiple embeds exist', async () => { + const onEmbedsResolved = vi.fn() + const externalEmbeds = [ + { type: 'track' as const, id: 't1', resource: mockTrack, sourceUrl: '' }, + { type: 'track' as const, id: 't2', resource: { ...mockTrack, id: 't2', title: 'Track 2' }, sourceUrl: '' }, + ] + render() + const closeBtns = screen.getAllByTestId('mini-player-close') + await userEvent.click(closeBtns[0]) + const lastCall = onEmbedsResolved.mock.calls[onEmbedsResolved.mock.calls.length - 1][0] + expect(lastCall).toHaveLength(1) + expect(lastCall[0].id).toBe('t2') + }) + }) +}) diff --git a/src/components/MessagingComponents/tests/MessageIdPage.test.tsx b/src/components/MessagingComponents/tests/MessageIdPage.test.tsx deleted file mode 100644 index e8fada16..00000000 --- a/src/components/MessagingComponents/tests/MessageIdPage.test.tsx +++ /dev/null @@ -1,460 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { MemoryRouter } from "react-router-dom"; -import MessageIdPage from "@/pages/social/messages/[messageId]/MessageIdPage"; -import type { Conversation, Message } from "@/services/api/messaging/conversationApi"; - -// ── Mock API ─────────────────────────────────────────────────────────────── -vi.mock("@/services/api/messaging/conversationApi", () => ({ - fetchConversations: vi.fn(), - fetchConversation: vi.fn(), - markMessageReadState: vi.fn(), -})); - -vi.mock("@/services/api/messaging/socketService", () => ({ - joinConversation: vi.fn(), - leaveConversation: vi.fn(), - emitMessageRead: vi.fn(), - getSocket: vi.fn(() => ({ - on: vi.fn(), - off: vi.fn(), - })), -})); - -vi.mock("@/stores/messaging.store", () => { - const useMessagingStoreMock = vi.fn(() => ({ - refreshUnreadCount: vi.fn(), - })); - (useMessagingStoreMock as any).setState = vi.fn(); - return { useMessagingStore: useMessagingStoreMock }; -}); - -const { mockNavigate } = vi.hoisted(() => ({ - mockNavigate: vi.fn(), -})); - -vi.mock("react-router-dom", async () => { - const actual = await vi.importActual("react-router-dom"); - return { ...actual, useNavigate: () => mockNavigate }; -}); - -// ── Mock child components ────────────────────────────────────────────────── -// All elements use data-test — this project's testIdAttribute is 'data-test'. - -vi.mock("@/components/MessagingComponents/Chats", () => ({ - Chats: ({ - conversations, - onSelect, - activeConversationId, - error, - }: { - conversations: Conversation[]; - loading: boolean; - error: string | null; - activeConversationId: string | null; - onSelect: (c: Conversation) => void; - }) => ( -
- {error &&

{error}

} - {conversations.map((c) => ( - - ))} -
- ), -})); - -vi.mock("@/components/MessagingComponents/MessagingHeader", () => ({ - default: () =>
Header
, -})); - -vi.mock("@/components/MessagingComponents/ConversationHeader", () => ({ - default: ({ - recipientName, - onReadStateChange, - onDeleted, - conversationId, - }: { - recipientName: string; - onReadStateChange: (v: boolean) => void; - onDeleted?: (id: string) => void; - conversationId: string; - reciepiantId: string; - lastMessageId: string | null; - }) => ( -
- {recipientName} - - -
- ), -})); - -vi.mock("@/components/MessagingComponents/SendMessageForm", () => ({ - default: ({ - onMessageSent, - }: { - conversationId: string; - existingMessages: Message[]; - loadingMessages: boolean; - onMessageSent: (msg: unknown) => void; - ParticipantInfo: { display_name: string; profile_picture?: string | null }; - }) => ( -
- -
- ), -})); - -import { - fetchConversations, - fetchConversation, - markMessageReadState, -} from "@/services/api/messaging/conversationApi"; - -// ── Helpers ──────────────────────────────────────────────────────────────── - -const makeConv = (id: string, participantId = `p-${id}`): Conversation => ({ - id, - participant: { - id: participantId, - display_name: `User ${id}`, - profile_picture: null, - username: `user${id}`, - }, - last_message: { - id: `lm-${id}`, - body: `Last from ${id}`, - created_at: new Date().toISOString(), - sender_id: participantId, - is_read: true, - }, - unread_count: 0, - updated_at: new Date().toISOString(), -} as unknown as Conversation); - -const makeMessage = (senderId: string, isRead = true): Message => ({ - id: `msg-${Math.random()}`, - body: "Hello", - created_at: new Date().toISOString(), - sender_id: senderId, - is_read: isRead, -} as unknown as Message); - -const renderPage = () => - render( - - - - ); - -describe("MessageIdPage", () => { - beforeEach(() => { - vi.clearAllMocks(); - (markMessageReadState as ReturnType).mockResolvedValue({}); - (fetchConversation as ReturnType).mockResolvedValue({ - data: { - messages: [], - pagination: { total_items: 0 } - }, - }); - }); - - // ── Loading / initial render ─────────────────────────────────────────────── - - it("renders the MessagingHeader", async () => { - (fetchConversations as ReturnType).mockResolvedValue({ - data: { items: [] }, - }); - renderPage(); - expect(screen.getByTestId("messaging-header")).toBeInTheDocument(); - }); - - it("renders the Chats panel", async () => { - (fetchConversations as ReturnType).mockResolvedValue({ - data: { items: [] }, - }); - renderPage(); - expect(screen.getByTestId("chats")).toBeInTheDocument(); - }); - - it("shows 'Select a conversation' when no conversations exist", async () => { - (fetchConversations as ReturnType).mockResolvedValue({ - data: { items: [] }, - }); - renderPage(); - await waitFor(() => - expect( - screen.getByText(/select a conversation to start messaging/i) - ).toBeInTheDocument() - ); - }); - - // ── Auto-open first conversation ─────────────────────────────────────────── - - it("auto-opens the first conversation after loading", async () => { - const conv1 = makeConv("1", "p1"); - (fetchConversations as ReturnType).mockResolvedValue({ - data: { items: [conv1] }, - }); - (fetchConversation as ReturnType).mockResolvedValue({ - data: { - messages: [], - pagination: { total_items: 0 } - }, - }); - - renderPage(); - await waitFor(() => - expect(screen.getByTestId("conversation-header")).toBeInTheDocument() - ); - expect(screen.getByText("User 1")).toBeInTheDocument(); - }); - - it("navigates to participant URL when user selects a conversation", async () => { - const conv1 = makeConv("1", "p1"); - const conv2 = makeConv("2", "p2"); - (fetchConversations as ReturnType).mockResolvedValue({ - data: { items: [conv1, conv2] }, - }); - (fetchConversation as ReturnType).mockResolvedValue({ - data: { - messages: [], - pagination: { total_items: 0 } - }, - }); - - renderPage(); - await waitFor(() => screen.getByTestId("conv-2")); - await userEvent.click(screen.getByTestId("conv-2")); - - await waitFor(() => - expect(mockNavigate).toHaveBeenCalledWith("/messages/2") - ); - }); - - it("marks unread messages as read on conversation open", async () => { - const conv1 = makeConv("1", "p1"); - const unreadMsg = makeMessage("p1", false); - (fetchConversations as ReturnType).mockResolvedValue({ - data: { items: [conv1] }, - }); - (fetchConversation as ReturnType).mockResolvedValue({ - data: { - messages: [unreadMsg], - pagination: { total_items: 1 } - }, - }); - - renderPage(); - await waitFor(() => - expect(markMessageReadState).toHaveBeenCalledWith("1", unreadMsg.id, true) - ); - }); - - it("does not call markMessageReadState for already-read messages", async () => { - const conv1 = makeConv("1", "p1"); - const readMsg = makeMessage("p1", true); - (fetchConversations as ReturnType).mockResolvedValue({ - data: { items: [conv1] }, - }); - (fetchConversation as ReturnType).mockResolvedValue({ - data: { - messages: [readMsg], - pagination: { total_items: 1 } - }, - }); - - renderPage(); - await waitFor(() => screen.getByTestId("conversation-header")); - expect(markMessageReadState).not.toHaveBeenCalled(); - }); - - // ── Switching conversations ──────────────────────────────────────────────── - - it("switches active conversation when another is clicked", async () => { - const conv1 = makeConv("1", "p1"); - const conv2 = makeConv("2", "p2"); - (fetchConversations as ReturnType).mockResolvedValue({ - data: { items: [conv1, conv2] }, - }); - (fetchConversation as ReturnType).mockResolvedValue({ - data: { - messages: [], - pagination: { total_items: 0 } - }, - }); - - renderPage(); - await waitFor(() => screen.getByTestId("conv-1")); - await userEvent.click(screen.getByTestId("conv-2")); - - await waitFor(() => - expect(mockNavigate).toHaveBeenCalledWith("/messages/2") - ); - expect(screen.getByText("User 2")).toBeInTheDocument(); - }); - - // ── Message sent ─────────────────────────────────────────────────────────── - - it("renders SendMessageForm when conversation is active", async () => { - const conv1 = makeConv("1", "p1"); - (fetchConversations as ReturnType).mockResolvedValue({ - data: { items: [conv1] }, - }); - (fetchConversation as ReturnType).mockResolvedValue({ - data: { - messages: [], - pagination: { total_items: 0 } - }, - }); - - renderPage(); - await waitFor(() => - expect(screen.getByTestId("send-message-form")).toBeInTheDocument() - ); - }); - - it("updates conversation list when a message is sent", async () => { - const conv1 = makeConv("1", "p1"); - (fetchConversations as ReturnType).mockResolvedValue({ - data: { items: [conv1] }, - }); - (fetchConversation as ReturnType).mockResolvedValue({ - data: { - messages: [], - pagination: { total_items: 0 } - }, - }); - - renderPage(); - await waitFor(() => screen.getByTestId("send-msg")); - await userEvent.click(screen.getByTestId("send-msg")); - }); - - // ── Conversation deleted ─────────────────────────────────────────────────── - - it("removes deleted conversation and auto-selects next", async () => { - const conv1 = makeConv("1", "p1"); - const conv2 = makeConv("2", "p2"); - (fetchConversations as ReturnType).mockResolvedValue({ - data: { items: [conv1, conv2] }, - }); - (fetchConversation as ReturnType).mockResolvedValue({ - data: { - messages: [], - pagination: { total_items: 0 } - }, - }); - - renderPage(); - await waitFor(() => screen.getByTestId("delete-conv")); - await userEvent.click(screen.getByTestId("delete-conv")); - - await waitFor(() => - expect(mockNavigate).toHaveBeenCalledWith("/messages/2") - ); - }); - - it("navigates to /messages when last conversation is deleted", async () => { - const conv1 = makeConv("1", "p1"); - (fetchConversations as ReturnType).mockResolvedValue({ - data: { items: [conv1] }, - }); - (fetchConversation as ReturnType).mockResolvedValue({ - data: { - messages: [], - pagination: { total_items: 0 } - }, - }); - - renderPage(); - await waitFor(() => screen.getByTestId("delete-conv")); - await userEvent.click(screen.getByTestId("delete-conv")); - - await waitFor(() => - expect(mockNavigate).toHaveBeenCalledWith("/messages") - ); - }); - - // ── Read state toggle ────────────────────────────────────────────────────── - - it("updates unread_count when read state changes to unread", async () => { - const conv1 = makeConv("1", "p1"); - (fetchConversations as ReturnType).mockResolvedValue({ - data: { items: [conv1] }, - }); - (fetchConversation as ReturnType).mockResolvedValue({ - data: { - messages: [], - pagination: { total_items: 0 } - }, - }); - - renderPage(); - await waitFor(() => screen.getByTestId("mark-unread")); - await userEvent.click(screen.getByTestId("mark-unread")); - }); - - // ── Error handling ───────────────────────────────────────────────────────── - - it("shows error message when fetchConversations fails", async () => { - (fetchConversations as ReturnType).mockRejectedValue( - new Error("Network error") - ); - renderPage(); - await waitFor(() => - expect( - screen.getByText(/could not load conversations/i) - ).toBeInTheDocument() - ); - }); - - it("shows error message when fetchConversation fails", async () => { - const conv1 = makeConv("1", "p1"); - (fetchConversations as ReturnType).mockResolvedValue({ - data: { items: [conv1] }, - }); - (fetchConversation as ReturnType).mockRejectedValue( - new Error("Not found") - ); - renderPage(); - await waitFor(() => - expect(screen.getByText(/could not load messages/i)).toBeInTheDocument() - ); - }); - - // ── data-test attribute ──────────────────────────────────────────────────── - - it("renders the page with correct data-test", () => { - (fetchConversations as ReturnType).mockResolvedValue({ - data: { items: [] }, - }); - renderPage(); - expect(screen.getByTestId("message-id-page")).toBeInTheDocument(); - }); -}); \ No newline at end of file diff --git a/src/components/MessagingComponents/tests/MessagesPage.test.tsx b/src/components/MessagingComponents/tests/MessagesPage.test.tsx deleted file mode 100644 index 54e030db..00000000 --- a/src/components/MessagingComponents/tests/MessagesPage.test.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; -import { MemoryRouter } from "react-router-dom"; -import MessagesPage from "@/pages/social/messages/MessagesPage"; - -// Use alias paths so vitest intercepts the same module instance the component loads. -// Relative paths from the test file ("/tests/") don't match what the component -// imports from its own directory, so the real module loads instead of the mock. - -vi.mock("@/services/api/messaging/conversationApi", () => ({ - fetchConversations: vi.fn(), -})); - -vi.mock("@/components/UI/Spinner", () => ({ - // data-test not data-testid — project testIdAttribute is 'data-test' - default: () =>
Loading...
, -})); - -vi.mock("@/pages/social/messages/emptyMessagesPage", () => ({ - // data-test not data-testid - default: () =>
Empty Page
, -})); - -// Stable navigate reference declared before vi.mock so the hoisted factory -// captures the real function, not undefined. -const mockNavigate = vi.fn(); -vi.mock("react-router-dom", async () => { - const actual = await vi.importActual("react-router-dom"); - return { ...actual, useNavigate: () => mockNavigate }; -}); - -import { fetchConversations } from "@/services/api/messaging/conversationApi"; - -const renderPage = () => - render( - - - - ); - -describe("MessagesPage", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - // ── Loading state ────────────────────────────────────────────────────────── - - it("shows spinner while loading", () => { - (fetchConversations as ReturnType).mockImplementation( - () => new Promise(() => {}) - ); - renderPage(); - expect(screen.getByTestId("spinner")).toBeInTheDocument(); - }); - - // ── Empty state ──────────────────────────────────────────────────────────── - - it("renders EmptyMessagesPage when no conversations", async () => { - (fetchConversations as ReturnType).mockResolvedValue({ - data: { items: [] }, - }); - renderPage(); - await waitFor(() => - expect(screen.getByTestId("empty-page")).toBeInTheDocument() - ); - }); - - it("does not navigate when no conversations", async () => { - (fetchConversations as ReturnType).mockResolvedValue({ - data: { items: [] }, - }); - renderPage(); - await waitFor(() => screen.getByTestId("empty-page")); - expect(mockNavigate).not.toHaveBeenCalled(); - }); - - // ── Redirect ─────────────────────────────────────────────────────────────── - - it("navigates to the first conversation when conversations exist", async () => { - (fetchConversations as ReturnType).mockResolvedValue({ - data: { - items: [ - { id: "conv-1", participant: { id: "user-1" } }, - { id: "conv-2", participant: { id: "user-2" } }, - ], - }, - }); - renderPage(); - await waitFor(() => - expect(mockNavigate).toHaveBeenCalledWith("/messages/conv-1", { - replace: true, - }) - ); - }); - - // ── Error state ──────────────────────────────────────────────────────────── - - it("shows error message when fetch fails with axios error", async () => { - const axiosError = { - isAxiosError: true, - response: { data: { message: "Unauthorized" } }, - }; - vi.doMock("axios", () => ({ - default: { isAxiosError: (e: unknown) => e === axiosError }, - isAxiosError: (e: unknown) => e === axiosError, - })); - - (fetchConversations as ReturnType).mockRejectedValue( - axiosError - ); - renderPage(); - await waitFor(() => - expect(screen.getByText(/unauthorized/i)).toBeInTheDocument() - ); - }); - - it("shows generic error when fetch fails with non-axios error", async () => { - (fetchConversations as ReturnType).mockRejectedValue( - new Error("Network error") - ); - renderPage(); - await waitFor(() => - expect( - screen.getByText(/an unexpected error occurred/i) - ).toBeInTheDocument() - ); - }); - - it("calls fetchConversations with page and limit", async () => { - (fetchConversations as ReturnType).mockResolvedValue({ - data: { items: [] }, - }); - renderPage(); - await waitFor(() => screen.getByTestId("empty-page")); - expect(fetchConversations).toHaveBeenCalledWith(1, 20); - }); -}); \ No newline at end of file diff --git a/src/components/MessagingComponents/tests/MessagingHeader.test.tsx b/src/components/MessagingComponents/tests/MessagingHeader.test.tsx index bbe52556..cfcf7d28 100644 --- a/src/components/MessagingComponents/tests/MessagingHeader.test.tsx +++ b/src/components/MessagingComponents/tests/MessagingHeader.test.tsx @@ -1,77 +1,86 @@ -import { describe, it, expect, vi } from "vitest"; -import { render, screen, configure } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { MemoryRouter } from "react-router-dom"; -import MessagingHeader from "../MessagingHeader"; +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' -configure({ testIdAttribute: "data-testid" }); - -vi.mock("@/components/MessagingComponents/Modal", () => ({ - Modal: ({ isOpen, children, onClose }: any) => +vi.mock('@/components/MessagingComponents/Modal', () => ({ + Modal: ({ isOpen, onClose, children }: { isOpen: boolean; onClose: () => void; children: React.ReactNode }) => isOpen ? ( -
+
+ {children} -
) : null, -})); +})) -vi.mock("@/pages/social/messages/ModalNewMessageBody", () => ({ - default: ({ onClose }: any) => ( -
- +vi.mock('@/pages/social/messages/ModalNewMessageBody', () => ({ + default: ({ onClose }: { onClose: () => void }) => ( +
+
), -})); +})) + +import MessagingHeader from '../MessagingHeader' + +describe('MessagingHeader', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('rendering', () => { + it('renders the messaging-header container', () => { + render() + expect(screen.getByTestId('messaging-header')).toBeInTheDocument() + }) -const renderHeader = () => - render( - - - - ); + it('renders the Messages title', () => { + render() + expect(screen.getByText('Messages')).toBeInTheDocument() + }) -describe("MessagingHeader", () => { - it("renders the Messages heading", () => { - renderHeader(); - expect( - screen.getByRole("heading", { name: /messages/i }) - ).toBeInTheDocument(); - }); + it('renders the New button', () => { + render() + expect(screen.getByTestId('new-message-button')).toBeInTheDocument() + }) - it("renders the New button", () => { - renderHeader(); - expect(screen.getByRole("button", { name: /new/i })).toBeInTheDocument(); - }); + it('does not show modal initially', () => { + render() + expect(screen.queryByTestId('modal-backdrop')).not.toBeInTheDocument() + }) + }) - it("does not show modal initially", () => { - renderHeader(); - expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); - }); + describe('interactions', () => { + it('opens the modal when New button is clicked', async () => { + render() + await userEvent.click(screen.getByTestId('new-message-button')) + expect(screen.getByTestId('modal-backdrop')).toBeInTheDocument() + }) - it("opens the modal when New is clicked", async () => { - renderHeader(); - await userEvent.click(screen.getByRole("button", { name: /new/i })); - expect(await screen.findByTestId("modal")).toBeInTheDocument(); - }); + it('renders ModalNewMessageBody inside the modal', async () => { + render() + await userEvent.click(screen.getByTestId('new-message-button')) + expect(screen.getByTestId('modal-new-message-body')).toBeInTheDocument() + }) - it("renders ModalNewMessageBody inside the modal", async () => { - renderHeader(); - await userEvent.click(screen.getByRole("button", { name: /new/i })); - expect(await screen.findByTestId("new-message-body")).toBeInTheDocument(); - }); + it('closes the modal when modal close button is clicked', async () => { + render() + await userEvent.click(screen.getByTestId('new-message-button')) + expect(screen.getByTestId('modal-backdrop')).toBeInTheDocument() + await userEvent.click(screen.getByTestId('modal-close-button')) + expect(screen.queryByTestId('modal-backdrop')).not.toBeInTheDocument() + }) - it("closes the modal when onClose is called from Modal", async () => { - renderHeader(); - await userEvent.click(screen.getByRole("button", { name: /new/i })); - await userEvent.click(await screen.findByText("Close modal")); - expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); - }); + it('closes the modal when onClose is called from ModalNewMessageBody', async () => { + render() + await userEvent.click(screen.getByTestId('new-message-button')) + await userEvent.click(screen.getByText('Close Modal')) + expect(screen.queryByTestId('modal-backdrop')).not.toBeInTheDocument() + }) - it("closes the modal when onClose is called from ModalNewMessageBody", async () => { - renderHeader(); - await userEvent.click(screen.getByRole("button", { name: /new/i })); - await userEvent.click(await screen.findByText("Close body")); - expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); - }); -}); \ No newline at end of file + it('accepts optional onConversationCreated prop without crashing', () => { + const onCreated = vi.fn() + render() + expect(screen.getByTestId('messaging-header')).toBeInTheDocument() + }) + }) +}) diff --git a/src/components/MessagingComponents/tests/MiniPlayer.test.tsx b/src/components/MessagingComponents/tests/MiniPlayer.test.tsx new file mode 100644 index 00000000..9cf2a502 --- /dev/null +++ b/src/components/MessagingComponents/tests/MiniPlayer.test.tsx @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +vi.mock('@/components/UI/CoverImage', () => ({ + default: ({ src, alt, className }: { src: string | null; alt: string; className?: string }) => ( + {alt} + ), +})) + +import MiniPlayer from '../MiniPlayer' + +describe('MiniPlayer', () => { + const defaultProps = { + coverImage: 'https://example.com/cover.jpg', + trackName: 'My Track', + artistName: 'My Artist', + onClose: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('rendering', () => { + it('renders the mini-player container', () => { + render() + expect(screen.getByTestId('mini-player')).toBeInTheDocument() + }) + + it('renders the track name', () => { + render() + expect(screen.getByText('Cool Song')).toBeInTheDocument() + }) + + it('renders the artist name', () => { + render() + expect(screen.getByText('Cool Artist')).toBeInTheDocument() + }) + + it('renders the close button', () => { + render() + expect(screen.getByTestId('mini-player-close')).toBeInTheDocument() + }) + + it('renders CoverImage with correct src', () => { + render() + expect(screen.getByTestId('cover-image')).toHaveAttribute('src', 'https://img.com/art.jpg') + }) + + it('renders CoverImage without crashing when coverImage is null', () => { + render() + expect(screen.getByTestId('cover-image')).toBeInTheDocument() + }) + + it('renders CoverImage with correct alt text', () => { + render() + expect(screen.getByTestId('cover-image')).toHaveAttribute('alt', 'My Song') + }) + + it('renders the dot separator between track name and artist', () => { + render() + expect(screen.getByText('·')).toBeInTheDocument() + }) + }) + + describe('interactions', () => { + it('calls onClose when close button is clicked', async () => { + const onClose = vi.fn() + render() + await userEvent.click(screen.getByTestId('mini-player-close')) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('calls onClose exactly once per click', async () => { + const onClose = vi.fn() + render() + await userEvent.click(screen.getByTestId('mini-player-close')) + await userEvent.click(screen.getByTestId('mini-player-close')) + expect(onClose).toHaveBeenCalledTimes(2) + }) + }) + + describe('edge cases', () => { + it('renders with empty strings for track and artist names', () => { + render() + expect(screen.getByTestId('mini-player')).toBeInTheDocument() + }) + + it('renders long track names without crashing', () => { + render() + expect(screen.getByTestId('mini-player')).toBeInTheDocument() + }) + }) +}) diff --git a/src/components/MessagingComponents/tests/Modal.test.tsx b/src/components/MessagingComponents/tests/Modal.test.tsx index fdc67201..0dc86572 100644 --- a/src/components/MessagingComponents/tests/Modal.test.tsx +++ b/src/components/MessagingComponents/tests/Modal.test.tsx @@ -1,115 +1,111 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, fireEvent, act } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { Modal } from "../Modal"; +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Modal } from '../Modal' -const renderModal = (props: Partial> = {}) => - render( - -

Modal content

-
- ); - -describe("Modal", () => { +describe('Modal', () => { beforeEach(() => { - vi.clearAllMocks(); - }); - - // ── Rendering ────────────────────────────────────────────────────────────── - - it("renders the backdrop when isOpen is true", () => { - renderModal(); - expect(screen.getByTestId("modal-backdrop")).toBeInTheDocument(); - }); - - it("does not render when isOpen is false", () => { - renderModal({ isOpen: false }); - expect(screen.queryByTestId("modal-backdrop")).not.toBeInTheDocument(); - }); - - it("renders children inside the modal", () => { - renderModal(); - expect(screen.getByText("Modal content")).toBeInTheDocument(); - }); - - it("renders the close button", () => { - renderModal(); - expect(screen.getByTestId("modal-close-button")).toBeInTheDocument(); - }); - - // ── Closing behaviour ────────────────────────────────────────────────────── - - it("calls onClose when the close button is clicked", async () => { - const user = userEvent.setup(); - const onClose = vi.fn(); - renderModal({ onClose }); - await user.click(screen.getByTestId("modal-close-button")); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("calls onClose when the backdrop is clicked", async () => { - const user = userEvent.setup(); - const onClose = vi.fn(); - renderModal({ onClose }); - await user.click(screen.getByTestId("modal-backdrop")); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("does not call onClose when the modal content area is clicked", async () => { - const user = userEvent.setup(); - const onClose = vi.fn(); - renderModal({ onClose }); - await user.click(screen.getByText("Modal content")); - expect(onClose).not.toHaveBeenCalled(); - }); - - // ── Escape key ───────────────────────────────────────────────────────────── - - it("calls onClose when Escape is pressed and modal is open", async () => { - const user = userEvent.setup(); - const onClose = vi.fn(); - renderModal({ isOpen: true, onClose }); - await user.keyboard("{Escape}"); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("does not call onClose when Escape is pressed and modal is closed", async () => { - const user = userEvent.setup(); - const onClose = vi.fn(); - renderModal({ isOpen: false, onClose }); - await user.keyboard("{Escape}"); - expect(onClose).not.toHaveBeenCalled(); - }); - - it("does not call onClose when a non-Escape key is pressed", async () => { - const user = userEvent.setup(); - const onClose = vi.fn(); - renderModal({ isOpen: true, onClose }); - await user.keyboard("{Enter}"); - expect(onClose).not.toHaveBeenCalled(); - }); - - // ── Event listener lifecycle ─────────────────────────────────────────────── - - it("removes the keydown listener when modal closes", () => { - const removeEventListener = vi.spyOn(document, "removeEventListener"); - const { rerender } = renderModal({ isOpen: true }); - rerender( - -

Modal content

-
- ); - expect(removeEventListener).toHaveBeenCalledWith("keydown", expect.any(Function)); - }); - - it("removes the keydown listener on unmount", () => { - const removeEventListener = vi.spyOn(document, "removeEventListener"); - const { unmount } = renderModal({ isOpen: true }); - unmount(); - expect(removeEventListener).toHaveBeenCalledWith("keydown", expect.any(Function)); - }); -}); \ No newline at end of file + vi.clearAllMocks() + }) + + describe('rendering', () => { + it('renders nothing when isOpen is false', () => { + render(
Content
) + expect(screen.queryByTestId('modal-backdrop')).not.toBeInTheDocument() + }) + + it('renders the backdrop when isOpen is true', () => { + render(
Content
) + expect(screen.getByTestId('modal-backdrop')).toBeInTheDocument() + }) + + it('renders children when open', () => { + render(
Hello
) + expect(screen.getByTestId('child')).toBeInTheDocument() + }) + + it('renders the close button when open', () => { + render(
Content
) + expect(screen.getByTestId('modal-close-button')).toBeInTheDocument() + }) + + it('renders into document.body via portal', () => { + render(
Content
) + expect(document.body.contains(screen.getByTestId('modal-backdrop'))).toBe(true) + }) + }) + + describe('interactions', () => { + it('calls onClose when close button is clicked', async () => { + const onClose = vi.fn() + render(
Content
) + await userEvent.click(screen.getByTestId('modal-close-button')) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('calls onClose when Escape key is pressed', () => { + const onClose = vi.fn() + render(
Content
) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('does not call onClose on other key presses', () => { + const onClose = vi.fn() + render(
Content
) + fireEvent.keyDown(document, { key: 'Enter' }) + expect(onClose).not.toHaveBeenCalled() + }) + + it('does not attach keydown listener when isOpen is false', () => { + const onClose = vi.fn() + render(
Content
) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(onClose).not.toHaveBeenCalled() + }) + + it('removes keydown listener when closed', () => { + const onClose = vi.fn() + const { rerender } = render(
Content
) + rerender(
Content
) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(onClose).not.toHaveBeenCalled() + }) + + it('backdrop click stops propagation (does not bubble to parent)', () => { + const outerClick = vi.fn() + render( +
+
Content
+
+ ) + fireEvent.click(screen.getByTestId('modal-backdrop')) + expect(outerClick).not.toHaveBeenCalled() + }) + + it('clicking the backdrop does not close the modal', () => { + const onClose = vi.fn() + render(
Content
) + fireEvent.click(screen.getByTestId('modal-backdrop')) + expect(onClose).not.toHaveBeenCalled() + }) + }) + + describe('lifecycle', () => { + it('re-renders correctly when isOpen changes from false to true', () => { + const onClose = vi.fn() + const { rerender } = render(
Content
) + expect(screen.queryByTestId('modal-backdrop')).not.toBeInTheDocument() + rerender(
Content
) + expect(screen.getByTestId('modal-backdrop')).toBeInTheDocument() + }) + + it('re-renders correctly when isOpen changes from true to false', () => { + const onClose = vi.fn() + const { rerender } = render(
Content
) + expect(screen.getByTestId('modal-backdrop')).toBeInTheDocument() + rerender(
Content
) + expect(screen.queryByTestId('modal-backdrop')).not.toBeInTheDocument() + }) + }) +}) diff --git a/src/components/MessagingComponents/tests/ModalNewMessageBody.test.tsx b/src/components/MessagingComponents/tests/ModalNewMessageBody.test.tsx deleted file mode 100644 index d1152c2e..00000000 --- a/src/components/MessagingComponents/tests/ModalNewMessageBody.test.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { MemoryRouter } from "react-router-dom"; -import ModalNewMessageBody from "@/pages/social/messages/ModalNewMessageBody"; - -// ── Module mocks ─────────────────────────────────────────────────────────────── -// -// NOTE: this project sets testIdAttribute: "data-test" in its Testing Library -// config, so getByTestId() queries for data-test (not the default data-testid). -// All attributes in the mocks below use data-test to match that convention. - -vi.mock("@/services/api/messaging/conversationApi", () => ({ - startConversation: vi.fn(), - sendMessage: vi.fn(), -})); - -vi.mock("@/components/MessagingComponents/MessageBox", () => ({ - MessageBox: ({ - onValueChange, - onSubmit, - hasError, - }: { - onValueChange: (v: string) => void; - onIsEmptyChange: (empty: boolean) => void; - onEmbedsResolved: (embeds: unknown[]) => void; - onSubmit?: () => void; - hasError?: boolean; - }) => ( -
-