diff --git a/src/components/MessagingComponents/messagecell.tsx b/src/components/MessagingComponents/messagecell.tsx index 31ab4c22..4f54f1d2 100644 --- a/src/components/MessagingComponents/messagecell.tsx +++ b/src/components/MessagingComponents/messagecell.tsx @@ -60,21 +60,26 @@ function EmbedCard({ const [failed, setFailed] = useState(false); useEffect(() => { - // Map preloaded resource (optimistic send) — no network call needed - if (preloaded) { - if (isApiTrack(preloaded)) { - setTrack(mapTrack(preloaded)); - } else { - setPlaylist(mapPlaylist(preloaded)); - } - setLoading(false); - return; - } - - // Lazy-load from server (messages loaded from history) let cancelled = false; const load = async () => { + // For tracks: use preloaded data optimistically only if stream_url is present. + // When sent via the picker, stream_url is null on the synthetic embed, so we + // must fetch the real resource to get a playable audioUrl. + if (preloaded && embedType === 'track' && isApiTrack(preloaded)) { + if (preloaded.stream_url) { + // Full data available — no network call needed + setTrack(mapTrack(preloaded)); + setLoading(false); + return; + } + // stream_url missing (picker-built synthetic embed) — fall through to fetch + } + + // For playlists: preloaded data from the picker won't have track stream_urls + // either, so always fetch fresh to get fully playable track data. + // (We still use preloaded title/cover for an instant partial render below.) + try { if (embedType === 'track') { const res = await fetchTrack(embedId); @@ -82,25 +87,24 @@ function EmbedCard({ } else if (embedType === 'playlist') { const res = await fetchPlaylist(embedId); const pl = mapPlaylist(res.data); - - // Explicitly fetch all tracks to ensure we have full data (waveform, audioUrl, etc.) + + // Fetch individual tracks so waveform + audioUrl are fully populated if (pl.tracks && pl.tracks.length > 0) { try { const fetchPromises = pl.tracks.map(async (t) => { const trackId = t.id; - if (trackId && trackId !== "undefined" && trackId !== "") { + if (trackId && trackId !== 'undefined' && trackId !== '') { const trackRes = await fetchTrack(trackId); return mapTrack(trackRes.data); } return t; }); - pl.tracks = await Promise.all(fetchPromises); } catch (err) { - console.error("Failed to fetch tracks for embedded playlist:", err); + console.error('Failed to fetch tracks for embedded playlist:', err); } } - + if (!cancelled) setPlaylist(pl); } } catch { @@ -111,10 +115,41 @@ function EmbedCard({ }; load(); - return () => { cancelled = true; }; + return () => { + cancelled = true; + }; }, [embedType, embedId, preloaded]); - if (loading) return ; + // While the real data is loading, show the preloaded title/cover instantly + // so the user sees something right away instead of a spinner. + if (loading) { + if (preloaded && embedType === 'track' && isApiTrack(preloaded)) { + // Render a lightweight placeholder with the known title + cover + return ( +
+ {preloaded.cover_image ? ( + {preloaded.title + ) : ( +
+ )} +
+ + {preloaded.title ?? 'Track'} + + + {preloaded.artist_name ?? ''} + +
+
+
+ ); + } + return ; + } if (failed) { return ( diff --git a/src/components/MessagingComponents/tests/BlockModal.test.tsx b/src/components/MessagingComponents/tests/BlockModal.test.tsx index 407c5644..87c62067 100644 --- a/src/components/MessagingComponents/tests/BlockModal.test.tsx +++ b/src/components/MessagingComponents/tests/BlockModal.test.tsx @@ -1,290 +1,180 @@ -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"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import { describe, it, expect, vi, beforeEach } from "vitest" +import { BlockUserModal } from "@/components/UI/BlockModal" +import * as conversationApi from "@/services/api/messaging/conversationApi" -// ✅ Ensure data-test works with getByTestId -configure({ testIdAttribute: "data-test" }); - -// ✅ Mock API -vi.mock("@/services/api/messaging/conversationApi", () => ({ +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( - - ); +})) + +vi.mock("../MessagingComponents/CheckBox", () => ({ + default: ({ + label, + checked, + onChange, + }: { + label: string + checked: boolean + onChange: (v: boolean) => void + }) => ( + + ), +})) + +const mockBlockUser = vi.mocked(conversationApi.blockUser) +const mockSubmitReport = vi.mocked(conversationApi.submitReport) + +const defaultProps = { + username: "testuser", + userId: "user-123", + onClose: vi.fn(), + onBlocked: vi.fn(), +} + +beforeEach(() => { + vi.clearAllMocks() +}) 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")); - + it("renders modal with username", () => { + render() + expect(screen.getByTestId("block-user-modal")).toBeInTheDocument() + expect(screen.getByText("Block testuser")).toBeInTheDocument() + }) + + it("renders all blocking consequence list items", () => { + render() + expect(screen.getByText("follow you,")).toBeInTheDocument() + expect(screen.getByText("like your tracks,")).toBeInTheDocument() + expect(screen.getByText("repost your tracks,")).toBeInTheDocument() + expect(screen.getByText("send you messages,")).toBeInTheDocument() + expect(screen.getByText("share tracks with you,")).toBeInTheDocument() + expect(screen.getByText("post new comments on your tracks, or")).toBeInTheDocument() + expect(screen.getByText("send you new stream or email notifications.")).toBeInTheDocument() + }) + + it("renders checkboxes with correct labels", () => { + render() expect( - screen.queryByText(/missing or invalid access token/i) - ).not.toBeInTheDocument(); - }); - - it("does not show error initially", () => { - renderBlock(); + screen.getByText( + "Also permanently remove this user's comments, reposts and likes of your tracks and playlists" + ) + ).toBeInTheDocument() 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); - }); -}); + screen.getByText("Also report testuser for spam") + ).toBeInTheDocument() + }) + + it("renders cancel and block buttons", () => { + render() + expect(screen.getByTestId("block-cancel-button")).toBeInTheDocument() + expect(screen.getByTestId("block-user-button")).toBeInTheDocument() + expect(screen.getByTestId("block-user-button")).toHaveTextContent("Block testuser") + }) + + it("calls onClose when cancel is clicked", () => { + render() + fireEvent.click(screen.getByTestId("block-cancel-button")) + expect(defaultProps.onClose).toHaveBeenCalledTimes(1) + }) + + it("calls blockUser with userId and then onBlocked/onClose on success (data shape)", async () => { + const blockData = { blocker_id: "me", blocked_id: "user-123", created_at: "2024-01-01" } + mockBlockUser.mockResolvedValueOnce({ data: blockData } as any) + + render() + fireEvent.click(screen.getByTestId("block-user-button")) + + await waitFor(() => { + expect(mockBlockUser).toHaveBeenCalledWith("user-123") + expect(defaultProps.onBlocked).toHaveBeenCalledWith(blockData) + expect(defaultProps.onClose).toHaveBeenCalled() + }) + }) + + it("calls onBlocked with fallback shape when response has no data field", async () => { + mockBlockUser.mockResolvedValueOnce({} as any) + + render() + fireEvent.click(screen.getByTestId("block-user-button")) + + await waitFor(() => { + expect(defaultProps.onBlocked).toHaveBeenCalledWith({ + blocker_id: "", + blocked_id: "user-123", + created_at: "", + }) + }) + }) + + it("also calls submitReport when reportSpam checkbox is checked", async () => { + mockBlockUser.mockResolvedValueOnce({} as any) + mockSubmitReport.mockResolvedValueOnce(undefined as any) + + render() + + const checkboxes = screen.getAllByRole("checkbox") + // second checkbox is "report spam" + fireEvent.click(checkboxes[1]) + fireEvent.click(screen.getByTestId("block-user-button")) + + await waitFor(() => { + expect(mockSubmitReport).toHaveBeenCalledWith({ + resource_type: "user", + resource_id: "user-123", + reason: "spam", + }) + }) + }) + + it("does NOT call submitReport when reportSpam checkbox is unchecked", async () => { + mockBlockUser.mockResolvedValueOnce({} as any) + + render() + fireEvent.click(screen.getByTestId("block-user-button")) + + await waitFor(() => { + expect(mockSubmitReport).not.toHaveBeenCalled() + }) + }) + + it("shows 401 error message when blockUser throws 401", async () => { + mockBlockUser.mockRejectedValueOnce({ response: { status: 401 } }) + + render() + fireEvent.click(screen.getByTestId("block-user-button")) + + await waitFor(() => { + expect(screen.getByText("Missing or invalid access token.")).toBeInTheDocument() + }) + }) + + it("disables buttons while loading", async () => { + let resolve: (v: any) => void + mockBlockUser.mockReturnValueOnce(new Promise((r) => { resolve = r })) + + render() + fireEvent.click(screen.getByTestId("block-user-button")) + + expect(screen.getByTestId("block-cancel-button")).toBeDisabled() + expect(screen.getByTestId("block-user-button")).toBeDisabled() + + resolve!({}) + }) + + it("does nothing if userId is missing", async () => { + render() + fireEvent.click(screen.getByTestId("block-user-button")) + await waitFor(() => { + expect(mockBlockUser).not.toHaveBeenCalled() + }) + }) +}) 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..3c66e0e6 100644 --- a/src/components/MessagingComponents/tests/ConversationHeader.test.tsx +++ b/src/components/MessagingComponents/tests/ConversationHeader.test.tsx @@ -1,590 +1,409 @@ -// 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('../UI/BlockModal', () => ({ - BlockUserModal: ({ userId, username, onClose, onBlocked }: any) => ( -
- - -
- ) +vi.mock('../Modal', () => ({ + Modal: ({ isOpen, onClose, children }: { isOpen: boolean; onClose: () => void; children: React.ReactNode }) => + isOpen ?
{children}
: null, })) -vi.mock('../UI/ReportModal', () => ({ - ReportModal: ({ userId, username, onClose, onSpamSelected }: any) => ( -
- - +vi.mock('@/components/UI/BlockModal', () => ({ + BlockUserModal: ({ + userId, + username, + onClose, + onBlocked, + }: { + userId: string + username: string + onClose: () => void + onBlocked: () => void + }) => ( +
+ +
- ) + ), })) -vi.mock('../UI/SpamModal', () => ({ - SpamModal: ({ userId, username, onClose }: any) => ( -
- +vi.mock('@/components/UI/ReportModal', () => ({ + ReportModal: ({ + userId, + username, + onClose, + onSpamSelected, + }: { + userId: string + username: string + onClose: () => void + onSpamSelected: () => void + }) => ( +
+ +
- ) + ), })) -vi.mock('@/components/UI/Tooltip', () => ({ - default: ({ children, text }: any) => ( -
- {children} +vi.mock('@/components/UI/SpamModal', () => ({ + SpamModal: ({ + userId, + username, + onClose, + }: { + userId: string + username: string + onClose: () => void + }) => ( +
+
- ) + ), })) -vi.mock('@/services/api/messaging/conversationApi', () => ({ - markMessageReadState: vi.fn(), - unblockUser: vi.fn(), - fetchFollowStatus: vi.fn() +vi.mock('@/components/UI/Tooltip', () => ({ + default: ({ children }: { children: React.ReactNode }) => <>{children}, })) -const mockNavigate = vi.fn() +import ConversationHeader from '../ConversationHeader' -describe('ConversationHeader', () => { - const defaultProps = { - reciepiantId: 'user123', - conversationId: 'conv456', - recipientName: 'john_doe', - lastMessageId: 'msg789', - onReadStateChange: vi.fn(), - onDeleted: vi.fn(), - onBack: 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', () => { 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() - }) - }) - - it('should render back button when onBack is provided', async () => { - renderWithRouter({ onBack: vi.fn() }) - - await waitFor(() => { - expect(screen.getByTestId('conversation-back-button')).toBeInTheDocument() - }) - }) - - 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('should render delete conversation button', async () => { - renderWithRouter() - - await waitFor(() => { - expect(screen.getByTestId('delete-conversation-button')).toBeInTheDocument() - }) - }) - - it('should render block button on desktop', async () => { - renderWithRouter() - - await waitFor(() => { - expect(screen.getByTestId('conversation-block-button')).toBeInTheDocument() - }) - }) - - it('should render report button on desktop', async () => { - renderWithRouter() - - await waitFor(() => { - expect(screen.getByTestId('conversation-report-button')).toBeInTheDocument() - }) - }) - - it('should render mark as read button on desktop', async () => { - renderWithRouter() - - await waitFor(() => { - expect(screen.getByTestId('conversation-toggle-read-button')).toBeInTheDocument() - }) - }) - - it('should render mobile menu button', async () => { - renderWithRouter() - - await waitFor(() => { - expect(screen.getByTestId('conversation-mobile-menu-btn')).toBeInTheDocument() - }) - }) - - it('should show loading state while fetching block status', async () => { - vi.mocked(conversationApi.fetchFollowStatus).mockImplementation( - () => new Promise(() => {}) - ) - - renderWithRouter() - - await waitFor(() => { - expect(screen.getByText('...')).toBeInTheDocument() - }) + describe('rendering', () => { + it('renders the header container', async () => { + renderHeader() + expect(screen.getByTestId('conversation-header')).toBeInTheDocument() + }) + + it('renders the recipient name button', async () => { + renderHeader() + expect(screen.getByTestId('conversation-profile-button')).toHaveTextContent('John') + }) + + it('renders the back button when onBack is provided', async () => { + renderHeader() + expect(screen.getByTestId('conversation-back-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('renders the delete button', async () => { + renderHeader() + expect(screen.getByTestId('delete-conversation-button')).toBeInTheDocument() + }) + + it('renders mobile menu button', async () => { + renderHeader() + expect(screen.getByTestId('conversation-mobile-menu-btn')).toBeInTheDocument() + }) + + it('initially shows "Mark as unread" on desktop button', async () => { + renderHeader() + expect(screen.getByTestId('conversation-toggle-read-button')).toHaveTextContent('Mark as unread') + }) + + it('loads block status on mount', async () => { + renderHeader() + await waitFor(() => expect(mockFetchFollowStatus).toHaveBeenCalledWith('user-2')) + }) + + 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() - }) - }) - - 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('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('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('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() - }) - }) + 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)) }) - }) - 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() - }) - }) - - 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('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('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')) }) - }) - 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) - }) - }) - }) - - 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('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('disables toggle read button when lastMessageId is null', async () => { + renderHeader({ lastMessageId: null }) + expect(screen.getByTestId('conversation-toggle-read-button')).toBeDisabled() }) - }) - 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() - }) - }) - - 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('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('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('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('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('Tooltips', () => { - it('should render tooltips for desktop buttons', async () => { - renderWithRouter() - - await waitFor(() => { - const tooltips = screen.getAllByTestId('tooltip') - expect(tooltips.length).toBeGreaterThan(0) - }) + 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('passes recipient props to BlockUserModal', 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')).toHaveAttribute('data-user-id', 'user-2') + expect(screen.getByTestId('block-modal')).toHaveAttribute('data-username', 'John') + }) + + it('closes block modal from BlockUserModal onClose', 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-close')) + expect(screen.queryByTestId('block-modal')).not.toBeInTheDocument() + }) + + it('closes block modal from the Modal onClose callback', 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('modal-close')) + expect(screen.queryByTestId('block-modal')).not.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('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('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('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('passes recipient props to ReportModal', async () => { + renderHeader() + await userEvent.click(screen.getByTestId('conversation-report-button')) + expect(screen.getByTestId('report-modal')).toHaveAttribute('data-user-id', 'user-2') + expect(screen.getByTestId('report-modal')).toHaveAttribute('data-username', 'John') + }) + + it('closes report modal from ReportModal onClose', async () => { + renderHeader() + await userEvent.click(screen.getByTestId('conversation-report-button')) + await userEvent.click(screen.getByTestId('report-close')) + expect(screen.queryByTestId('report-modal')).not.toBeInTheDocument() + }) + + it('closes report modal from the Modal onClose callback', async () => { + renderHeader() + await userEvent.click(screen.getByTestId('conversation-report-button')) + await userEvent.click(screen.getByTestId('modal-close')) + expect(screen.queryByTestId('report-modal')).not.toBeInTheDocument() + }) + + 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()) + }) + + it('passes recipient props to SpamModal', 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()) + expect(screen.getByTestId('spam-modal')).toHaveAttribute('data-user-id', 'user-2') + expect(screen.getByTestId('spam-modal')).toHaveAttribute('data-username', 'John') + }) + + it('closes spam modal from SpamModal onClose', 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()) + await userEvent.click(screen.getByTestId('spam-close')) + expect(screen.queryByTestId('spam-modal')).not.toBeInTheDocument() + }) + + it('closes spam modal from the Modal onClose callback', 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()) + await userEvent.click(screen.getByTestId('modal-close')) + expect(screen.queryByTestId('spam-modal')).not.toBeInTheDocument() }) }) - 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('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('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('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('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('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('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') - }) + 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('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() - }) + 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')) }) - it('should show mobile menu on small screens', async () => { - renderWithRouter() - - await waitFor(() => { - expect(screen.getByTestId('conversation-mobile-menu-btn')).toBeVisible() - }) + 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 }) }) -}) \ 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..7cdb0bdc 100644 --- a/src/components/MessagingComponents/tests/DeleteConversationModal.test.tsx +++ b/src/components/MessagingComponents/tests/DeleteConversationModal.test.tsx @@ -1,186 +1,173 @@ -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.click(checkbox) + expect(checkbox).toBeChecked() + 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/EmptyMessagesPage.test.tsx b/src/components/MessagingComponents/tests/EmptyMessagesPage.test.tsx new file mode 100644 index 00000000..6f9f67c4 --- /dev/null +++ b/src/components/MessagingComponents/tests/EmptyMessagesPage.test.tsx @@ -0,0 +1,125 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import MessagesPage from '@/pages/social/messages/emptyMessagesPage' + +// Mock child components +vi.mock('@/components/MessagingComponents/Modal', () => ({ + Modal: ({ isOpen, onClose, children }: { isOpen: boolean; onClose: () => void; children: React.ReactNode }) => + isOpen ? ( +
+ + {children} +
+ ) : null, +})) + +vi.mock('@/components/MessagingComponents/MessagingHeader', () => ({ + default: () =>
, +})) + +vi.mock('./ModalNewMessageBody', () => ({ + default: ({ onClose }: { onClose: () => void }) => ( +
+ +
+ ), +})) + +describe('MessagesPage', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('renders without crashing', () => { + render() + expect(screen.getByTestId('empty-messages-page')).toBeInTheDocument() + }) + + it('renders the MessagingHeader', () => { + render() + expect(screen.getByTestId('messaging-header')).toBeInTheDocument() + }) + + it('renders the empty state container', () => { + render() + expect(screen.getByTestId('empty-messages-page')).toBeInTheDocument() + }) + + it('renders the empty messages title', () => { + render() + expect(screen.getByTestId('empty-messages-title')).toBeInTheDocument() + expect(screen.getByTestId('empty-messages-title')).toHaveTextContent('You have no messages') + }) + + it('renders the write button', () => { + render() + expect(screen.getByTestId('empty-messages-write-button')).toBeInTheDocument() + expect(screen.getByTestId('empty-messages-write-button')).toHaveTextContent('Write one') + }) + + it('renders the descriptive message text', () => { + render() + expect(screen.getByText(/Send someone a message and make their day/i)).toBeInTheDocument() + }) + }) + + describe('Modal initial state', () => { + it('does not show the modal by default', () => { + render() + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + }) + + it('does not render ModalNewMessageBody when modal is closed', () => { + render() + expect(screen.queryByTestId('modal-new-message-body')).not.toBeInTheDocument() + }) + }) + + describe('Modal open behavior', () => { + it('opens the modal when "Write one" button is clicked', () => { + render() + fireEvent.click(screen.getByTestId('empty-messages-write-button')) + expect(screen.getByTestId('modal')).toBeInTheDocument() + }) + + it('renders ModalNewMessageBody inside the modal when open', () => { + render() + fireEvent.click(screen.getByTestId('empty-messages-write-button')) + expect(screen.getByTestId('modal-new-message-body')).toBeInTheDocument() + }) + }) + + describe('Modal close behavior', () => { + it('closes the modal when onClose is called via Modal close button', () => { + render() + fireEvent.click(screen.getByTestId('empty-messages-write-button')) + expect(screen.getByTestId('modal')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('modal-close')) + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + }) + + it('closes the modal when onClose is called via ModalNewMessageBody', () => { + render() + fireEvent.click(screen.getByTestId('empty-messages-write-button')) + expect(screen.getByTestId('modal')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('modal-body-close')) + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + }) + + it('can reopen the modal after closing', () => { + render() + + fireEvent.click(screen.getByTestId('empty-messages-write-button')) + expect(screen.getByTestId('modal')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('modal-close')) + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + + fireEvent.click(screen.getByTestId('empty-messages-write-button')) + expect(screen.getByTestId('modal')).toBeInTheDocument() + }) + }) +}) diff --git a/src/components/MessagingComponents/tests/MessageBox.test.tsx b/src/components/MessagingComponents/tests/MessageBox.test.tsx index 21917e92..5caa3a95 100644 --- a/src/components/MessagingComponents/tests/MessageBox.test.tsx +++ b/src/components/MessagingComponents/tests/MessageBox.test.tsx @@ -1,239 +1,946 @@ -// 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 { act, render, fireEvent } from '@testing-library/react' + +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' + +// Helpers that query by data-test instead of data-testid +const getByTest = (container: HTMLElement, value: string) => { + const el = container.querySelector(`[data-test="${value}"]`) + if (!el) throw new Error(`Unable to find element with data-test="${value}"`) + return el as HTMLElement +} +const getAllByTest = (container: HTMLElement, value: string) => { + const els = container.querySelectorAll(`[data-test="${value}"]`) + if (!els.length) throw new Error(`Unable to find any element with data-test="${value}"`) + return Array.from(els) as HTMLElement[] +} +const queryByTest = (container: HTMLElement, value: string) => + container.querySelector(`[data-test="${value}"]`) as HTMLElement | null + +const runDebounce = async () => { + await act(async () => { + vi.advanceTimersByTime(600) + for (let i = 0; i < 5; i += 1) { + await Promise.resolve() + } + }) +} + +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: '', +} + +beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('MessageBox', () => { + describe('rendering', () => { + it('renders the message-box container', () => { + const { container } = render() + expect(getByTest(container, 'message-box')).toBeInTheDocument() + }) + + it('renders the textarea', () => { + const { container } = render() + expect(getByTest(container, 'message-input')).toBeInTheDocument() + }) + + it('textarea starts empty', () => { + const { container } = render() + expect(getByTest(container, 'message-input')).toHaveValue('') + }) + + it('applies error border class when hasError is true', () => { + const { container } = render() + expect(getByTest(container, 'message-input')).toHaveClass('border-red-500') + }) + + it('applies normal border class when hasError is false', () => { + const { container } = render() + expect(getByTest(container, 'message-input')).not.toHaveClass('border-red-500') + }) + + it('does not render MiniPlayer when no embeds', () => { + const { container } = render() + expect(queryByTest(container, 'mini-player')).not.toBeInTheDocument() + }) + + it('renders MiniPlayer for external track embed', () => { + const externalEmbeds = [{ type: 'track' as const, id: 't1', resource: mockTrack, sourceUrl: '' }] + const { container } = render() + expect(getByTest(container, 'mini-player')).toBeInTheDocument() + }) + + it('renders MiniPlayer for external playlist embed', () => { + const externalEmbeds = [{ type: 'playlist' as const, id: 'pl1', resource: mockPlaylist as any, sourceUrl: '' }] + const { container } = render() + expect(getByTest(container, '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: '' }, + ] + const { container } = render() + expect(getAllByTest(container, 'mini-player')).toHaveLength(2) + }) + }) + + describe('typing', () => { + it('calls onValueChange when user types', () => { + const onValueChange = vi.fn() + const { container } = render() + fireEvent.change(getByTest(container, 'message-input'), { target: { value: 'hello' } }) + expect(onValueChange).toHaveBeenCalledWith('hello') + }) + + it('calls onIsEmptyChange with false when text is entered', () => { + const onIsEmptyChange = vi.fn() + const { container } = render() + fireEvent.change(getByTest(container, 'message-input'), { target: { value: 'hi' } }) + expect(onIsEmptyChange).toHaveBeenCalledWith(false) + }) + + it('calls onIsEmptyChange with true when text is cleared', () => { + const onIsEmptyChange = vi.fn() + const { container } = render() + fireEvent.change(getByTest(container, 'message-input'), { target: { value: 'hi' } }) + fireEvent.change(getByTest(container, 'message-input'), { target: { value: '' } }) + expect(onIsEmptyChange).toHaveBeenCalledWith(true) + }) + + it('calls onEmbedsResolved with empty array when no URL in text', () => { + const onEmbedsResolved = vi.fn() + const { container } = render() + fireEvent.change(getByTest(container, '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() + const { container } = render() + fireEvent.keyDown(getByTest(container, 'message-input'), { key: 'Enter', shiftKey: false }) + expect(onSubmit).toHaveBeenCalledTimes(1) + }) + + it('does not call onSubmit when Shift+Enter is pressed', () => { + const onSubmit = vi.fn() + const { container } = render() + fireEvent.keyDown(getByTest(container, '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 }) + + const { container } = render() + fireEvent.change(getByTest(container, 'message-input'), { + target: { value: 'https://soundcloud.com/artist/track' }, + }) + await runDebounce() + 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 }) + + const { container } = render() + fireEvent.change(getByTest(container, 'message-input'), { + target: { value: 'https://soundcloud.com/artist/track' }, + }) + await runDebounce() + 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 }) + + const { container } = render() + fireEvent.change(getByTest(container, 'message-input'), { + target: { value: 'https://soundcloud.com/artist/sets/playlist' }, + }) + await runDebounce() + expect(mockFetchPlaylist).toHaveBeenCalledWith('pl-1') + }) + + it('ignores user type from resolvePermalink', async () => { + mockResolvePermalink.mockResolvedValue({ data: { type: 'user', id: 'u1' } }) + + const { container } = render() + fireEvent.change(getByTest(container, 'message-input'), { + target: { value: 'https://soundcloud.com/artist' }, + }) + await runDebounce() + expect(mockResolvePermalink).toHaveBeenCalled() + expect(mockFetchTrack).not.toHaveBeenCalled() + }) + + it('handles resolvePermalink failure gracefully', async () => { + mockResolvePermalink.mockRejectedValue(new Error('fail')) + + const { container } = render() + fireEvent.change(getByTest(container, 'message-input'), { + target: { value: 'https://soundcloud.com/artist/track' }, + }) + await runDebounce() + expect(mockResolvePermalink).toHaveBeenCalled() + // no crash; test passes if we get here + }) + }) + + 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: '' }] + const { container } = render() + fireEvent.click(getByTest(container, '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: '' }, + ] + const { container } = render() + const closeBtns = getAllByTest(container, 'mini-player-close') + fireEvent.click(closeBtns[0]) + const lastCall = onEmbedsResolved.mock.calls[onEmbedsResolved.mock.calls.length - 1][0] + expect(lastCall).toHaveLength(1) + expect(lastCall[0].id).toBe('t2') + }) + }) +}) +describe('mergeEmbeds (controlled mode deduplication)', () => { + it('merges new resolved embeds with existing external embeds without duplicates', async () => { + mockResolvePermalink.mockResolvedValue({ data: { type: 'track', id: 'track-1' } }) + mockFetchTrack.mockResolvedValue({ data: mockTrack }) + + const onEmbedsResolved = vi.fn() + // Start with 'track-1' already present as an external embed + const externalEmbeds = [ + { type: 'track' as const, id: 'track-1', resource: mockTrack, sourceUrl: 'https://soundcloud.com/artist/track' }, + ] + const { container } = render( + + ) + + // Type the same URL — resolves to the same embed id + fireEvent.change(getByTest(container, 'message-input'), { + target: { value: 'https://soundcloud.com/artist/track' }, + }) + await runDebounce() + + // onEmbedsResolved should be called with exactly 1 embed (no duplicate) + const lastCall = onEmbedsResolved.mock.calls[onEmbedsResolved.mock.calls.length - 1][0] + expect(lastCall).toHaveLength(1) + expect(lastCall[0].id).toBe('track-1') + }) + }) + + describe('stripEmbedUrls duplicate URL handling', () => { + it('keeps surplus duplicate URLs when there are fewer active embeds', async () => { + mockResolvePermalink.mockResolvedValue({ data: { type: 'track', id: 'track-1' } }) + mockFetchTrack.mockResolvedValue({ data: mockTrack }) + + const url = 'https://soundcloud.com/artist/track' + const onValueChange = vi.fn() + const externalEmbeds = [ + { type: 'track' as const, id: 'track-1', resource: mockTrack, sourceUrl: url }, + ] + const { container } = render( + + ) + fireEvent.change(getByTest(container, 'message-input'), { + target: { value: `${url} hello ${url}` }, + }) + await runDebounce() + + // One active embed removes one URL occurrence; the surplus duplicate remains. + const lastClean = onValueChange.mock.calls[onValueChange.mock.calls.length - 1][0] + expect(lastClean).toBe(`hello ${url}`) + }) + }) + + describe('removeEmbedAt with sourceUrl', () => { + it('removes the embed URL from the textarea value when closing', async () => { + // Pre-wire a URL resolve so the embed has a sourceUrl + mockResolvePermalink.mockResolvedValue({ data: { type: 'track', id: 'track-1' } }) + mockFetchTrack.mockResolvedValue({ data: mockTrack }) + + const url = 'https://soundcloud.com/artist/track' + const onValueChange = vi.fn() + const externalEmbeds = [ + { type: 'track' as const, id: 'track-1', resource: mockTrack, sourceUrl: url }, + ] + const { container } = render( + + ) + fireEvent.change(getByTest(container, 'message-input'), { target: { value: url } }) + await runDebounce() + + // Now close the mini-player + fireEvent.click(getByTest(container, 'mini-player-close')) + + // The URL should no longer appear in the value passed upstream + const lastClean = onValueChange.mock.calls[onValueChange.mock.calls.length - 1][0] + expect(lastClean).not.toContain(url) + }) + + it('calls onIsEmptyChange(true) when last embed is removed and text is empty', async () => { + mockResolvePermalink.mockResolvedValue({ data: { type: 'track', id: 'track-1' } }) + mockFetchTrack.mockResolvedValue({ data: mockTrack }) + + const onIsEmptyChange = vi.fn() + const { container } = render() + + const url = 'https://soundcloud.com/artist/track' + fireEvent.change(getByTest(container, 'message-input'), { target: { value: url } }) + await runDebounce() + + fireEvent.click(getByTest(container, 'mini-player-close')) + + // After removing the only embed with no other text, isEmpty should be true + const calls = onIsEmptyChange.mock.calls + expect(calls[calls.length - 1][0]).toBe(true) + }) + }) + + describe('resolvedUrlsRef cache hit', () => { + it('calls resolvePermalink only once when the same URL is typed twice', async () => { + mockResolvePermalink.mockResolvedValue({ data: { type: 'track', id: 'track-1' } }) + mockFetchTrack.mockResolvedValue({ data: mockTrack }) + + const { container } = render() + const url = 'https://soundcloud.com/artist/track' + + // First type + fireEvent.change(getByTest(container, 'message-input'), { target: { value: url } }) + await runDebounce() + + const firstCallCount = mockResolvePermalink.mock.calls.length + + // Second type with same URL — should hit cache + fireEvent.change(getByTest(container, 'message-input'), { target: { value: url } }) + await runDebounce() + + expect(mockResolvePermalink.mock.calls.length).toBe(firstCallCount) + }) + + it('returns null from cache when a previously failed URL is typed again', async () => { + mockResolvePermalink.mockRejectedValue(new Error('fail')) + + const onEmbedsResolved = vi.fn() + const { container } = render() + const url = 'https://soundcloud.com/artist/track' + + // First attempt — fails and caches null + fireEvent.change(getByTest(container, 'message-input'), { target: { value: url } }) + await runDebounce() + + const firstCallCount = mockResolvePermalink.mock.calls.length + + // Second attempt — should hit the null cache, not call resolvePermalink again + fireEvent.change(getByTest(container, 'message-input'), { target: { value: url } }) + await runDebounce() + + expect(mockResolvePermalink.mock.calls.length).toBe(firstCallCount) + const lastResolved = onEmbedsResolved.mock.calls[onEmbedsResolved.mock.calls.length - 1][0] + expect(lastResolved).toEqual([]) + }) + }) + + describe('playlist track hydration (lines 147–162)', () => { + it('calls fetchTrack for each track in a playlist when tracks have an id field', async () => { + const playlistWithTracks = { + ...mockPlaylist, + track_count: 2, + tracks: [ + { id: 'tr-a', title: 'Track A' }, + { id: 'tr-b', title: 'Track B' }, + ], + } + + mockResolvePermalink.mockResolvedValue({ data: { type: 'playlist', id: 'pl-1' } }) + mockFetchPlaylist.mockResolvedValue({ data: playlistWithTracks }) + mockFetchTrack.mockResolvedValue({ data: mockTrack }) + + const { container } = render() + fireEvent.change(getByTest(container, 'message-input'), { + target: { value: 'https://soundcloud.com/artist/sets/playlist' }, + }) + await runDebounce() + + expect(mockFetchTrack).toHaveBeenCalledWith('tr-a') + expect(mockFetchTrack).toHaveBeenCalledWith('tr-b') + }) + + it('calls fetchTrack using track_id field when id is absent', async () => { + const playlistWithTracks = { + ...mockPlaylist, + tracks: [{ track_id: 'tr-c' }], + } + + mockResolvePermalink.mockResolvedValue({ data: { type: 'playlist', id: 'pl-1' } }) + mockFetchPlaylist.mockResolvedValue({ data: playlistWithTracks }) + mockFetchTrack.mockResolvedValue({ data: mockTrack }) + + const { container } = render() + fireEvent.change(getByTest(container, 'message-input'), { + target: { value: 'https://soundcloud.com/artist/sets/playlist' }, + }) + await runDebounce() + + expect(mockFetchTrack).toHaveBeenCalledWith('tr-c') + }) + + it('skips fetchTrack for tracks with undefined or empty id', async () => { + const playlistWithTracks = { + ...mockPlaylist, + tracks: [{ id: 'undefined' }, { id: '' }, { id: undefined }], + } + + mockResolvePermalink.mockResolvedValue({ data: { type: 'playlist', id: 'pl-1' } }) + mockFetchPlaylist.mockResolvedValue({ data: playlistWithTracks }) + mockFetchTrack.mockResolvedValue({ data: mockTrack }) + + const { container } = render() + fireEvent.change(getByTest(container, 'message-input'), { + target: { value: 'https://soundcloud.com/artist/sets/playlist' }, + }) + await runDebounce() + + expect(mockFetchTrack).not.toHaveBeenCalled() + }) + + it('handles fetchTrack failure inside playlist hydration gracefully', async () => { + const playlistWithTracks = { + ...mockPlaylist, + tracks: [{ id: 'tr-d' }], + } + + mockResolvePermalink.mockResolvedValue({ data: { type: 'playlist', id: 'pl-1' } }) + mockFetchPlaylist.mockResolvedValue({ data: playlistWithTracks }) + mockFetchTrack.mockRejectedValue(new Error('track fetch failed')) + + const { container } = render() + fireEvent.change(getByTest(container, 'message-input'), { + target: { value: 'https://soundcloud.com/artist/sets/playlist' }, + }) + await runDebounce() + + // Should not crash; playlist embed still resolves (with original track stubs) + expect(getByTest(container, 'message-box')).toBeInTheDocument() + }) + + it('handles string track entries in a playlist', async () => { + const playlistWithTracks = { + ...mockPlaylist, + tracks: ['tr-e'], + } + + mockResolvePermalink.mockResolvedValue({ data: { type: 'playlist', id: 'pl-1' } }) + mockFetchPlaylist.mockResolvedValue({ data: playlistWithTracks }) + mockFetchTrack.mockResolvedValue({ data: mockTrack }) + + const { container } = render() + fireEvent.change(getByTest(container, 'message-input'), { + target: { value: 'https://soundcloud.com/artist/sets/playlist' }, + }) + await runDebounce() + + expect(mockFetchTrack).toHaveBeenCalledWith('tr-e') + }) + }) +// ─── Additional tests to close the remaining coverage gaps ─────────────────── +// +// Drop these describe blocks inside the existing describe('MessageBox', ...) +// in MessageBox.test.tsx, after the existing 'embed removal' block. +// +// Gaps targeted: +// Lines 37-44 → mergeEmbeds (controlled mode + URL resolves) +// Line 60 → stripEmbedUrls "return match" branch (more occurrences than embeds) +// Lines 72-77 → removeEmbedAt sourceUrl branch +// Line 90 → setInternalEmbeds inside removeEmbedAt (uncontrolled mode) +// Lines 123-125 → cache hit returning a non-null cached embed +// Lines 147-162 → playlist track hydration (tracks array, track_id, string, skip, error) +// ───────────────────────────────────────────────────────────────────────────── + + // ── Lines 37-44: mergeEmbeds ───────────────────────────────────────────── + // mergeEmbeds is only called when controlled===true (externalEmbeds prop is + // provided) AND a URL resolves successfully after debounce. + describe('mergeEmbeds — controlled mode', () => { + it('deduplicates when the resolved embed id already exists in externalEmbeds', async () => { + mockResolvePermalink.mockResolvedValue({ data: { type: 'track', id: 'track-1' } }) + mockFetchTrack.mockResolvedValue({ data: mockTrack }) + + const onEmbedsResolved = vi.fn() + // Provide the same embed id that the URL will resolve to + const externalEmbeds = [ + { type: 'track' as const, id: 'track-1', resource: mockTrack, sourceUrl: '' }, + ] + const { container } = render( + + ) + + fireEvent.change(getByTest(container, 'message-input'), { + target: { value: 'https://soundcloud.com/artist/track' }, + }) + await runDebounce() + + // mergeEmbeds should deduplicate — still only one embed with id 'track-1' + const lastCall = onEmbedsResolved.mock.calls[onEmbedsResolved.mock.calls.length - 1][0] + expect(lastCall).toHaveLength(1) + expect(lastCall[0].id).toBe('track-1') + }) + + it('adds a newly resolved embed to existing externalEmbeds without losing originals', async () => { + const track2 = { ...mockTrack, id: 'track-2', title: 'Second Track' } + mockResolvePermalink.mockResolvedValue({ data: { type: 'track', id: 'track-2' } }) + mockFetchTrack.mockResolvedValue({ data: track2 }) + + const onEmbedsResolved = vi.fn() + const externalEmbeds = [ + { type: 'track' as const, id: 'track-1', resource: mockTrack, sourceUrl: '' }, + ] + const { container } = render( + + ) + + fireEvent.change(getByTest(container, 'message-input'), { + target: { value: 'https://soundcloud.com/artist/track2' }, + }) + await runDebounce() + + // mergeEmbeds should produce both embeds + const lastCall = onEmbedsResolved.mock.calls[onEmbedsResolved.mock.calls.length - 1][0] + expect(lastCall).toHaveLength(2) + const ids = lastCall.map((e: any) => e.id) + expect(ids).toContain('track-1') + expect(ids).toContain('track-2') + }) + }) + + // ── Line 60: stripEmbedUrls "return match" branch ──────────────────────── + // This branch executes when the URL appears MORE times in the text than + // there are embeds for it. The regex callback fires for every occurrence; + // once `removed >= count` the surplus occurrences are returned unchanged. + describe('stripEmbedUrls surplus-occurrence branch (line 60)', () => { + it('leaves extra URL occurrences in the clean text when they exceed embed count', async () => { + mockResolvePermalink.mockResolvedValue({ data: { type: 'track', id: 'track-1' } }) + mockFetchTrack.mockResolvedValue({ data: mockTrack }) + + const url = 'https://soundcloud.com/artist/track' + const onValueChange = vi.fn() + const externalEmbeds = [ + { type: 'track' as const, id: 'track-1', resource: mockTrack, sourceUrl: url }, + ] + const { container } = render( + + ) + // Three occurrences of the URL but only one embed will be created, + // so stripEmbedUrls removes 1 occurrence and keeps the other 2 → + // the "return match" branch on line 60 fires for occurrences 2 and 3. + fireEvent.change(getByTest(container, 'message-input'), { + target: { value: `${url} ${url} ${url}` }, + }) + await runDebounce() + + const lastClean: string = + onValueChange.mock.calls[onValueChange.mock.calls.length - 1][0] + // One occurrence stripped, two kept → url still present in output + expect(lastClean).toContain(url) + }) + }) + + // ── Lines 72-77: removeEmbedAt sourceUrl branch ────────────────────────── + // Existing remove tests pass externalEmbeds with sourceUrl:'', so the + // `if (embedToRemove.sourceUrl)` guard is never truthy. We need to close + // an embed that actually has a sourceUrl so the regex replacement runs. + describe('removeEmbedAt — sourceUrl branch (lines 72-77)', () => { + it('strips the embed sourceUrl from the textarea value when closing', async () => { + mockResolvePermalink.mockResolvedValue({ data: { type: 'track', id: 'track-1' } }) + mockFetchTrack.mockResolvedValue({ data: mockTrack }) + + const onValueChange = vi.fn() + // Uncontrolled mode so the embed is stored internally with the real sourceUrl + const { container } = render() + + const url = 'https://soundcloud.com/artist/track' + fireEvent.change(getByTest(container, 'message-input'), { target: { value: url } }) + await runDebounce() + + // The mini-player close button is now rendered + fireEvent.click(getByTest(container, 'mini-player-close')) + + // After closing, the URL must have been removed from the value passed upstream + const lastClean: string = + onValueChange.mock.calls[onValueChange.mock.calls.length - 1][0] + expect(lastClean).not.toContain(url) + }) + + it('keeps a second occurrence of the URL when only one embed is removed', async () => { + // Two resolves with different ids so we get two embeds, same URL base + const url = 'https://soundcloud.com/artist/track' + const onValueChange = vi.fn() + const externalEmbeds = [ + { type: 'track' as const, id: 'track-1', resource: mockTrack, sourceUrl: url }, + ] + const { container } = render( + + ) + // Two copies of the same URL in the text → one embed resolved (cache hit for second) + fireEvent.change(getByTest(container, 'message-input'), { + target: { value: `${url} ${url}` }, + }) + // Close the single embed; line 76 ("return match" inside removeEmbedAt) + // fires for the second occurrence and keeps it + fireEvent.click(getByTest(container, 'mini-player-close')) + + const lastClean: string = + onValueChange.mock.calls[onValueChange.mock.calls.length - 1][0] + // Second occurrence should survive + expect(lastClean).toContain(url) + }) + }) + + // ── Line 90: setInternalEmbeds inside removeEmbedAt (uncontrolled) ──────── + // Existing remove tests always pass externalEmbeds (controlled mode), so + // `if (!controlled)` is always false there. We need an uncontrolled removal. + describe('removeEmbedAt — uncontrolled mode (line 90)', () => { + it('removes the mini-player from the DOM when closed in uncontrolled mode', async () => { + mockResolvePermalink.mockResolvedValue({ data: { type: 'track', id: 'track-1' } }) + mockFetchTrack.mockResolvedValue({ data: mockTrack }) + + // No externalEmbeds prop → uncontrolled + const { container } = render() + + fireEvent.change(getByTest(container, 'message-input'), { + target: { value: 'https://soundcloud.com/artist/track' }, + }) + await runDebounce() + + expect(queryByTest(container, 'mini-player')).toBeInTheDocument() + + fireEvent.click(getByTest(container, 'mini-player-close')) + + // setInternalEmbeds([]) causes a re-render with no embeds → no mini-player + expect(queryByTest(container, 'mini-player')).not.toBeInTheDocument() + }) + + it('calls onEmbedsResolved with empty array after closing last embed in uncontrolled mode', async () => { + mockResolvePermalink.mockResolvedValue({ data: { type: 'track', id: 'track-1' } }) + mockFetchTrack.mockResolvedValue({ data: mockTrack }) + + const onEmbedsResolved = vi.fn() + const { container } = render( + + ) + + fireEvent.change(getByTest(container, 'message-input'), { + target: { value: 'https://soundcloud.com/artist/track' }, + }) + await runDebounce() + + fireEvent.click(getByTest(container, 'mini-player-close')) + + const lastCall = + onEmbedsResolved.mock.calls[onEmbedsResolved.mock.calls.length - 1][0] + expect(lastCall).toEqual([]) + }) + }) + + // ── Lines 123-125: cache hit returning a non-null cached embed ──────────── + // resolvedUrlsRef stores the result after the first resolve. On a second + // change event with the same URL the `has()` check is true and the cached + // embed (non-null) is spread and returned — lines 124-125. + describe('resolvedUrlsRef cache hit (lines 123-125)', () => { + it('returns the cached embed without calling resolvePermalink again', async () => { + mockResolvePermalink.mockResolvedValue({ data: { type: 'track', id: 'track-1' } }) + mockFetchTrack.mockResolvedValue({ data: mockTrack }) + + const onEmbedsResolved = vi.fn() + const { container } = render( + + ) + + const url = 'https://soundcloud.com/artist/track' + + // First type — populates cache + fireEvent.change(getByTest(container, 'message-input'), { target: { value: url } }) + await runDebounce() + + const callsAfterFirst = mockResolvePermalink.mock.calls.length + expect(callsAfterFirst).toBe(1) + + // Second type with the same URL — must hit cache, not call API again + fireEvent.change(getByTest(container, 'message-input'), { target: { value: `${url} ` } }) + await runDebounce() + + expect(mockResolvePermalink.mock.calls.length).toBe(callsAfterFirst) + + // The embed is still resolved and reported + const lastCall = + onEmbedsResolved.mock.calls[onEmbedsResolved.mock.calls.length - 1][0] + expect(lastCall[0].id).toBe('track-1') + }) + + it('does not call fetchTrack again when cache returns a previously resolved track', async () => { + mockResolvePermalink.mockResolvedValue({ data: { type: 'track', id: 'track-1' } }) + mockFetchTrack.mockResolvedValue({ data: mockTrack }) + + const { container } = render() + const url = 'https://soundcloud.com/artist/track' + + fireEvent.change(getByTest(container, 'message-input'), { target: { value: url } }) + await runDebounce() + + const fetchCallsAfterFirst = mockFetchTrack.mock.calls.length + + // Same URL again — full cache hit, no fetchTrack + fireEvent.change(getByTest(container, 'message-input'), { target: { value: `${url} ` } }) + await runDebounce() + + expect(mockFetchTrack.mock.calls.length).toBe(fetchCallsAfterFirst) + }) + }) + + // ── Lines 147-162: playlist track hydration ─────────────────────────────── + describe('playlist track hydration (lines 147-162)', () => { + it('calls fetchTrack for each track object that has an id field', async () => { + const playlistWithTracks = { + ...mockPlaylist, + tracks: [ + { id: 'tr-a', title: 'Track A' }, + { id: 'tr-b', title: 'Track B' }, + ], + } + mockResolvePermalink.mockResolvedValue({ data: { type: 'playlist', id: 'pl-1' } }) + mockFetchPlaylist.mockResolvedValue({ data: playlistWithTracks }) + mockFetchTrack.mockResolvedValue({ data: mockTrack }) + + const { container } = render() + fireEvent.change(getByTest(container, 'message-input'), { + target: { value: 'https://soundcloud.com/artist/sets/my-playlist' }, + }) + await runDebounce() + + expect(mockFetchTrack).toHaveBeenCalledWith('tr-a') + expect(mockFetchTrack).toHaveBeenCalledWith('tr-b') + }) + + it('falls back to track_id when the id field is absent', async () => { + const playlistWithTracks = { + ...mockPlaylist, + tracks: [{ track_id: 'tr-c' }], + } + mockResolvePermalink.mockResolvedValue({ data: { type: 'playlist', id: 'pl-1' } }) + mockFetchPlaylist.mockResolvedValue({ data: playlistWithTracks }) + mockFetchTrack.mockResolvedValue({ data: mockTrack }) + + const { container } = render() + fireEvent.change(getByTest(container, 'message-input'), { + target: { value: 'https://soundcloud.com/artist/sets/my-playlist' }, + }) + await runDebounce() + + expect(mockFetchTrack).toHaveBeenCalledWith('tr-c') + }) + + it('uses the string itself as trackId when the track entry is a plain string', async () => { + const playlistWithTracks = { + ...mockPlaylist, + tracks: ['tr-d'], + } + mockResolvePermalink.mockResolvedValue({ data: { type: 'playlist', id: 'pl-1' } }) + mockFetchPlaylist.mockResolvedValue({ data: playlistWithTracks }) + mockFetchTrack.mockResolvedValue({ data: mockTrack }) + + const { container } = render() + fireEvent.change(getByTest(container, 'message-input'), { + target: { value: 'https://soundcloud.com/artist/sets/my-playlist' }, + }) + await runDebounce() + + expect(mockFetchTrack).toHaveBeenCalledWith('tr-d') + }) + + it('skips fetchTrack when trackId is the literal string "undefined"', async () => { + const playlistWithTracks = { + ...mockPlaylist, + tracks: [{ id: 'undefined' }], + } + mockResolvePermalink.mockResolvedValue({ data: { type: 'playlist', id: 'pl-1' } }) + mockFetchPlaylist.mockResolvedValue({ data: playlistWithTracks }) + + const { container } = render() + fireEvent.change(getByTest(container, 'message-input'), { + target: { value: 'https://soundcloud.com/artist/sets/my-playlist' }, + }) + await runDebounce() + + expect(mockFetchTrack).not.toHaveBeenCalled() + }) + + it('skips fetchTrack when trackId is an empty string', async () => { + const playlistWithTracks = { + ...mockPlaylist, + tracks: [{ id: '' }], + } + mockResolvePermalink.mockResolvedValue({ data: { type: 'playlist', id: 'pl-1' } }) + mockFetchPlaylist.mockResolvedValue({ data: playlistWithTracks }) + + const { container } = render() + fireEvent.change(getByTest(container, 'message-input'), { + target: { value: 'https://soundcloud.com/artist/sets/my-playlist' }, + }) + await runDebounce() + + expect(mockFetchTrack).not.toHaveBeenCalled() + }) + + it('still resolves the playlist embed when fetchTrack throws for a track', async () => { + const playlistWithTracks = { + ...mockPlaylist, + tracks: [{ id: 'tr-e' }], + } + mockResolvePermalink.mockResolvedValue({ data: { type: 'playlist', id: 'pl-1' } }) + mockFetchPlaylist.mockResolvedValue({ data: playlistWithTracks }) + mockFetchTrack.mockRejectedValue(new Error('track fetch failed')) + + const onEmbedsResolved = vi.fn() + const { container } = render( + + ) + fireEvent.change(getByTest(container, 'message-input'), { + target: { value: 'https://soundcloud.com/artist/sets/my-playlist' }, + }) + await runDebounce() + + // The catch on line 162 swallows the error; playlist embed is still emitted + const lastCall = + onEmbedsResolved.mock.calls[onEmbedsResolved.mock.calls.length - 1][0] + expect(lastCall).toHaveLength(1) + expect(lastCall[0].type).toBe('playlist') + }) + + it('resolves album type the same way as playlist type', async () => { + const playlistWithTracks = { ...mockPlaylist, tracks: [{ id: 'tr-f' }] } + mockResolvePermalink.mockResolvedValue({ data: { type: 'album', id: 'pl-1' } }) + mockFetchPlaylist.mockResolvedValue({ data: playlistWithTracks }) + mockFetchTrack.mockResolvedValue({ data: mockTrack }) + + const onEmbedsResolved = vi.fn() + const { container } = render( + + ) + fireEvent.change(getByTest(container, 'message-input'), { + target: { value: 'https://soundcloud.com/artist/sets/my-album' }, + }) + await runDebounce() + + const lastCall = + onEmbedsResolved.mock.calls[onEmbedsResolved.mock.calls.length - 1][0] + expect(lastCall[0].type).toBe('playlist') + expect(mockFetchTrack).toHaveBeenCalledWith('tr-f') + }) + }) 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 index 54e030db..67b1cd8c 100644 --- a/src/components/MessagingComponents/tests/MessagesPage.test.tsx +++ b/src/components/MessagingComponents/tests/MessagesPage.test.tsx @@ -1,137 +1,207 @@ -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"; +import { render, screen, waitFor, act } from "@testing-library/react" +import { describe, it, expect, vi, beforeEach } from "vitest" +import { MemoryRouter } from "react-router-dom" +import MessagesPage from "@/pages/social/messages/MessagesPage" +import * as conversationApi from "../../../services/api/messaging/conversationApi" -// 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. +// ─── Module mocks ───────────────────────────────────────────────────────────── -vi.mock("@/services/api/messaging/conversationApi", () => ({ +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("../../../components/UI/Spinner", () => ({ + default: (props: Record) => ( +
Loading...
+ ), +})) -vi.mock("@/pages/social/messages/emptyMessagesPage", () => ({ - // data-test not data-testid - default: () =>
Empty Page
, -})); +vi.mock("./emptyMessagesPage", () => ({ + default: () =>
No messages
, +})) -// Stable navigate reference declared before vi.mock so the hoisted factory -// captures the real function, not undefined. -const mockNavigate = vi.fn(); +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( + const actual = await vi.importActual("react-router-dom") + return { ...actual, useNavigate: () => mockNavigate } +}) + +// Mock axios module so isAxiosError is fully controllable per-test +const mockIsAxiosError = vi.fn((_err: unknown): boolean => false) +vi.mock("axios", () => ({ + default: { isAxiosError: (err: unknown) => mockIsAxiosError(err) }, + isAxiosError: (err: unknown) => mockIsAxiosError(err), +})) + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const mockFetchConversations = vi.mocked(conversationApi.fetchConversations) + +const makeConversation = (id: string) => ({ + id, + created_at: "2024-01-01T00:00:00Z", + last_message: null, + participants: [], +}) + +function renderPage() { + return render( - ); + ) +} + +beforeEach(() => { + vi.clearAllMocks() + mockIsAxiosError.mockImplementation(() => false) +}) + +// ─── Tests ──────────────────────────────────────────────────────────────────── 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(); + mockFetchConversations.mockReturnValueOnce(new Promise(() => {})) + renderPage() + expect(screen.getByTestId("messages-loading")).toBeInTheDocument() + }) + + it("does not render EmptyMessagesPage or error while loading", () => { + mockFetchConversations.mockReturnValueOnce(new Promise(() => {})) + renderPage() + expect(screen.queryByTestId("empty-messages-page")).not.toBeInTheDocument() + expect(screen.queryByTestId("messages-error")).not.toBeInTheDocument() + }) + + // ── Success: empty ───────────────────────────────────────────────────────── + + it("renders EmptyMessagesPage when there are no conversations", async () => { + mockFetchConversations.mockResolvedValueOnce({ data: { items: [] } } as any) + renderPage() + await waitFor(() => + expect(screen.getByTestId("empty-messages-page")).toBeInTheDocument() + ) + }) + + it("does not navigate when conversations list is empty", async () => { + mockFetchConversations.mockResolvedValueOnce({ data: { items: [] } } as any) + 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(); + expect(screen.getByTestId("empty-messages-page")).toBeInTheDocument() + ) + expect(mockNavigate).not.toHaveBeenCalled() + }) + + // ── Success: with conversations ──────────────────────────────────────────── + + it("redirects to conversations[0] with replace:true", async () => { + mockFetchConversations.mockResolvedValueOnce({ + data: { items: [makeConversation("conv-1"), makeConversation("conv-2")] }, + } as any) + renderPage() + await waitFor(() => + expect(mockNavigate).toHaveBeenCalledWith("/messages/conv-1", { replace: true }) + ) + }) + + it("calls fetchConversations with page 1 and limit 20", async () => { + mockFetchConversations.mockResolvedValueOnce({ data: { items: [] } } as any) + renderPage() + await waitFor(() => + expect(mockFetchConversations).toHaveBeenCalledWith(1, 20) + ) + }) + + // ── Error: axios with response message ──────────────────────────────────── + + it("shows the response message from an axios error", async () => { + mockIsAxiosError.mockImplementation(() => true) + mockFetchConversations.mockRejectedValueOnce({ + response: { data: { message: "Unauthorized access" } }, + }) + renderPage() + await waitFor(() => + expect(screen.getByTestId("messages-error")).toHaveTextContent("Unauthorized access") + ) + }) + + // ── Error: axios with no message field ──────────────────────────────────── + + it("falls back to default message when axios error has no response message", async () => { + mockIsAxiosError.mockImplementation(() => true) + mockFetchConversations.mockRejectedValueOnce({ response: { data: {} } }) + 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(); + expect(screen.getByTestId("messages-error")).toHaveTextContent( + "Failed to load conversations." + ) + ) + }) + + // ── Error: axios with no response at all ────────────────────────────────── + + it("falls back to default message when axios error has no response object", async () => { + mockIsAxiosError.mockImplementation(() => true) + mockFetchConversations.mockRejectedValueOnce({}) + 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(); + expect(screen.getByTestId("messages-error")).toHaveTextContent( + "Failed to load conversations." + ) + ) + }) + + // ── Error: non-axios ────────────────────────────────────────────────────── + + it("shows generic error for non-axios errors", async () => { + mockIsAxiosError.mockImplementation(() => false) + mockFetchConversations.mockRejectedValueOnce(new Error("Network failure")) + 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 + expect(screen.getByTestId("messages-error")).toHaveTextContent( + "An unexpected error occurred." + ) + ) + }) + + // ── Cancellation: unmount before resolve ────────────────────────────────── + + it("does not set state or navigate when component unmounts before fetch resolves", async () => { + let resolvePromise!: (v: any) => void + mockFetchConversations.mockReturnValueOnce( + new Promise((res) => { resolvePromise = res }) + ) + + const { unmount } = renderPage() + + // Unmount triggers cancelled = true in the cleanup function + unmount() + + // Resolve after unmount — the cancelled guard should prevent all state updates + await act(async () => { + resolvePromise({ data: { items: [makeConversation("conv-unmounted")] } }) + }) + + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it("does not set error state when component unmounts before fetch rejects", async () => { + let rejectPromise!: (reason?: any) => void + mockFetchConversations.mockReturnValueOnce( + new Promise((_, rej) => { rejectPromise = rej }) + ) + + const { unmount } = renderPage() + unmount() + + // Reject after unmount — cancelled guard should swallow the branch + await act(async () => { + rejectPromise(new Error("late error")) + }) + + expect(screen.queryByTestId("messages-error")).not.toBeInTheDocument() + }) +}) \ 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 index d1152c2e..4e71af58 100644 --- a/src/components/MessagingComponents/tests/ModalNewMessageBody.test.tsx +++ b/src/components/MessagingComponents/tests/ModalNewMessageBody.test.tsx @@ -1,270 +1,862 @@ -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; - }) => ( -
-