import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { MailboxView } from "../MailboxView";
import * as apiModule from "../../api";
import type { Agent } from "../../api";
import type { Message } from "@fusion/core";
// Mock the API module
vi.mock("../../api", () => ({
fetchInbox: vi.fn(),
fetchOutbox: vi.fn(),
fetchUnreadCount: vi.fn(),
fetchAgentMailbox: vi.fn(),
markMessageRead: vi.fn(),
markAllMessagesRead: vi.fn(),
deleteMessage: vi.fn(),
fetchConversation: vi.fn(),
sendMessage: vi.fn(),
fetchAgents: vi.fn(),
}));
// Mock lucide-react icons
vi.mock("lucide-react", () => ({
X: () => X,
Mail: () => Mail,
Send: () => Send,
Inbox: () => Inbox,
Bot: () => Bot,
Trash2: () => Trash,
Check: () => Check,
CheckCheck: () => CheckCheck,
Loader2: ({ className }: { className?: string }) => (
Loader
),
RefreshCw: () => Refresh,
MessageSquare: () => Message,
User: () => User,
AlertCircle: () => Alert,
}));
const mockFetchInbox = vi.mocked(apiModule.fetchInbox);
const mockFetchOutbox = vi.mocked(apiModule.fetchOutbox);
const mockFetchUnreadCount = vi.mocked(apiModule.fetchUnreadCount);
const mockFetchAgentMailbox = vi.mocked(apiModule.fetchAgentMailbox);
const mockFetchAgents = vi.mocked(apiModule.fetchAgents);
const mockMarkMessageRead = vi.mocked(apiModule.markMessageRead);
const mockMarkAllMessagesRead = vi.mocked(apiModule.markAllMessagesRead);
const mockDeleteMessage = vi.mocked(apiModule.deleteMessage);
const mockFetchConversation = vi.mocked(apiModule.fetchConversation);
const mockAgents: Agent[] = [
{
id: "agent-001",
name: "Test Agent 1",
role: "executor",
state: "idle",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
},
{
id: "agent-002",
name: "Test Agent 2",
role: "triage",
state: "active",
taskId: "FN-001",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
},
];
const mockMessage: Message = {
id: "msg-001",
fromId: "agent-001",
fromType: "agent",
toId: "dashboard",
toType: "user",
content: "Hello, this is a test message from the agent.",
type: "agent-to-user",
read: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const mockReadMessage: Message = {
...mockMessage,
id: "msg-002",
read: true,
content: "This message has been read already.",
};
const defaultProps = {
addToast: vi.fn(),
};
describe("MailboxView", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchUnreadCount.mockResolvedValue({ unreadCount: 2 });
mockFetchAgents.mockResolvedValue(mockAgents);
});
it("renders the mailbox view", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
});
render();
expect(screen.getByTestId("mailbox-view")).toBeDefined();
expect(screen.getByTestId("mailbox-tabs")).toBeDefined();
});
it("shows the Mailbox title with unread count badge", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],
unreadCount: 1,
});
render();
await waitFor(() => {
expect(screen.getByTestId("mailbox-unread-badge")).toBeDefined();
});
});
it("renders all three tabs", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
});
render();
expect(screen.getByTestId("mailbox-tab-inbox")).toBeDefined();
expect(screen.getByTestId("mailbox-tab-outbox")).toBeDefined();
expect(screen.getByTestId("mailbox-tab-agents")).toBeDefined();
});
it("shows inbox tab as active by default", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
});
render();
const inboxTab = screen.getByTestId("mailbox-tab-inbox");
expect(inboxTab).toHaveClass("active");
});
it("loads inbox on mount", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage, mockReadMessage],
unreadCount: 1,
});
render();
await waitFor(() => {
expect(mockFetchInbox).toHaveBeenCalled();
});
});
it("shows inbox messages after loading", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage, mockReadMessage],
unreadCount: 1,
});
render();
await waitFor(() => {
expect(screen.getByTestId("mailbox-conversations")).toBeDefined();
});
});
it("groups messages by sender and shows unread count per group", async () => {
const secondMessage = { ...mockMessage, id: "msg-003", read: false };
mockFetchInbox.mockResolvedValue({
messages: [mockMessage, secondMessage], // Same sender, both unread
unreadCount: 2,
});
render();
await waitFor(() => {
// Should show one conversation group with 2 unread
const group = screen.getByTestId("mailbox-conversation-agent:agent-001");
expect(group).toBeDefined();
expect(screen.getByTestId("mailbox-unread-badge-agent:agent-001")).toBeDefined();
});
});
it("shows unread dot for unread messages", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],
unreadCount: 1,
});
render();
await waitFor(() => {
expect(screen.getByTestId("mailbox-unread-badge-agent:agent-001")).toBeDefined();
});
});
it("does not show unread dot for read messages", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockReadMessage],
unreadCount: 0,
});
render();
await waitFor(() => {
expect(screen.queryByTestId("mailbox-unread-dot-msg-002")).toBeNull();
});
});
it("switches to outbox tab on click", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
});
mockFetchOutbox.mockResolvedValue({
messages: [],
unreadCount: 0,
});
render();
const outboxTab = screen.getByTestId("mailbox-tab-outbox");
await act(async () => {
fireEvent.click(outboxTab);
});
expect(mockFetchOutbox).toHaveBeenCalled();
});
it("switches to agents tab on click", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
});
render();
const agentsTab = screen.getByTestId("mailbox-tab-agents");
await act(async () => {
fireEvent.click(agentsTab);
});
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalled();
});
});
it("opens message detail when clicking a message", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],
unreadCount: 1,
});
mockFetchConversation.mockResolvedValue([mockMessage]);
// Mock markMessageRead to return undefined (simulating no read update needed)
mockMarkMessageRead.mockResolvedValue(undefined);
render();
await waitFor(() => {
expect(screen.getByTestId("mailbox-conversation-agent:agent-001")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-conversation-agent:agent-001"));
});
await waitFor(() => {
expect(screen.getByTestId("mailbox-message-detail")).toBeDefined();
});
});
it("marks message as read when opening unread message", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],
unreadCount: 1,
});
mockMarkMessageRead.mockResolvedValue({ ...mockMessage, read: true });
mockFetchConversation.mockResolvedValue([mockMessage]);
const onUnreadCountChange = vi.fn();
render();
await waitFor(() => {
expect(screen.getByTestId("mailbox-conversation-agent:agent-001")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-conversation-agent:agent-001"));
});
await waitFor(() => {
expect(mockMarkMessageRead).toHaveBeenCalledWith("msg-001", undefined);
});
});
it("calls markAllMessagesRead when clicking mark all read", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],
unreadCount: 1,
});
mockMarkAllMessagesRead.mockResolvedValue({ markedAsRead: 1 });
const onUnreadCountChange = vi.fn();
render();
await waitFor(() => {
expect(screen.getByTestId("mailbox-mark-all-read")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-mark-all-read"));
});
await waitFor(() => {
expect(mockMarkAllMessagesRead).toHaveBeenCalledWith(undefined);
expect(onUnreadCountChange).toHaveBeenCalledWith(0);
});
});
it("deletes message when clicking delete in detail view", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],
unreadCount: 1,
});
mockDeleteMessage.mockResolvedValue(undefined);
mockFetchConversation.mockResolvedValue([mockMessage]);
render();
await waitFor(() => {
expect(screen.getByTestId("mailbox-conversation-agent:agent-001")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-conversation-agent:agent-001"));
});
await waitFor(() => {
expect(screen.getByTestId("mailbox-message-detail")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-delete"));
});
await waitFor(() => {
expect(mockDeleteMessage).toHaveBeenCalledWith("msg-001", undefined);
});
});
it("shows compose FAB in inbox tab", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
});
render();
await waitFor(() => {
expect(screen.getByTestId("mailbox-compose-fab")).toBeDefined();
});
});
it("does not show compose FAB in agents tab", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
});
render();
const agentsTab = screen.getByTestId("mailbox-tab-agents");
await act(async () => {
fireEvent.click(agentsTab);
});
await waitFor(() => {
expect(screen.queryByTestId("mailbox-compose-fab")).toBeNull();
});
});
it("shows loading skeleton while loading", async () => {
mockFetchInbox.mockImplementation(() => new Promise(() => {})); // Never resolves
render();
await waitFor(() => {
expect(screen.getByTestId("mailbox-skeleton")).toBeDefined();
});
});
it("shows empty inbox state when no messages", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
});
render();
await waitFor(() => {
expect(screen.getByTestId("mailbox-inbox-empty")).toBeDefined();
});
});
it("passes projectId to API calls", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
});
render();
await waitFor(() => {
expect(mockFetchInbox).toHaveBeenCalledWith(
expect.objectContaining({ limit: 50 }),
"test-project"
);
expect(mockFetchUnreadCount).toHaveBeenCalledWith("test-project");
});
});
it("calls onUnreadCountChange when unread count changes", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 5,
});
const onUnreadCountChange = vi.fn();
render();
await waitFor(() => {
expect(onUnreadCountChange).toHaveBeenCalledWith(5);
});
});
it("shows MessageComposer with agents when clicking compose FAB from inbox tab", async () => {
mockFetchInbox.mockResolvedValue({
messages: [],
unreadCount: 0,
});
render();
// Verify compose FAB is visible in inbox tab
await waitFor(() => {
expect(screen.getByTestId("mailbox-compose-fab")).toBeDefined();
});
// Click compose FAB
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-compose-fab"));
});
// Verify MessageComposer is shown
await waitFor(() => {
expect(screen.getByTestId("message-composer")).toBeDefined();
});
// Verify agents are available (not "No agents available")
// The select should have agents as options, not just the placeholder
const recipientSelect = screen.getByTestId("message-composer-recipient");
expect(recipientSelect).toBeDefined();
// Should have agents option, not just "No agents available" placeholder
expect(screen.queryByText("No agents available")).toBeNull();
// Should show the mock agents
expect(screen.getByText("Test Agent 1")).toBeDefined();
expect(screen.getByText("Test Agent 2")).toBeDefined();
});
describe("mobile layout CSS regressions", () => {
it("defines .mailbox-view base flex layout with min-height: 0", async () => {
const fs = await import("fs");
const path = await import("path");
const cssPath = path.resolve(__dirname, "../../styles.css");
const css = fs.readFileSync(cssPath, "utf-8");
const viewBlockMatch = css.match(/\.mailbox-view\s*\{([^}]*)\}/);
expect(viewBlockMatch).toBeTruthy();
const viewBlock = viewBlockMatch![1];
expect(viewBlock).toContain("display: flex;");
expect(viewBlock).toContain("flex-direction: column;");
expect(viewBlock).toContain("height: 100%;");
expect(viewBlock).toContain("min-height: 0;");
expect(viewBlock).toContain("overflow: hidden;");
});
it("keeps mobile .mailbox-view overrides in the dedicated media-query section", async () => {
const fs = await import("fs");
const path = await import("path");
const cssPath = path.resolve(__dirname, "../../styles.css");
const css = fs.readFileSync(cssPath, "utf-8");
const sectionStart = css.indexOf("/* ── Mailbox — Mobile");
expect(sectionStart).toBeGreaterThan(-1);
const sectionEnd = css.indexOf("/* ── Message Composer", sectionStart);
expect(sectionEnd).toBeGreaterThan(sectionStart);
const mailboxMobileSection = css.slice(sectionStart, sectionEnd);
expect(mailboxMobileSection).toContain("@media (max-width: 768px)");
// Verify .mailbox-view selectors are in mobile section
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-header");
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-tabs");
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-content");
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-compose-fab");
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-empty");
});
it("uses mobile-specific values for .mailbox-view content and FAB", async () => {
const fs = await import("fs");
const path = await import("path");
const cssPath = path.resolve(__dirname, "../../styles.css");
const css = fs.readFileSync(cssPath, "utf-8");
const sectionStart = css.indexOf("/* ── Mailbox — Mobile");
expect(sectionStart).toBeGreaterThan(-1);
const sectionEnd = css.indexOf("/* ── Message Composer", sectionStart);
const mailboxMobileSection = css.slice(sectionStart, sectionEnd);
// Content should have max-height: none (not modal's calc)
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-content");
// Match descendant selector: .mailbox-view followed by space, then .mailbox-content
const contentRuleMatch = mailboxMobileSection.match(/\.mailbox-view\s+\.mailbox-content\s*\{[^}]*\}/);
expect(contentRuleMatch).toBeTruthy();
expect(contentRuleMatch![0]).toContain("max-height: none");
// Content should have padding-bottom accounting for mobile nav
expect(contentRuleMatch![0]).toContain("padding-bottom");
// FAB should account for mobile nav height
expect(mailboxMobileSection).toContain(".mailbox-view .mailbox-compose-fab");
const fabRuleMatch = mailboxMobileSection.match(/\.mailbox-view\s+\.mailbox-compose-fab\s*\{[^}]*\}/);
expect(fabRuleMatch).toBeTruthy();
expect(fabRuleMatch![0]).toContain("--mobile-nav-height");
expect(fabRuleMatch![0]).toContain("safe-area-inset-bottom");
expect(fabRuleMatch![0]).toContain("--standalone-bottom-gap");
});
it("renders structural elements that mobile CSS targets", async () => {
mockFetchInbox.mockResolvedValue({
messages: [mockMessage],
unreadCount: 1,
});
const { container } = render();
// Verify root element with data-testid
expect(screen.getByTestId("mailbox-view")).toBeDefined();
// Verify header
const header = container.querySelector(".mailbox-header");
expect(header).toBeTruthy();
// Verify tabs
const tabs = container.querySelector(".mailbox-tabs");
expect(tabs).toBeTruthy();
// Verify content
const content = container.querySelector(".mailbox-content");
expect(content).toBeTruthy();
});
});
});