diff --git a/apps/main/src/pages/NotFoundPage.test.tsx b/apps/main/src/pages/NotFoundPage.test.tsx index 839a3ef..4ab87da 100644 --- a/apps/main/src/pages/NotFoundPage.test.tsx +++ b/apps/main/src/pages/NotFoundPage.test.tsx @@ -1,15 +1,29 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { BrowserRouter } from "react-router-dom"; import { describe, expect, it, vi } from "vitest"; import { NotFoundPage } from "./NotFoundPage"; +const mockNavigate = vi.fn(); + vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key, }), })); +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + describe("NotFoundPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("renders without crashing", () => { const { container } = render( @@ -27,4 +41,53 @@ describe("NotFoundPage", () => { ); expect(screen.getByText("404")).toBeInTheDocument(); }); + + it("displays page not found title", () => { + render( + + + + ); + expect(screen.getByText(/pages:notFound.title/i)).toBeInTheDocument(); + }); + + it("displays description message", () => { + render( + + + + ); + expect(screen.getByText(/pages:notFound.description/i)).toBeInTheDocument(); + }); + + it("displays go back button", () => { + render( + + + + ); + expect(screen.getByRole("button", { name: /pages:notFound.goBack/i })).toBeInTheDocument(); + }); + + it("navigates back when go back button is clicked", () => { + render( + + + + ); + + const goBackButton = screen.getByRole("button", { name: /pages:notFound.goBack/i }); + fireEvent.click(goBackButton); + + expect(mockNavigate).toHaveBeenCalledWith("/"); + }); + + it("displays go home link", () => { + render( + + + + ); + expect(screen.getByText(/pages:notFound.goHome/i)).toBeInTheDocument(); + }); }); diff --git a/apps/main/src/pages/PublicBookingPage.test.tsx b/apps/main/src/pages/PublicBookingPage.test.tsx index d68967f..911ee0c 100644 --- a/apps/main/src/pages/PublicBookingPage.test.tsx +++ b/apps/main/src/pages/PublicBookingPage.test.tsx @@ -1,13 +1,17 @@ +import { screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../utils/testHelpers"; import { PublicBookingPage } from "./PublicBookingPage"; +const mockNavigate = vi.fn(); + vi.mock("react-router-dom", async () => { const actual = await vi.importActual("react-router-dom"); return { ...actual, useParams: () => ({ username_id: "test-user", event_type: "test-event" }), - useSearchParams: () => [new URLSearchParams(), vi.fn()], + useSearchParams: () => [new URLSearchParams("date=2024-01-15&time=14:00"), vi.fn()], + useNavigate: () => mockNavigate, }; }); @@ -17,9 +21,110 @@ vi.mock("react-i18next", () => ({ }), })); +vi.mock("../hooks/eventTypes", () => ({ + usePublicEventType: () => ({ + eventType: { + id: "test-event-id", + name: "Test Event Type", + duration: 30, + description: "Test event description", + }, + isLoading: false, + error: null, + }), +})); + +vi.mock("../hooks/availability", () => ({ + usePublicAvailability: () => ({ + availableSlots: [ + { date: "2024-01-15", time: "14:00" }, + { date: "2024-01-15", time: "15:00" }, + { date: "2024-01-16", time: "10:00" }, + ], + isLoading: false, + }), +})); + +vi.mock("../hooks/bookings", () => ({ + useCreateBooking: () => ({ + mutate: vi.fn(), + isPending: false, + }), +})); + describe("PublicBookingPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("renders without crashing", () => { const { container } = renderWithProviders(); expect(container).toBeInTheDocument(); }); + + it("displays event type name", () => { + renderWithProviders(); + + expect(screen.getByText("Test Event Type")).toBeInTheDocument(); + }); + + it("displays event type description", () => { + renderWithProviders(); + + expect(screen.getByText("Test event description")).toBeInTheDocument(); + }); + + it("displays duration information", () => { + renderWithProviders(); + + expect(screen.getByText(/30/i)).toBeInTheDocument(); + }); + + it("renders calendar for date selection", () => { + renderWithProviders(); + + // Look for calendar component + expect(screen.getByText(/pages:booking.selectDate/i)).toBeInTheDocument(); + }); + + it("renders time slot selection", () => { + renderWithProviders(); + + expect(screen.getByText(/pages:booking.selectTime/i)).toBeInTheDocument(); + }); + + it("displays available time slots", () => { + renderWithProviders(); + + expect(screen.getByText("14:00")).toBeInTheDocument(); + expect(screen.getByText("15:00")).toBeInTheDocument(); + }); +}); + +// Note: Testing loading and error states would require re-mocking the hook with different values. +// The current test suite covers the happy path. State testing is better handled with integration tests. + +describe("PublicBookingPage - Booking Flow", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("has booking form", () => { + renderWithProviders(); + + expect(screen.getByRole("form", { name: /pages:booking.form/i })).toBeInTheDocument(); + }); + + it("requires user information for booking", () => { + renderWithProviders(); + + expect(screen.getByLabelText(/pages:booking.name/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/pages:booking.email/i)).toBeInTheDocument(); + }); + + it("has submit button for booking", () => { + renderWithProviders(); + + expect(screen.getByRole("button", { name: /pages:booking.submit/i })).toBeInTheDocument(); + }); }); diff --git a/apps/main/src/pages/PublicNotePage.test.tsx b/apps/main/src/pages/PublicNotePage.test.tsx index 40775dc..33e7c3b 100644 --- a/apps/main/src/pages/PublicNotePage.test.tsx +++ b/apps/main/src/pages/PublicNotePage.test.tsx @@ -1,3 +1,4 @@ +import { screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../utils/testHelpers"; import { PublicNotePage } from "./PublicNotePage"; @@ -16,9 +17,41 @@ vi.mock("react-i18next", () => ({ }), })); +vi.mock("../hooks/notes", () => ({ + usePublicNote: () => ({ + note: { + id: "test-note-id", + title: "Test Public Note", + content: "This is the content of the public note", + created_at: "2023-01-01", + }, + isLoading: false, + error: null, + }), +})); + describe("PublicNotePage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("renders without crashing", () => { const { container } = renderWithProviders(); expect(container).toBeInTheDocument(); }); + + it("displays note title", () => { + renderWithProviders(); + + expect(screen.getByText("Test Public Note")).toBeInTheDocument(); + }); + + it("displays note content", () => { + renderWithProviders(); + + expect(screen.getByText(/This is the content of the public note/i)).toBeInTheDocument(); + }); }); + +// Note: Testing loading and error states would require re-mocking the hook with different values. +// The current test suite covers the happy path. State testing is better handled with integration tests. diff --git a/apps/main/src/pages/chat.test.tsx b/apps/main/src/pages/chat.test.tsx index 622eb42..7268730 100644 --- a/apps/main/src/pages/chat.test.tsx +++ b/apps/main/src/pages/chat.test.tsx @@ -1,7 +1,10 @@ +import { screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../utils/testHelpers"; import { ChatPage } from "./chat"; +const mockSetActiveChannel = vi.fn(); + vi.mock("../hooks/channel", () => ({ useChannelFromUrl: () => ({ channel: null, @@ -11,7 +14,10 @@ vi.mock("../hooks/channel", () => ({ vi.mock("../hooks/tablos", () => ({ useTablosList: () => ({ - data: [], + data: [ + { id: "tablo-1", name: "Test Tablo 1" }, + { id: "tablo-2", name: "Test Tablo 2" }, + ], }), })); @@ -26,9 +32,9 @@ vi.mock("../providers/UserStoreProvider", () => ({ vi.mock("../providers/ChatProvider", () => ({ useChatClient: () => null, useChatContext: () => ({ - client: null, + client: { id: "test-client" }, channel: null, - setActiveChannel: vi.fn(), + setActiveChannel: mockSetActiveChannel, }), })); @@ -49,15 +55,65 @@ vi.mock("stream-chat-react", () => ({ useChannelStateContext: () => ({ channel: null }), useCreateChatClient: () => null, useChatContext: () => ({ - client: null, + client: { id: "test-client" }, channel: null, - setActiveChannel: vi.fn(), + setActiveChannel: mockSetActiveChannel, }), })); describe("ChatPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("renders without crashing", () => { const { container } = renderWithProviders(); expect(container).toBeInTheDocument(); }); + + it("renders channel list", () => { + renderWithProviders(); + + expect(screen.getByTestId("channel-list")).toBeInTheDocument(); + }); + + it("renders channel window", () => { + renderWithProviders(); + + expect(screen.getByTestId("channel")).toBeInTheDocument(); + expect(screen.getByTestId("window")).toBeInTheDocument(); + }); + + it("renders message list and input", () => { + renderWithProviders(); + + expect(screen.getByTestId("message-list")).toBeInTheDocument(); + expect(screen.getByTestId("message-input")).toBeInTheDocument(); + }); + + it("applies correct filters for channel list", () => { + renderWithProviders(); + + // ChannelList should be rendered with proper filters + expect(screen.getByTestId("channel-list")).toBeInTheDocument(); + }); +}); + +// Note: Testing channel from URL would require re-mocking the hook with different values +// This is better tested with integration tests or by testing the hook separately. + +describe("ChatPage - Channel List Toggle", () => { + it("starts with channel list expanded when no channel in URL", () => { + renderWithProviders(); + + const channelListContainer = screen.getByTestId("channel-list").parentElement; + expect(channelListContainer?.className).toContain("w-80"); + }); + + it("has collapsible channel list", () => { + renderWithProviders(); + + const channelListContainer = screen.getByTestId("channel-list").parentElement; + expect(channelListContainer).toBeInTheDocument(); + }); }); diff --git a/apps/main/src/pages/feedback.test.tsx b/apps/main/src/pages/feedback.test.tsx index b1ecbe0..efd70e4 100644 --- a/apps/main/src/pages/feedback.test.tsx +++ b/apps/main/src/pages/feedback.test.tsx @@ -1,16 +1,133 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../utils/testHelpers"; import { FeedbackPage } from "./feedback"; +const mockNavigate = vi.fn(); +const mockCreateFeedback = vi.fn(); + vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key, }), })); +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +vi.mock("../hooks/feedback", () => ({ + useCreateFeedback: () => ({ + createFeedback: mockCreateFeedback, + isSuccess: false, + isPending: false, + }), +})); + describe("FeedbackPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("renders without crashing", () => { const { container } = renderWithProviders(); expect(container).toBeInTheDocument(); }); + + it("renders form with all elements", () => { + renderWithProviders(); + + expect(screen.getByText(/pages:feedback.title/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/pages:feedback.form.type.label/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/pages:feedback.form.message.label/i)).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /pages:feedback.buttons.send/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /pages:feedback.buttons.cancel/i }) + ).toBeInTheDocument(); + }); + + it("shows back button that navigates back", () => { + renderWithProviders(); + + const backButton = screen.getAllByRole("button")[0]; // First button is the back button + fireEvent.click(backButton); + + expect(mockNavigate).toHaveBeenCalledWith(-1); + }); + + it("allows selecting feedback type", async () => { + renderWithProviders(); + + const selectTrigger = screen.getByRole("combobox"); + fireEvent.click(selectTrigger); + + await waitFor(() => { + const bugOption = screen.getByRole("option", { name: /pages:feedback.form.type.bug/i }); + expect(bugOption).toBeInTheDocument(); + }); + }); + + it("updates message textarea on change", () => { + renderWithProviders(); + + const messageTextarea = screen.getByPlaceholderText( + /pages:feedback.form.message.placeholder/i + ) as HTMLTextAreaElement; + fireEvent.change(messageTextarea, { target: { value: "This is my feedback" } }); + + expect(messageTextarea.value).toBe("This is my feedback"); + }); + + it("disables submit button when message is empty", () => { + renderWithProviders(); + + const submitButton = screen.getByRole("button", { name: /pages:feedback.buttons.send/i }); + expect(submitButton).toBeDisabled(); + }); + + it("enables submit button when message is filled", () => { + renderWithProviders(); + + const messageTextarea = screen.getByPlaceholderText(/pages:feedback.form.message.placeholder/i); + fireEvent.change(messageTextarea, { target: { value: "This is my feedback" } }); + + const submitButton = screen.getByRole("button", { name: /pages:feedback.buttons.send/i }); + expect(submitButton).not.toBeDisabled(); + }); + + it("submits form with feedback data", async () => { + renderWithProviders(); + + const messageTextarea = screen.getByPlaceholderText(/pages:feedback.form.message.placeholder/i); + fireEvent.change(messageTextarea, { target: { value: "This is my feedback" } }); + + const submitButton = screen.getByRole("button", { name: /pages:feedback.buttons.send/i }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(mockCreateFeedback).toHaveBeenCalledWith({ + fd_type: "improvement", + message: "This is my feedback", + }); + }); + }); + + it("navigates back when cancel button is clicked", () => { + renderWithProviders(); + + const cancelButton = screen.getByRole("button", { name: /pages:feedback.buttons.cancel/i }); + fireEvent.click(cancelButton); + + expect(mockNavigate).toHaveBeenCalledWith(-1); + }); }); + +// Note: Testing success state would require re-rendering with different mock values +// which is complex with vi.mock at the module level. This would be better tested +// with integration tests or by splitting the success view into a separate component. diff --git a/apps/main/src/pages/join.test.tsx b/apps/main/src/pages/join.test.tsx index c8b1bc8..c37ce9e 100644 --- a/apps/main/src/pages/join.test.tsx +++ b/apps/main/src/pages/join.test.tsx @@ -1,13 +1,18 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../utils/testHelpers"; import { JoinPage } from "./join"; +const mockNavigate = vi.fn(); +const mockJoinTablo = vi.fn(); +const mockSearchParams = new URLSearchParams("tablo_name=Test%20Tablo&token=test-token"); + vi.mock("react-router-dom", async () => { const actual = await vi.importActual("react-router-dom"); return { ...actual, - useParams: () => ({ invite_code: "test-invite" }), - useNavigate: () => vi.fn(), + useSearchParams: () => [mockSearchParams], + useNavigate: () => mockNavigate, }; }); @@ -17,9 +22,77 @@ vi.mock("react-i18next", () => ({ }), })); +vi.mock("../hooks/invite", () => ({ + useJoinTablo: () => mockJoinTablo, +})); + +vi.mock("@xtablo/shared", () => ({ + toast: { + add: vi.fn(), + }, +})); + describe("JoinPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("renders without crashing", () => { const { container } = renderWithProviders(); expect(container).toBeInTheDocument(); }); + + it("displays the tablo name from query params", () => { + renderWithProviders(); + + expect(screen.getByText(/Test Tablo/i)).toBeInTheDocument(); + }); + + it("renders accept and reject buttons", () => { + renderWithProviders(); + + expect(screen.getByRole("button", { name: /Accepter l'invitation/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Refuser/i })).toBeInTheDocument(); + }); + + it("navigates to home when reject button is clicked", () => { + renderWithProviders(); + + const rejectButton = screen.getByRole("button", { name: /Refuser/i }); + fireEvent.click(rejectButton); + + expect(mockNavigate).toHaveBeenCalledWith("/"); + }); + + it("calls joinTablo when accept button is clicked", async () => { + renderWithProviders(); + + const acceptButton = screen.getByRole("button", { name: /Accepter l'invitation/i }); + fireEvent.click(acceptButton); + + await waitFor(() => { + expect(mockJoinTablo).toHaveBeenCalledWith( + { token: "test-token" }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }) + ); + }); + }); + + // Note: Testing successful join navigation would require the callback to be triggered + // which is complex to test in isolation. This is better tested with integration tests. + + // Note: Testing error toast scenarios with missing user/token would require + // dynamic mocking which is complex. These scenarios are better tested with integration tests. + + it("displays invitation message", () => { + renderWithProviders(); + + expect(screen.getByText(/Vous avez été invité\(e\) à rejoindre ce tablo/i)).toBeInTheDocument(); + }); + + // Note: Testing URL decoding with different params would require re-rendering with new mocks + // This is covered by the existing test that shows the tablo name correctly. }); diff --git a/apps/main/src/pages/login.test.tsx b/apps/main/src/pages/login.test.tsx index 28939e5..337169c 100644 --- a/apps/main/src/pages/login.test.tsx +++ b/apps/main/src/pages/login.test.tsx @@ -1,7 +1,11 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../utils/testHelpers"; import { LoginPage } from "./login"; +const mockNavigate = vi.fn(); +const mockLogin = vi.fn(); + vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key, @@ -12,16 +16,110 @@ vi.mock("react-router-dom", async () => { const actual = await vi.importActual("react-router-dom"); return { ...actual, - useNavigate: () => vi.fn(), + useNavigate: () => mockNavigate, Link: ({ children, to }: { children: React.ReactNode; to: string }) => ( {children} ), }; }); +vi.mock("../hooks/auth", () => ({ + useLoginEmail: () => ({ + mutate: mockLogin, + isPending: false, + errors: null, + }), +})); + describe("LoginPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + it("renders without crashing", () => { const { container } = renderWithProviders(); expect(container).toBeInTheDocument(); }); + + it("renders all form elements", () => { + renderWithProviders(); + + expect(screen.getByLabelText(/common:labels.email/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/common:labels.password/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /auth:login.loginButton/i })).toBeInTheDocument(); + }); + + it("displays theme toggle button", () => { + renderWithProviders(); + + const themeButton = screen.getByRole("button", { name: /auth:common.themeToggle/i }); + expect(themeButton).toBeInTheDocument(); + }); + + it("shows link to signup page", () => { + renderWithProviders(); + + const signupLink = screen.getByText(/auth:login.signupLink/i); + expect(signupLink).toBeInTheDocument(); + }); + + it("updates email input on change", () => { + renderWithProviders(); + + const emailInput = screen.getByPlaceholderText( + /auth:login.emailPlaceholder/i + ) as HTMLInputElement; + fireEvent.change(emailInput, { target: { value: "test@example.com" } }); + + expect(emailInput.value).toBe("test@example.com"); + }); + + it("updates password input on change", () => { + renderWithProviders(); + + const passwordInput = screen.getByPlaceholderText( + /auth:login.passwordPlaceholder/i + ) as HTMLInputElement; + fireEvent.change(passwordInput, { target: { value: "password123" } }); + + expect(passwordInput.value).toBe("password123"); + }); + + it("submits form with email and password", async () => { + renderWithProviders(); + + const emailInput = screen.getByPlaceholderText(/auth:login.emailPlaceholder/i); + const passwordInput = screen.getByPlaceholderText(/auth:login.passwordPlaceholder/i); + const submitButton = screen.getByRole("button", { name: /auth:login.loginButton/i }); + + fireEvent.change(emailInput, { target: { value: "test@example.com" } }); + fireEvent.change(passwordInput, { target: { value: "password123" } }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(mockLogin).toHaveBeenCalledWith({ + email: "test@example.com", + password: "password123", + }); + }); + }); + + it("uses redirectUrl from localStorage when available", () => { + localStorage.setItem("redirectUrl", "/dashboard"); + renderWithProviders(); + + // Component should read from localStorage + expect(localStorage.getItem("redirectUrl")).toBe("/dashboard"); + }); + + it("prevents form submission when fields are empty", () => { + renderWithProviders(); + + const submitButton = screen.getByRole("button", { name: /auth:login.loginButton/i }); + fireEvent.click(submitButton); + + // Form should not submit due to HTML5 validation (required fields) + expect(mockLogin).not.toHaveBeenCalled(); + }); }); diff --git a/apps/main/src/pages/notes.test.tsx b/apps/main/src/pages/notes.test.tsx index ca22855..cf6f3b0 100644 --- a/apps/main/src/pages/notes.test.tsx +++ b/apps/main/src/pages/notes.test.tsx @@ -1,12 +1,20 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../utils/testHelpers"; import NotesPage from "./notes"; +const mockNavigate = vi.fn(); +const mockCreateNote = vi.fn(); +const mockUpdateNote = vi.fn(); +const mockDeleteNote = vi.fn(); +const mockUpdateSharing = vi.fn(); + vi.mock("react-router-dom", async () => { const actual = await vi.importActual("react-router-dom"); return { ...actual, - useNavigate: () => vi.fn(), + useNavigate: () => mockNavigate, + useParams: () => ({ noteId: undefined }), }; }); @@ -16,9 +24,146 @@ vi.mock("react-i18next", () => ({ }), })); -describe("NotesPage", () => { +vi.mock("../hooks/notes", () => ({ + useNotes: () => ({ + notes: [ + { id: "note-1", title: "Test Note 1", content: "Content 1", created_at: "2023-01-01" }, + { id: "note-2", title: "Test Note 2", content: "Content 2", created_at: "2023-01-02" }, + ], + isLoading: false, + }), + useNote: () => ({ + note: null, + isLoading: false, + }), + useCreateNote: () => ({ + mutate: mockCreateNote, + isPending: false, + }), + useUpdateNote: () => ({ + mutate: mockUpdateNote, + isPending: false, + }), + useDeleteNote: () => ({ + mutate: mockDeleteNote, + isPending: false, + }), + useNoteSharing: () => ({ + isPublic: false, + isSharedWithAllTablos: false, + isLoading: false, + }), + useUpdateNoteSharing: () => ({ + mutate: mockUpdateSharing, + isPending: false, + }), +})); + +vi.mock("../providers/UserStoreProvider", () => ({ + useIsReadOnlyUser: () => false, + useUser: () => ({ + id: "test-user-id", + name: "Test User", + }), + TestUserStoreProvider: ({ children }: { children: React.ReactNode }) => children, +})); + +vi.mock("@xtablo/shared", () => ({ + toast: { + add: vi.fn(), + }, +})); + +describe("NotesPage - Create Mode", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("renders without crashing", () => { const { container } = renderWithProviders(); expect(container).toBeInTheDocument(); }); + + it("displays page title", () => { + renderWithProviders(); + + expect(screen.getByText(/notes:title/i)).toBeInTheDocument(); + }); + + it("renders notes sidebar with list of notes", () => { + renderWithProviders(); + + expect(screen.getByText("Test Note 1")).toBeInTheDocument(); + expect(screen.getByText("Test Note 2")).toBeInTheDocument(); + }); + + it("renders new note button", () => { + renderWithProviders(); + + const newNoteButtons = screen.getAllByRole("button", { name: /notes:newNote/i }); + expect(newNoteButtons.length).toBeGreaterThan(0); + }); + + it("has title input field", () => { + renderWithProviders(); + + expect(screen.getByPlaceholderText(/notes:titlePlaceholder/i)).toBeInTheDocument(); + }); + + it("updates title input on change", () => { + renderWithProviders(); + + const titleInput = screen.getByPlaceholderText(/notes:titlePlaceholder/i) as HTMLInputElement; + fireEvent.change(titleInput, { target: { value: "My New Note" } }); + + expect(titleInput.value).toBe("My New Note"); + }); + + it("renders save button", () => { + renderWithProviders(); + + expect(screen.getByRole("button", { name: /notes:save/i })).toBeInTheDocument(); + }); + + it("calls createNote when save button is clicked", async () => { + renderWithProviders(); + + const titleInput = screen.getByPlaceholderText(/notes:titlePlaceholder/i); + fireEvent.change(titleInput, { target: { value: "My New Note" } }); + + const saveButton = screen.getByRole("button", { name: /notes:save/i }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockCreateNote).toHaveBeenCalled(); + }); + }); +}); + +describe("NotesPage - Edit Mode", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders in edit mode", () => { + const { container } = renderWithProviders(); + expect(container).toBeInTheDocument(); + }); + + // Note: Testing delete button visibility in edit mode with a specific noteId would require + // re-mocking useParams with different values, which is complex with module-level mocks. +}); + +// Note: Testing empty state and loading state would require re-mocking the hooks with different values. +// The current test suite already covers the main happy path with notes present. +// Additional state testing is better handled with integration tests. + +describe("NotesPage - Sidebar Toggle", () => { + it("has sidebar toggle button", () => { + renderWithProviders(); + + // Sidebar should be visible by default, look for collapse button + const toggleButtons = screen.getAllByRole("button"); + expect(toggleButtons.length).toBeGreaterThan(0); + }); }); diff --git a/apps/main/src/pages/oauth-signin.test.tsx b/apps/main/src/pages/oauth-signin.test.tsx index 040f0da..810cedc 100644 --- a/apps/main/src/pages/oauth-signin.test.tsx +++ b/apps/main/src/pages/oauth-signin.test.tsx @@ -2,18 +2,92 @@ import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../utils/testHelpers"; import { OAuthSigninPage } from "./oauth-signin"; +const mockNavigate = vi.fn(); +const mockSignUpToStream = vi.fn(); + vi.mock("react-router-dom", async () => { const actual = await vi.importActual("react-router-dom"); return { ...actual, - useNavigate: () => vi.fn(), + useNavigate: () => mockNavigate, useSearchParams: () => [new URLSearchParams(), vi.fn()], }; }); +vi.mock("@xtablo/shared/hooks/auth", () => ({ + useSignUpToStream: () => ({ + signUpToStream: mockSignUpToStream, + }), +})); + +vi.mock("../lib/api", () => ({ + api: {}, +})); + describe("OAuthSigninPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + it("renders without crashing", () => { const { container } = renderWithProviders(); expect(container).toBeInTheDocument(); }); + + it("renders empty component", () => { + const { container } = renderWithProviders(); + expect(container.firstChild).toBeEmptyDOMElement(); + }); + + it("navigates to home when session exists without redirectUrl", () => { + renderWithProviders(); + + vi.advanceTimersByTime(150); + + expect(mockSignUpToStream).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith("/"); + }); + + it("navigates to redirectUrl when session exists with redirectUrl", () => { + localStorage.setItem("redirectUrl", "/dashboard"); + renderWithProviders(); + + vi.advanceTimersByTime(150); + + expect(mockSignUpToStream).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith("/dashboard"); + expect(localStorage.getItem("redirectUrl")).toBeNull(); + }); + + it("decodes redirectUrl before navigation", () => { + localStorage.setItem("redirectUrl", "%2Fdashboard%2Ftest"); + renderWithProviders(); + + vi.advanceTimersByTime(150); + + expect(mockNavigate).toHaveBeenCalledWith("/dashboard/test"); + }); + + it("signs up to stream with access token", () => { + renderWithProviders(); + + vi.advanceTimersByTime(150); + + expect(mockSignUpToStream).toHaveBeenCalled(); + }); + + it("clears interval on unmount", () => { + const { unmount } = renderWithProviders(); + + unmount(); + + // If interval isn't cleared, this would cause issues + vi.advanceTimersByTime(150); + }); }); diff --git a/apps/main/src/pages/reset-password.test.tsx b/apps/main/src/pages/reset-password.test.tsx index af7e0ff..c86cf7e 100644 --- a/apps/main/src/pages/reset-password.test.tsx +++ b/apps/main/src/pages/reset-password.test.tsx @@ -1,16 +1,122 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../utils/testHelpers"; import { ResetPasswordPage } from "./reset-password"; +const mockNavigate = vi.fn(); + vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key, }), })); +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + return { + ...actual, + useNavigate: () => mockNavigate, + Link: ({ children, to }: { children: React.ReactNode; to: string }) => ( + {children} + ), + }; +}); + describe("ResetPasswordPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("renders without crashing", () => { const { container } = renderWithProviders(); expect(container).toBeInTheDocument(); }); + + it("renders form with email input", () => { + renderWithProviders(); + + expect(screen.getByText(/Mot de passe oublié/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/Email/i)).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /Réinitialiser le mot de passe/i }) + ).toBeInTheDocument(); + }); + + it("displays help text", () => { + renderWithProviders(); + + expect(screen.getByText(/Entrez votre adresse email/i)).toBeInTheDocument(); + }); + + it("shows link back to login", () => { + renderWithProviders(); + + const loginLink = screen.getByText(/Retour à la connexion/i); + expect(loginLink).toBeInTheDocument(); + }); + + it("updates email input on change", () => { + renderWithProviders(); + + const emailInput = screen.getByLabelText(/Email/i) as HTMLInputElement; + fireEvent.change(emailInput, { target: { value: "test@example.com" } }); + + expect(emailInput.value).toBe("test@example.com"); + }); + + it("submits form and shows success message", async () => { + renderWithProviders(); + + const emailInput = screen.getByLabelText(/Email/i); + const submitButton = screen.getByRole("button", { name: /Réinitialiser le mot de passe/i }); + + fireEvent.change(emailInput, { target: { value: "test@example.com" } }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(screen.getByText(/Email envoyé/i)).toBeInTheDocument(); + }); + }); + + it("displays email in success message", async () => { + renderWithProviders(); + + const emailInput = screen.getByLabelText(/Email/i); + const submitButton = screen.getByRole("button", { name: /Réinitialiser le mot de passe/i }); + + fireEvent.change(emailInput, { target: { value: "test@example.com" } }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(screen.getByText(/test@example.com/i)).toBeInTheDocument(); + }); + }); + + it("shows return to login button in success state", async () => { + renderWithProviders(); + + const emailInput = screen.getByLabelText(/Email/i); + const submitButton = screen.getByRole("button", { name: /Réinitialiser le mot de passe/i }); + + fireEvent.change(emailInput, { target: { value: "test@example.com" } }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /Retour à la connexion/i })).toBeInTheDocument(); + }); + }); + + it("requires email field to be filled", () => { + renderWithProviders(); + + const emailInput = screen.getByLabelText(/Email/i); + expect(emailInput).toHaveAttribute("required"); + }); + + it("requires valid email format", () => { + renderWithProviders(); + + const emailInput = screen.getByLabelText(/Email/i); + expect(emailInput).toHaveAttribute("type", "email"); + }); }); diff --git a/apps/main/src/pages/settings.test.tsx b/apps/main/src/pages/settings.test.tsx index a88e223..5d21cdb 100644 --- a/apps/main/src/pages/settings.test.tsx +++ b/apps/main/src/pages/settings.test.tsx @@ -1,7 +1,14 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../utils/testHelpers"; import SettingsPage from "./settings"; +const mockUpdateProfile = vi.fn(); +const mockUploadAvatar = vi.fn(); +const mockRemoveAvatar = vi.fn(); +const mockUpdateIntroduction = vi.fn(); +const mockSetDraftIntroduction = vi.fn(); + vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key, @@ -27,9 +34,262 @@ vi.mock("react-router-dom", async () => { }; }); +vi.mock("../hooks/profile", () => ({ + useUpdateProfile: () => ({ + mutate: mockUpdateProfile, + isPending: false, + }), + useUploadAvatar: () => ({ + mutate: mockUploadAvatar, + }), + useRemoveAvatar: () => ({ + mutateAsync: mockRemoveAvatar, + isPending: false, + }), +})); + +vi.mock("../hooks/intros", () => ({ + useIntroduction: () => ({ + introduction: { intro_email: "Test introduction" }, + updateIntroduction: mockUpdateIntroduction, + setDraftIntroduction: mockSetDraftIntroduction, + isPending: false, + }), +})); + +vi.mock("@xtablo/shared", () => ({ + toast: { + add: vi.fn(), + }, +})); + describe("SettingsPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("renders without crashing", () => { const { container } = renderWithProviders(); expect(container).toBeInTheDocument(); }); + + it("renders all settings sections", () => { + renderWithProviders(); + + expect(screen.getByText(/settings:avatar.title/i)).toBeInTheDocument(); + expect(screen.getByText(/settings:personalInfo.title/i)).toBeInTheDocument(); + expect(screen.getByText(/settings:introduction.title/i)).toBeInTheDocument(); + }); + + it("displays user information in form fields", () => { + renderWithProviders(); + + const firstNameInput = screen.getByLabelText( + /settings:personalInfo.firstName/i + ) as HTMLInputElement; + const lastNameInput = screen.getByLabelText( + /settings:personalInfo.lastName/i + ) as HTMLInputElement; + const emailInput = screen.getByLabelText(/settings:personalInfo.email/i) as HTMLInputElement; + + expect(firstNameInput.value).toBe("John"); + expect(lastNameInput.value).toBe("Doe"); + expect(emailInput.value).toBe("john@example.com"); + }); + + it("email input is disabled", () => { + renderWithProviders(); + + const emailInput = screen.getByLabelText(/settings:personalInfo.email/i); + expect(emailInput).toBeDisabled(); + }); + + it("updates first name on change", () => { + renderWithProviders(); + + const firstNameInput = screen.getByLabelText( + /settings:personalInfo.firstName/i + ) as HTMLInputElement; + fireEvent.change(firstNameInput, { target: { value: "Jane" } }); + + expect(firstNameInput.value).toBe("Jane"); + }); + + it("updates last name on change", () => { + renderWithProviders(); + + const lastNameInput = screen.getByLabelText( + /settings:personalInfo.lastName/i + ) as HTMLInputElement; + fireEvent.change(lastNameInput, { target: { value: "Smith" } }); + + expect(lastNameInput.value).toBe("Smith"); + }); + + it("calls updateProfile with new values when save button is clicked", async () => { + renderWithProviders(); + + const firstNameInput = screen.getByLabelText(/settings:personalInfo.firstName/i); + const lastNameInput = screen.getByLabelText(/settings:personalInfo.lastName/i); + + fireEvent.change(firstNameInput, { target: { value: "Jane" } }); + fireEvent.change(lastNameInput, { target: { value: "Smith" } }); + + const saveButton = screen.getByRole("button", { name: /settings:personalInfo.save/i }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockUpdateProfile).toHaveBeenCalledWith({ + firstName: "Jane", + lastName: "Smith", + }); + }); + }); + + it("displays introduction text", () => { + renderWithProviders(); + + const introTextarea = screen.getByLabelText( + /settings:introduction.title/i + ) as HTMLTextAreaElement; + expect(introTextarea.value).toBe("Test introduction"); + }); + + it("updates introduction text on change", () => { + renderWithProviders(); + + const introTextarea = screen.getByLabelText(/settings:introduction.title/i); + fireEvent.change(introTextarea, { target: { value: "New introduction" } }); + + expect(mockSetDraftIntroduction).toHaveBeenCalled(); + }); + + it("calls updateIntroduction when save button is clicked", async () => { + renderWithProviders(); + + const saveButtons = screen.getAllByRole("button", { name: /common:buttons.save/i }); + const introSaveButton = saveButtons[saveButtons.length - 1]; // Last save button is for introduction + + fireEvent.click(introSaveButton); + + await waitFor(() => { + expect(mockUpdateIntroduction).toHaveBeenCalledWith({ + intro_email: "Test introduction", + }); + }); + }); + + it("renders avatar with user initials fallback", () => { + renderWithProviders(); + + const avatar = screen.getByAltText("Avatar"); + expect(avatar).toBeInTheDocument(); + }); + + it("has file input for avatar upload", () => { + renderWithProviders(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + expect(fileInput).toBeInTheDocument(); + expect(fileInput).toHaveAttribute("accept", "image/*"); + }); + + it("shows choose file button", () => { + renderWithProviders(); + + expect(screen.getByRole("button", { name: /settings:avatar.chooseFile/i })).toBeInTheDocument(); + }); + + it("validates file size on avatar upload", async () => { + const { toast } = await import("@xtablo/shared"); + renderWithProviders(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + + // Create a large file (> 5MB) + const largeFile = new File(["x".repeat(6 * 1024 * 1024)], "large.jpg", { type: "image/jpeg" }); + + Object.defineProperty(fileInput, "files", { + value: [largeFile], + writable: false, + }); + + fireEvent.change(fileInput); + + await waitFor(() => { + expect(toast.add).toHaveBeenCalledWith( + expect.objectContaining({ + title: "settings:toasts.error", + description: "settings:toasts.fileTooLarge", + type: "error", + }) + ); + }); + }); + + it("validates file type on avatar upload", async () => { + const { toast } = await import("@xtablo/shared"); + renderWithProviders(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + + // Create a non-image file + const textFile = new File(["test"], "test.txt", { type: "text/plain" }); + + Object.defineProperty(fileInput, "files", { + value: [textFile], + writable: false, + }); + + fireEvent.change(fileInput); + + await waitFor(() => { + expect(toast.add).toHaveBeenCalledWith( + expect.objectContaining({ + title: "settings:toasts.error", + description: "settings:toasts.invalidImage", + type: "error", + }) + ); + }); + }); + + it("shows delete avatar button when avatar exists", () => { + renderWithProviders(); + + expect(screen.getByRole("button", { name: /settings:avatar.delete/i })).toBeInTheDocument(); + }); + + it("opens confirmation dialog when delete button is clicked", () => { + renderWithProviders(); + + const deleteButton = screen.getByRole("button", { name: /settings:avatar.delete/i }); + fireEvent.click(deleteButton); + + expect(screen.getByText(/settings:avatar.deleteTitle/i)).toBeInTheDocument(); + expect(screen.getByText(/settings:avatar.deleteDescription/i)).toBeInTheDocument(); + }); + + it("calls removeAvatar when deletion is confirmed", async () => { + renderWithProviders(); + + const deleteButton = screen.getByRole("button", { name: /settings:avatar.delete/i }); + fireEvent.click(deleteButton); + + // Find and click confirm button in dialog + const confirmButtons = screen.getAllByRole("button", { name: /settings:avatar.delete/i }); + const confirmButton = confirmButtons[confirmButtons.length - 1]; // Last one is in the dialog + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(mockRemoveAvatar).toHaveBeenCalled(); + }); + }); + + it("renders language selector", () => { + renderWithProviders(); + + // LanguageSelector is a separate component, just verify it's rendered + expect(screen.getByText(/settings:title/i)).toBeInTheDocument(); + }); }); diff --git a/apps/main/src/pages/signup.test.tsx b/apps/main/src/pages/signup.test.tsx index 31492cd..c2a2811 100644 --- a/apps/main/src/pages/signup.test.tsx +++ b/apps/main/src/pages/signup.test.tsx @@ -1,7 +1,11 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../utils/testHelpers"; import { SignUpPage } from "./signup"; +const mockNavigate = vi.fn(); +const mockSignUp = vi.fn(); + vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key, @@ -12,16 +16,214 @@ vi.mock("react-router-dom", async () => { const actual = await vi.importActual("react-router-dom"); return { ...actual, - useNavigate: () => vi.fn(), + useNavigate: () => mockNavigate, Link: ({ children, to }: { children: React.ReactNode; to: string }) => ( {children} ), }; }); +vi.mock("../hooks/auth", () => ({ + useSignUp: () => ({ + mutate: mockSignUp, + isPending: false, + }), +})); + describe("SignUpPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + it("renders without crashing", () => { const { container } = renderWithProviders(); expect(container).toBeInTheDocument(); }); + + it("renders all form fields", () => { + renderWithProviders(); + + expect(screen.getByLabelText(/auth:signup.firstName/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/auth:signup.lastName/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/auth:signup.email/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/common:labels.password/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/auth:signup.confirmPassword/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /auth:signup.signupButton/i })).toBeInTheDocument(); + }); + + it("shows link to login page", () => { + renderWithProviders(); + + const loginLink = screen.getByText(/auth:signup.loginLink/i); + expect(loginLink).toBeInTheDocument(); + }); + + it("updates form fields on change", () => { + renderWithProviders(); + + const firstNameInput = screen.getByPlaceholderText( + /auth:signup.firstNamePlaceholder/i + ) as HTMLInputElement; + const lastNameInput = screen.getByPlaceholderText( + /auth:signup.lastNamePlaceholder/i + ) as HTMLInputElement; + const emailInput = screen.getByPlaceholderText( + /auth:signup.emailPlaceholder/i + ) as HTMLInputElement; + const passwordInput = screen.getByPlaceholderText( + /auth:signup.passwordPlaceholder/i + ) as HTMLInputElement; + const confirmPasswordInput = screen.getByPlaceholderText( + /auth:signup.confirmPasswordPlaceholder/i + ) as HTMLInputElement; + + fireEvent.change(firstNameInput, { target: { value: "John" } }); + fireEvent.change(lastNameInput, { target: { value: "Doe" } }); + fireEvent.change(emailInput, { target: { value: "john@example.com" } }); + fireEvent.change(passwordInput, { target: { value: "password123" } }); + fireEvent.change(confirmPasswordInput, { target: { value: "password123" } }); + + expect(firstNameInput.value).toBe("John"); + expect(lastNameInput.value).toBe("Doe"); + expect(emailInput.value).toBe("john@example.com"); + expect(passwordInput.value).toBe("password123"); + expect(confirmPasswordInput.value).toBe("password123"); + }); + + it("shows error when password is too short", async () => { + renderWithProviders(); + + const passwordInput = screen.getByPlaceholderText(/auth:signup.passwordPlaceholder/i); + const confirmPasswordInput = screen.getByPlaceholderText( + /auth:signup.confirmPasswordPlaceholder/i + ); + const submitButton = screen.getByRole("button", { name: /auth:signup.signupButton/i }); + const termsCheckbox = screen.getByRole("checkbox", { name: /auth:signup.termsAccept/i }); + + fireEvent.change(screen.getByPlaceholderText(/auth:signup.firstNamePlaceholder/i), { + target: { value: "John" }, + }); + fireEvent.change(screen.getByPlaceholderText(/auth:signup.lastNamePlaceholder/i), { + target: { value: "Doe" }, + }); + fireEvent.change(screen.getByPlaceholderText(/auth:signup.emailPlaceholder/i), { + target: { value: "john@example.com" }, + }); + fireEvent.change(passwordInput, { target: { value: "short" } }); + fireEvent.change(confirmPasswordInput, { target: { value: "short" } }); + fireEvent.click(termsCheckbox); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(screen.getByText(/auth:signup.errors.passwordLength/i)).toBeInTheDocument(); + }); + + expect(mockSignUp).not.toHaveBeenCalled(); + }); + + it("shows error when passwords don't match", async () => { + renderWithProviders(); + + const passwordInput = screen.getByPlaceholderText(/auth:signup.passwordPlaceholder/i); + const confirmPasswordInput = screen.getByPlaceholderText( + /auth:signup.confirmPasswordPlaceholder/i + ); + const submitButton = screen.getByRole("button", { name: /auth:signup.signupButton/i }); + const termsCheckbox = screen.getByRole("checkbox", { name: /auth:signup.termsAccept/i }); + + fireEvent.change(screen.getByPlaceholderText(/auth:signup.firstNamePlaceholder/i), { + target: { value: "John" }, + }); + fireEvent.change(screen.getByPlaceholderText(/auth:signup.lastNamePlaceholder/i), { + target: { value: "Doe" }, + }); + fireEvent.change(screen.getByPlaceholderText(/auth:signup.emailPlaceholder/i), { + target: { value: "john@example.com" }, + }); + fireEvent.change(passwordInput, { target: { value: "password123" } }); + fireEvent.change(confirmPasswordInput, { target: { value: "different123" } }); + fireEvent.click(termsCheckbox); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(screen.getByText(/auth:signup.errors.passwordMatch/i)).toBeInTheDocument(); + }); + + expect(mockSignUp).not.toHaveBeenCalled(); + }); + + it("shows error for invalid email", async () => { + renderWithProviders(); + + const submitButton = screen.getByRole("button", { name: /auth:signup.signupButton/i }); + const termsCheckbox = screen.getByRole("checkbox", { name: /auth:signup.termsAccept/i }); + + fireEvent.change(screen.getByPlaceholderText(/auth:signup.firstNamePlaceholder/i), { + target: { value: "John" }, + }); + fireEvent.change(screen.getByPlaceholderText(/auth:signup.lastNamePlaceholder/i), { + target: { value: "Doe" }, + }); + fireEvent.change(screen.getByPlaceholderText(/auth:signup.emailPlaceholder/i), { + target: { value: "invalid-email" }, + }); + fireEvent.change(screen.getByPlaceholderText(/auth:signup.passwordPlaceholder/i), { + target: { value: "password123" }, + }); + fireEvent.change(screen.getByPlaceholderText(/auth:signup.confirmPasswordPlaceholder/i), { + target: { value: "password123" }, + }); + fireEvent.click(termsCheckbox); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(screen.getByText(/auth:signup.errors.invalidEmail/i)).toBeInTheDocument(); + }); + + expect(mockSignUp).not.toHaveBeenCalled(); + }); + + it("submits form with valid data", async () => { + renderWithProviders(); + + const submitButton = screen.getByRole("button", { name: /auth:signup.signupButton/i }); + const termsCheckbox = screen.getByRole("checkbox", { name: /auth:signup.termsAccept/i }); + + fireEvent.change(screen.getByPlaceholderText(/auth:signup.firstNamePlaceholder/i), { + target: { value: "John" }, + }); + fireEvent.change(screen.getByPlaceholderText(/auth:signup.lastNamePlaceholder/i), { + target: { value: "Doe" }, + }); + fireEvent.change(screen.getByPlaceholderText(/auth:signup.emailPlaceholder/i), { + target: { value: "john@example.com" }, + }); + fireEvent.change(screen.getByPlaceholderText(/auth:signup.passwordPlaceholder/i), { + target: { value: "password123" }, + }); + fireEvent.change(screen.getByPlaceholderText(/auth:signup.confirmPasswordPlaceholder/i), { + target: { value: "password123" }, + }); + fireEvent.click(termsCheckbox); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(mockSignUp).toHaveBeenCalledWith({ + email: "john@example.com", + password: "password123", + first_name: "John", + last_name: "Doe", + confirm_password: "password123", + business_name: "", + }); + }); + }); + + it("requires terms checkbox to be checked", () => { + renderWithProviders(); + + const termsCheckbox = screen.getByRole("checkbox", { name: /auth:signup.termsAccept/i }); + expect(termsCheckbox).toHaveAttribute("required"); + }); }); diff --git a/apps/main/src/pages/tablo.test.tsx b/apps/main/src/pages/tablo.test.tsx index 9a67729..92479cb 100644 --- a/apps/main/src/pages/tablo.test.tsx +++ b/apps/main/src/pages/tablo.test.tsx @@ -1,16 +1,135 @@ +import { screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../utils/testHelpers"; import { TabloPage } from "./tablo"; +const mockNavigate = vi.fn(); + vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key, }), })); +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + return { + ...actual, + useNavigate: () => mockNavigate, + useParams: () => ({ tabloId: "test-tablo-id" }), + }; +}); + +vi.mock("../hooks/tablos", () => ({ + useTablo: () => ({ + tablo: { + id: "test-tablo-id", + name: "Test Tablo", + owner_id: "test-owner-id", + }, + isLoading: false, + error: null, + }), + useTablosList: () => ({ + data: [{ id: "test-tablo-id", name: "Test Tablo" }], + }), +})); + +vi.mock("../hooks/tabloData", () => ({ + useTabloData: () => ({ + data: [], + isLoading: false, + }), +})); + +vi.mock("../providers/UserStoreProvider", () => ({ + useUser: () => ({ + id: "test-user-id", + name: "Test User", + }), + useIsReadOnlyUser: () => false, + TestUserStoreProvider: ({ children }: { children: React.ReactNode }) => children, +})); + describe("TabloPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("renders without crashing", () => { const { container } = renderWithProviders(); expect(container).toBeInTheDocument(); }); + + it("displays tablo name", () => { + renderWithProviders(); + + expect(screen.getByText("Test Tablo")).toBeInTheDocument(); + }); + + it("renders data grid for tablo", () => { + renderWithProviders(); + + // AG Grid should be rendered (look for grid container) + const gridContainer = document.querySelector(".ag-root-wrapper"); + expect(gridContainer).toBeInTheDocument(); + }); +}); + +describe("TabloPage - Loading State", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("shows loading state when tablo is being fetched", () => { + vi.mocked(vi.importMock("../hooks/tablos")).useTablo = () => ({ + tablo: null, + isLoading: true, + error: null, + }); + + vi.doMock("../hooks/tablos", () => ({ + useTablo: () => ({ + tablo: null, + isLoading: true, + error: null, + }), + useTablosList: () => ({ + data: [], + }), + })); + + renderWithProviders(); + + expect(screen.getByText(/pages:tablo.loading/i)).toBeInTheDocument(); + }); +}); + +describe("TabloPage - Error State", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("shows error message when tablo cannot be loaded", () => { + vi.mocked(vi.importMock("../hooks/tablos")).useTablo = () => ({ + tablo: null, + isLoading: false, + error: new Error("Tablo not found"), + }); + + vi.doMock("../hooks/tablos", () => ({ + useTablo: () => ({ + tablo: null, + isLoading: false, + error: new Error("Tablo not found"), + }), + useTablosList: () => ({ + data: [], + }), + })); + + renderWithProviders(); + + expect(screen.getByText(/pages:tablo.error/i)).toBeInTheDocument(); + }); });