diff --git a/src/components/Premium/HeroSection.test.tsx b/src/components/Premium/HeroSection.test.tsx
new file mode 100644
index 00000000..f007ce4a
--- /dev/null
+++ b/src/components/Premium/HeroSection.test.tsx
@@ -0,0 +1,34 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import HeroSection from "./HeroSection";
+
+describe("HeroSection", () => {
+ it("renders the hero copy, CTA, and plan link", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("heading", { name: "Reach more listeners." })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Get Premium" })).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "See plan" })).toHaveAttribute(
+ "href",
+ "#pricing-cards",
+ );
+ expect(screen.getByText("Grow your audience")).toBeInTheDocument();
+ expect(screen.getByText("Know your audience")).toBeInTheDocument();
+ expect(screen.getByText("Upload unlimited tracks")).toBeInTheDocument();
+ expect(screen.getByText("Distribution is included")).toBeInTheDocument();
+ });
+
+ it("shows the loading label when the CTA is starting", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("button", { name: /loading/i })).toBeDisabled();
+ });
+});
diff --git a/src/components/layout/AuthMainLayout.test.tsx b/src/components/layout/AuthMainLayout.test.tsx
new file mode 100644
index 00000000..f07d0a99
--- /dev/null
+++ b/src/components/layout/AuthMainLayout.test.tsx
@@ -0,0 +1,84 @@
+import { render, waitFor } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import AuthMainLayout from "@/components/layout/AuthMainLayout";
+
+const mockSetUser = vi.fn();
+const mockHydrateFromApi = vi.fn();
+const mockGetMe = vi.fn();
+const mockGetMySubscription = vi.fn();
+
+vi.mock("@/stores/auth.store", () => ({
+ useAuthStore: (selector: (state: { isAuthenticated: boolean; setUser: typeof mockSetUser }) => unknown) =>
+ selector({ isAuthenticated: true, setUser: mockSetUser }),
+}));
+
+vi.mock("@/stores/likes.store", () => ({
+ useLikesStore: (selector: (state: { hydrateFromApi: typeof mockHydrateFromApi }) => unknown) =>
+ selector({ hydrateFromApi: mockHydrateFromApi }),
+}));
+
+vi.mock("@/services/auth.service", () => ({
+ getMe: () => mockGetMe(),
+ normalizeDateOfBirth: (value: string | null | undefined) => value ?? null,
+}));
+
+vi.mock("@/services/api/upload/subscription.service", () => ({
+ getMySubscription: () => mockGetMySubscription(),
+}));
+
+vi.mock("@/components/layout/MainNavbar", () => ({
+ default: () =>
,
+}));
+
+vi.mock("@/components/player/StickyPlayer", () => ({
+ default: () => ,
+}));
+
+describe("AuthMainLayout", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockGetMe.mockResolvedValue({
+ data: {
+ id: "u1",
+ username: "me",
+ display_name: "Me",
+ first_name: "Test",
+ last_name: "User",
+ bio: "",
+ email: "me@example.com",
+ role: "listener",
+ profile_picture: null,
+ cover_photo: null,
+ city: null,
+ country: null,
+ following_ids: [],
+ followers_ids: [],
+ date_of_birth: "2001-02-02",
+ gender: "female",
+ },
+ });
+ mockGetMySubscription.mockResolvedValue({
+ data: { user_subscription_id: null },
+ });
+ });
+
+ it("hydrates auth store with basic profile fields from the backend", async () => {
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(mockSetUser).toHaveBeenCalledWith(
+ expect.objectContaining({
+ date_of_birth: "2001-02-02",
+ gender: "female",
+ }),
+ );
+ });
+
+ expect(mockHydrateFromApi).toHaveBeenCalled();
+ });
+});
diff --git a/src/components/library/useLibraryData.ts b/src/components/library/useLibraryData.ts
index 05261e51..66381ce8 100644
--- a/src/components/library/useLibraryData.ts
+++ b/src/components/library/useLibraryData.ts
@@ -1,7 +1,7 @@
import { useState, useEffect } from "react";
import { getRecentlyPlayed } from "@/services/api/discover.service";
import { mapRecentlyPlayedEntry } from "@/services/api/discover.mapper";
-import { getMyPlaylists as getMyPlaylistsLib, getMyFollowing } from "@/services/api/library.service";
+import { getMyFollowing } from "@/services/api/library.service";
import { getUserById } from "@/services/user.service";
import {
getMyPlaylists as getMyPlaylistsApi,
@@ -15,7 +15,6 @@ import { useLikesStore } from "@/stores/likes.store";
import { useHistoryStore } from "@/stores/history.store";
import { useAuthStore } from "@/stores/auth.store";
import { mapPlaylistToCard, mapAlbumToCard, mapFollowingToUser } from "./library.mappers";
-import { mockRecentlyPlayedTracks } from "@/services/mocks/discover";
import type { FilterOption } from "./PlaylistFilterDropdown";
const isAlbum = (p: Playlist) => p.is_album_view || p.subtype === "album";
@@ -28,13 +27,13 @@ export function useLibraryData() {
const [playlistFilter, setPlaylistFilter] = useState("All");
const {
- likedTracks = [],
- likedStations = [],
- likedPlaylists = [],
- likedAlbums: storeLikedAlbums = [],
- likedRadioTracks = [],
- likedMixes = [],
- likedGenres = [],
+ likedTracks,
+ likedStations,
+ likedPlaylists,
+ likedAlbums: storeLikedAlbums,
+ likedRadioTracks,
+ likedMixes,
+ likedGenres,
} = useLikesStore();
const { user } = useAuthStore();
const { entries } = useHistoryStore();
@@ -62,21 +61,18 @@ export function useLibraryData() {
useEffect(() => {
const displayName = user?.displayName ?? user?.username ?? "";
- getMyPlaylistsLib()
- .then((items) =>
- setPlaylists(
- items.map((p) => mapPlaylistToCard(p as unknown as Playlist, displayName || p.owner_user_id)),
- ),
- )
- .catch(() => setPlaylists([]));
- }, []);
- useEffect(() => {
Promise.all([
getMyPlaylistsApi({ limit: 50 }),
getLikedPlaylists({ limit: 50 }),
])
.then(([created, liked]) => {
+ setPlaylists(
+ created.data.items
+ .filter((p) => !isAlbum(p))
+ .map((p) => mapPlaylistToCard(p, displayName || p.owner_user_id)),
+ );
+
const merged = [
...created.data.items.filter(isAlbum),
...liked.data.items.filter(isAlbum),
@@ -90,16 +86,17 @@ export function useLibraryData() {
}),
);
})
- .catch(() => setAlbums([]));
+ .catch(() => {
+ setPlaylists([]);
+ setAlbums([]);
+ });
}, []);
const recentEntries = (() => {
const list =
entries.length > 0
? entries
- : recentlyPlayedApi.length > 0
- ? recentlyPlayedApi.map((t) => ({ type: "track" as const, item: t, playedAt: "" }))
- : mockRecentlyPlayedTracks.map((t) => ({ type: "track" as const, item: t, playedAt: "" }));
+ : recentlyPlayedApi.map((t) => ({ type: "track" as const, item: t, playedAt: "" }));
const seen = new Set();
return list.filter((e) => {
@@ -140,9 +137,7 @@ export function useLibraryData() {
})();
const displayedFollowing = (() => {
- const followingIdSet = new Set(user?.following_ids ?? []);
- const filtered = followingUsers.filter((u) => followingIdSet.has(u.username));
- const seenUsernames = new Set(filtered.map((u) => u.username));
+ const seenUsernames = new Set(followingUsers.map((u) => u.username));
const synthetic: User[] = (user?.following_ids ?? [])
.filter((username) => !seenUsernames.has(username))
.map((username, i) => ({
@@ -151,7 +146,7 @@ export function useLibraryData() {
displayName: username,
followers: 0,
}));
- return [...filtered, ...synthetic];
+ return [...followingUsers, ...synthetic];
})();
return {
diff --git a/src/components/playlist/test/PlaylistSidebarAndComponent.test.tsx b/src/components/playlist/test/PlaylistSidebarAndComponent.test.tsx
new file mode 100644
index 00000000..26616045
--- /dev/null
+++ b/src/components/playlist/test/PlaylistSidebarAndComponent.test.tsx
@@ -0,0 +1,188 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import React from "react";
+import { render, screen, waitFor, fireEvent } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import PlaylistSidebar from "../PlaylistSidebar";
+import PlaylistComponent from "../PlaylistComponent";
+import {
+ getPlaylistsByUser,
+ getPlaylistLikers,
+ getPlaylistReposters,
+} from "@/services/api/playlist/playlist.service";
+import { getUserById } from "@/services/user.service";
+import { usePlayerStore } from "@/stores/player.store";
+import { useAuthStore } from "@/stores/auth.store";
+
+// ── Mocks ────────────────────────────────────────────────────
+
+vi.mock("@/services/api/playlist/playlist.service", () => ({
+ getPlaylistsByUser: vi.fn(),
+ getPlaylistLikers: vi.fn(),
+ getPlaylistReposters: vi.fn(),
+}));
+
+vi.mock("@/services/user.service", () => ({
+ getUserById: vi.fn(),
+}));
+
+vi.mock("@/stores/auth.store", () => ({
+ useAuthStore: Object.assign(vi.fn(), {
+ subscribe: vi.fn(),
+ }),
+}));
+
+vi.mock("@/stores/player.store", () => ({
+ usePlayerStore: vi.fn(),
+}));
+
+vi.mock("@/pages/[username]/[trackSlug]/components/SharePopup", () => ({
+ default: ({ onClose }: any) => (
+
+
+
+ ),
+}));
+
+vi.mock("@/services/audioService", () => ({
+ audio: { src: "" },
+ seekAudio: vi.fn(),
+ setGlobalWaveSurfer: vi.fn(),
+ setTrackLoadedLocally: vi.fn(),
+}));
+
+vi.mock("wavesurfer.js", () => ({
+ default: {
+ create: vi.fn(() => ({
+ on: vi.fn(),
+ destroy: vi.fn(),
+ })),
+ },
+}));
+
+// ── Shared test data ─────────────────────────────────────────────────────────
+
+const mockUser = { id: "u-1", username: "testuser", displayName: "Test User" };
+
+const mockPlaylistDetails = {
+ playlist_id: "pl-1",
+ name: "Test Playlist",
+ is_public: true,
+ track_count: 3,
+ owner_user_id: "u-1",
+ tracks: [{ track_id: "t-1", title: "Track 1", artist_name: "Artist A" }],
+};
+
+const sidebarPlaylists = [
+ { playlist_id: "pl-2", name: "Another Set" },
+ { playlist_id: "pl-3", name: "Third Set" },
+];
+
+describe("PlaylistSidebar", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(useAuthStore).mockReturnValue({ user: mockUser } as any);
+ vi.mocked(getUserById).mockResolvedValue({
+ id: "u-1",
+ username: "testuser",
+ display_name: "Test User",
+ } as any);
+ vi.mocked(getPlaylistsByUser).mockResolvedValue({
+ data: { items: sidebarPlaylists, meta: { total: 2 } },
+ } as any);
+ vi.mocked(getPlaylistLikers).mockResolvedValue({
+ data: [],
+ } as any);
+ vi.mocked(getPlaylistReposters).mockResolvedValue({
+ data: [],
+ } as any);
+ });
+
+ it("renders the sidebar container", async () => {
+ render(
+
+
+ ,
+ );
+ await waitFor(() =>
+ expect(screen.getByTestId("playlist-sidebar")).toBeInTheDocument(),
+ );
+ });
+
+ it("renders 'Playlists from this user' heading", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText(/Playlists from this user/i)).toBeInTheDocument();
+ });
+
+ it("renders 'View all' link", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText("View all")).toBeInTheDocument();
+ });
+
+ it("navigates 'View all' to the user's sets page", async () => {
+ render(
+
+
+ ,
+ );
+ await waitFor(() =>
+ expect(screen.getByRole("link", { name: "View all" })).toHaveAttribute(
+ "href",
+ "/testuser",
+ ),
+ );
+ });
+
+ it("renders other playlists after load", async () => {
+ render(
+
+
+ ,
+ );
+ await waitFor(() =>
+ expect(screen.getByText("Another Set")).toBeInTheDocument(),
+ );
+ });
+
+ it("does not render the current playlist in the sidebar list", async () => {
+ render(
+
+
+ ,
+ );
+ await waitFor(() =>
+ expect(screen.queryByText("Test Playlist")).not.toBeInTheDocument(),
+ );
+ });
+
+ it("shows spinner while loading", () => {
+ vi.mocked(getPlaylistsByUser).mockReturnValue(new Promise(() => {}));
+ render(
+
+
+ ,
+ );
+ expect(document.querySelector(".animate-spin")).toBeInTheDocument();
+ });
+
+ it("shows 'No other playlists' when list is empty", async () => {
+ vi.mocked(getPlaylistsByUser).mockResolvedValue({
+ data: { items: [] },
+ } as any);
+ render(
+
+
+ ,
+ );
+ await waitFor(() =>
+ expect(screen.getByText(/No other playlists/)).toBeInTheDocument(),
+ );
+ });
+});
diff --git a/src/pages/admin/tests/AdminUsersPage.test.tsx b/src/pages/admin/tests/AdminUsersPage.test.tsx
new file mode 100644
index 00000000..a8b698e5
--- /dev/null
+++ b/src/pages/admin/tests/AdminUsersPage.test.tsx
@@ -0,0 +1,371 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import React from "react";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import AdminUsersPage from "../users/AdminUsersPage";
+
+const mockAxiosGet = vi.hoisted(() => vi.fn());
+const mockAdminSuspendUser = vi.hoisted(() => vi.fn());
+const mockAdminReinstateUser = vi.hoisted(() => vi.fn());
+const mockAdminWarnUser = vi.hoisted(() => vi.fn());
+
+vi.mock("@/services/api/axiosInstance", () => ({
+ default: { get: mockAxiosGet },
+}));
+
+vi.mock("@/services/api/admin.service", () => ({
+ adminSuspendUser: mockAdminSuspendUser,
+ adminReinstateUser: mockAdminReinstateUser,
+ adminWarnUser: mockAdminWarnUser,
+}));
+
+const makeApiUser = (overrides: Record = {}) => ({
+ id: "u1",
+ display_name: "Jane Doe",
+ username: "janedoe",
+ role: "listener",
+ is_verified: false,
+ follower_count: 120,
+ profile_picture: null,
+ ...overrides,
+});
+
+const usersResponse = (users: ReturnType[]) => ({
+ data: { data: { users }, pagination: { total: users.length } },
+});
+
+beforeEach(() => {
+ mockAxiosGet.mockResolvedValue(usersResponse([]));
+ mockAdminSuspendUser.mockResolvedValue(undefined);
+ mockAdminReinstateUser.mockResolvedValue(undefined);
+ mockAdminWarnUser.mockResolvedValue(undefined);
+});
+
+describe("AdminUsersPage", () => {
+ it("renders the User Management heading", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText("User Management")).toBeInTheDocument();
+ });
+
+ it("renders the user search input", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByTestId("input-user-search")).toBeInTheDocument();
+ });
+
+ it("renders the role filter select", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByTestId("select-role-filter")).toBeInTheDocument();
+ });
+
+ it("renders the refresh button", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByTestId("btn-refresh")).toBeInTheDocument();
+ });
+
+ it("shows 'Type a name to search users' when search is empty", async () => {
+ render(
+
+
+ ,
+ );
+ await waitFor(() => {
+ expect(screen.getByText("Type a name to search users")).toBeInTheDocument();
+ });
+ });
+
+ it("does not call API when search is empty on mount", () => {
+ render(
+
+
+ ,
+ );
+ expect(mockAxiosGet).not.toHaveBeenCalled();
+ });
+
+ it("renders user rows after search returns results", async () => {
+ mockAxiosGet.mockResolvedValue(usersResponse([makeApiUser()]));
+ render(
+
+
+ ,
+ );
+ fireEvent.change(screen.getByTestId("input-user-search"), {
+ target: { value: "jane" },
+ });
+ await waitFor(
+ () => {
+ expect(screen.getByText("Jane Doe")).toBeInTheDocument();
+ },
+ { timeout: 1000 },
+ );
+ });
+
+ it("shows 'No users found' when search returns empty", async () => {
+ render(
+
+
+ ,
+ );
+ fireEvent.change(screen.getByTestId("input-user-search"), {
+ target: { value: "nobody" },
+ });
+ await waitFor(
+ () => {
+ expect(screen.getByText("No users found")).toBeInTheDocument();
+ },
+ { timeout: 1000 },
+ );
+ });
+
+ it("shows action button for non-admin users", async () => {
+ mockAxiosGet.mockResolvedValue(usersResponse([makeApiUser({ id: "u1", role: "listener" })]));
+ render(
+
+
+ ,
+ );
+ fireEvent.change(screen.getByTestId("input-user-search"), {
+ target: { value: "jane" },
+ });
+ await waitFor(
+ () => {
+ expect(screen.getByTestId("btn-user-actions-u1")).toBeInTheDocument();
+ },
+ { timeout: 1000 },
+ );
+ });
+
+ it("opens user action dropdown on button click", async () => {
+ mockAxiosGet.mockResolvedValue(usersResponse([makeApiUser({ id: "u1", role: "listener" })]));
+ render(
+
+
+ ,
+ );
+ fireEvent.change(screen.getByTestId("input-user-search"), {
+ target: { value: "jane" },
+ });
+ await waitFor(() => fireEvent.click(screen.getByTestId("btn-user-actions-u1")), {
+ timeout: 1000,
+ });
+ expect(screen.getByTestId("btn-send-warning")).toBeInTheDocument();
+ expect(screen.getByTestId("btn-suspend-account")).toBeInTheDocument();
+ });
+
+ it("opens suspend modal when Suspend Account is clicked", async () => {
+ mockAxiosGet.mockResolvedValue(usersResponse([makeApiUser({ id: "u1", role: "listener" })]));
+ render(
+
+
+ ,
+ );
+ fireEvent.change(screen.getByTestId("input-user-search"), {
+ target: { value: "jane" },
+ });
+ await waitFor(() => fireEvent.click(screen.getByTestId("btn-user-actions-u1")), {
+ timeout: 1000,
+ });
+ fireEvent.click(screen.getByTestId("btn-suspend-account"));
+ expect(screen.getByText("Suspend Account")).toBeInTheDocument();
+ });
+
+ it("closes suspend modal when cancel is clicked", async () => {
+ mockAxiosGet.mockResolvedValue(usersResponse([makeApiUser({ id: "u1", role: "listener" })]));
+ render(
+
+
+ ,
+ );
+ fireEvent.change(screen.getByTestId("input-user-search"), {
+ target: { value: "jane" },
+ });
+ await waitFor(() => fireEvent.click(screen.getByTestId("btn-user-actions-u1")), {
+ timeout: 1000,
+ });
+ fireEvent.click(screen.getByTestId("btn-suspend-account"));
+ fireEvent.click(screen.getByTestId("btn-suspend-cancel"));
+ expect(screen.queryByText("Suspend Account")).not.toBeInTheDocument();
+ });
+
+ it("confirm button is disabled when suspend reason is empty", async () => {
+ mockAxiosGet.mockResolvedValue(usersResponse([makeApiUser({ id: "u1", role: "listener" })]));
+ render(
+
+
+ ,
+ );
+ fireEvent.change(screen.getByTestId("input-user-search"), {
+ target: { value: "jane" },
+ });
+ await waitFor(() => fireEvent.click(screen.getByTestId("btn-user-actions-u1")), {
+ timeout: 1000,
+ });
+ fireEvent.click(screen.getByTestId("btn-suspend-account"));
+ const confirmBtn = screen.getByTestId("btn-suspend-confirm");
+ expect(confirmBtn).toBeDisabled();
+ });
+
+ it("enables confirm button after entering a suspend reason", async () => {
+ mockAxiosGet.mockResolvedValue(usersResponse([makeApiUser({ id: "u1", role: "listener" })]));
+ render(
+
+
+ ,
+ );
+ fireEvent.change(screen.getByTestId("input-user-search"), {
+ target: { value: "jane" },
+ });
+ await waitFor(() => fireEvent.click(screen.getByTestId("btn-user-actions-u1")), {
+ timeout: 1000,
+ });
+ fireEvent.click(screen.getByTestId("btn-suspend-account"));
+ fireEvent.change(screen.getByTestId("textarea-suspend-reason"), {
+ target: { value: "Violation of terms" },
+ });
+ expect(screen.getByTestId("btn-suspend-confirm")).not.toBeDisabled();
+ });
+
+ it("calls adminSuspendUser when suspend is confirmed", async () => {
+ mockAxiosGet.mockResolvedValue(usersResponse([makeApiUser({ id: "u1", role: "listener" })]));
+ render(
+
+
+ ,
+ );
+ fireEvent.change(screen.getByTestId("input-user-search"), {
+ target: { value: "jane" },
+ });
+ await waitFor(() => fireEvent.click(screen.getByTestId("btn-user-actions-u1")), {
+ timeout: 1000,
+ });
+ fireEvent.click(screen.getByTestId("btn-suspend-account"));
+ fireEvent.change(screen.getByTestId("textarea-suspend-reason"), {
+ target: { value: "Repeated violations" },
+ });
+ fireEvent.click(screen.getByTestId("btn-suspend-confirm"));
+ await waitFor(() => {
+ expect(mockAdminSuspendUser).toHaveBeenCalledWith("u1", "Repeated violations");
+ });
+ });
+
+ it("opens warn modal when Send Warning is clicked", async () => {
+ mockAxiosGet.mockResolvedValue(usersResponse([makeApiUser({ id: "u1", role: "listener" })]));
+ render(
+
+
+ ,
+ );
+ fireEvent.change(screen.getByTestId("input-user-search"), {
+ target: { value: "jane" },
+ });
+ await waitFor(() => fireEvent.click(screen.getByTestId("btn-user-actions-u1")), {
+ timeout: 1000,
+ });
+ fireEvent.click(screen.getByTestId("btn-send-warning"));
+ // "Send Warning" appears in both the modal heading and the confirm button
+ expect(screen.getAllByText("Send Warning").length).toBeGreaterThanOrEqual(1);
+ });
+
+ it("renders warning category buttons in warn modal", async () => {
+ mockAxiosGet.mockResolvedValue(usersResponse([makeApiUser({ id: "u1", role: "listener" })]));
+ render(
+
+
+ ,
+ );
+ fireEvent.change(screen.getByTestId("input-user-search"), {
+ target: { value: "jane" },
+ });
+ await waitFor(() => fireEvent.click(screen.getByTestId("btn-user-actions-u1")), {
+ timeout: 1000,
+ });
+ fireEvent.click(screen.getByTestId("btn-send-warning"));
+ expect(
+ screen.getByTestId("btn-warn-reason-copyright_strike"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByTestId("btn-warn-reason-content_policy_violation"),
+ ).toBeInTheDocument();
+ });
+
+ it("calls adminWarnUser when warn is confirmed", async () => {
+ mockAxiosGet.mockResolvedValue(usersResponse([makeApiUser({ id: "u1", role: "listener" })]));
+ render(
+
+
+ ,
+ );
+ fireEvent.change(screen.getByTestId("input-user-search"), {
+ target: { value: "jane" },
+ });
+ await waitFor(() => fireEvent.click(screen.getByTestId("btn-user-actions-u1")), {
+ timeout: 1000,
+ });
+ fireEvent.click(screen.getByTestId("btn-send-warning"));
+ fireEvent.click(screen.getByTestId("btn-warn-confirm"));
+ await waitFor(() => {
+ expect(mockAdminWarnUser).toHaveBeenCalledWith(
+ "u1",
+ expect.objectContaining({ reason: "content_policy_violation" }),
+ );
+ });
+ });
+
+ it("clears search when clear button is clicked", async () => {
+ render(
+
+
+ ,
+ );
+ fireEvent.change(screen.getByTestId("input-user-search"), {
+ target: { value: "jane" },
+ });
+ fireEvent.click(screen.getByTestId("btn-clear-search"));
+ const input = screen.getByTestId("input-user-search") as HTMLInputElement;
+ expect(input.value).toBe("");
+ });
+
+ it("shows reinstate button for suspended users", async () => {
+ mockAxiosGet.mockResolvedValue(
+ usersResponse([makeApiUser({ id: "u1", role: "listener" })]),
+ );
+ render(
+
+
+ ,
+ );
+ fireEvent.change(screen.getByTestId("input-user-search"), {
+ target: { value: "jane" },
+ });
+ await waitFor(() => expect(screen.getByText("Active")).toBeInTheDocument(), {
+ timeout: 1000,
+ });
+ await waitFor(() => fireEvent.click(screen.getByTestId("btn-user-actions-u1")), {
+ timeout: 1000,
+ });
+ fireEvent.click(screen.getByTestId("btn-suspend-account"));
+ fireEvent.change(screen.getByTestId("textarea-suspend-reason"), {
+ target: { value: "Reason" },
+ });
+ fireEvent.click(screen.getByTestId("btn-suspend-confirm"));
+ await waitFor(() => {
+ expect(mockAdminSuspendUser).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/pages/creator/upload/tests/UploadDetailsForm.test.tsx b/src/pages/creator/upload/tests/UploadDetailsForm.test.tsx
index 7e5371c7..17ec7e2a 100644
--- a/src/pages/creator/upload/tests/UploadDetailsForm.test.tsx
+++ b/src/pages/creator/upload/tests/UploadDetailsForm.test.tsx
@@ -37,7 +37,7 @@ const renderForm = (props = {}) => {
const utils = render(
- ,
+
);
return { ref, ...utils };
};
@@ -55,36 +55,33 @@ describe("UploadDetailsForm", () => {
// ── Rendering ──────────────────────────────────────────────────────────────
+ it("renders the form container", () => {
+ renderForm();
+ expect(screen.getByTestId("upload-details-form")).toBeInTheDocument();
+ });
+
it("auto-populates title from the audio file name", () => {
renderForm();
- const titleInput = screen.getByTestId(
- "upload-title-input",
- ) as HTMLInputElement;
+ const titleInput = screen.getByTestId("upload-title-input") as HTMLInputElement;
expect(titleInput.value).toBe("my-track");
});
it("sets title to 'Recorded Audio' when audioData is a Blob", () => {
const blob = new Blob(["audio"], { type: "audio/wav" });
renderForm({ audioData: blob });
- const titleInput = screen.getByTestId(
- "upload-title-input",
- ) as HTMLInputElement;
+ const titleInput = screen.getByTestId("upload-title-input") as HTMLInputElement;
expect(titleInput.value).toBe("Recorded Audio");
});
it("auto-populates track link slug from the file name", () => {
renderForm();
- const linkInput = screen.getByTestId(
- "upload-track-link-input",
- ) as HTMLInputElement;
+ const linkInput = screen.getByTestId("upload-track-link-input") as HTMLInputElement;
expect(linkInput.value).toBe("my-track");
});
it("auto-populates artist field with the logged-in username", () => {
renderForm();
- const artistInput = screen.getByTestId(
- "upload-artists-input",
- ) as HTMLInputElement;
+ const artistInput = screen.getByTestId("upload-artists-input") as HTMLInputElement;
expect(artistInput.value).toBe("testuser");
});
@@ -105,17 +102,13 @@ describe("UploadDetailsForm", () => {
it("renders public privacy option selected by default", () => {
renderForm();
- const publicRadio = screen.getByTestId(
- "upload-privacy-public-radio",
- ) as HTMLInputElement;
+ const publicRadio = screen.getByTestId("upload-privacy-public-radio") as HTMLInputElement;
expect(publicRadio.checked).toBe(true);
});
it("renders private privacy option unselected by default", () => {
renderForm();
- const privateRadio = screen.getByTestId(
- "upload-privacy-private-radio",
- ) as HTMLInputElement;
+ const privateRadio = screen.getByTestId("upload-privacy-private-radio") as HTMLInputElement;
expect(privateRadio.checked).toBe(false);
});
@@ -136,9 +129,7 @@ describe("UploadDetailsForm", () => {
const titleInput = screen.getByTestId("upload-title-input");
await userEvent.clear(titleInput);
await userEvent.type(titleInput, "Hello World");
- const linkInput = screen.getByTestId(
- "upload-track-link-input",
- ) as HTMLInputElement;
+ const linkInput = screen.getByTestId("upload-track-link-input") as HTMLInputElement;
expect(linkInput.value).toBe("hello-world");
});
@@ -147,9 +138,7 @@ describe("UploadDetailsForm", () => {
const titleInput = screen.getByTestId("upload-title-input");
await userEvent.clear(titleInput);
await userEvent.type(titleInput, "My New Song");
- const linkInput = screen.getByTestId(
- "upload-track-link-input",
- ) as HTMLInputElement;
+ const linkInput = screen.getByTestId("upload-track-link-input") as HTMLInputElement;
expect(linkInput.value).toBe("my-new-song");
});
@@ -158,9 +147,7 @@ describe("UploadDetailsForm", () => {
const titleInput = screen.getByTestId("upload-title-input");
await userEvent.clear(titleInput);
await userEvent.type(titleInput, "UPPERCASE");
- const linkInput = screen.getByTestId(
- "upload-track-link-input",
- ) as HTMLInputElement;
+ const linkInput = screen.getByTestId("upload-track-link-input") as HTMLInputElement;
expect(linkInput.value).toBe("uppercase");
});
@@ -168,9 +155,7 @@ describe("UploadDetailsForm", () => {
it("switches to private when private radio is clicked", async () => {
renderForm();
- const privateRadio = screen.getByTestId(
- "upload-privacy-private-radio",
- ) as HTMLInputElement;
+ const privateRadio = screen.getByTestId("upload-privacy-private-radio") as HTMLInputElement;
await userEvent.click(privateRadio);
expect(privateRadio.checked).toBe(true);
});
@@ -178,9 +163,7 @@ describe("UploadDetailsForm", () => {
it("deselects public when private is chosen", async () => {
renderForm();
await userEvent.click(screen.getByTestId("upload-privacy-private-radio"));
- const publicRadio = screen.getByTestId(
- "upload-privacy-public-radio",
- ) as HTMLInputElement;
+ const publicRadio = screen.getByTestId("upload-privacy-public-radio") as HTMLInputElement;
expect(publicRadio.checked).toBe(false);
});
@@ -188,9 +171,7 @@ describe("UploadDetailsForm", () => {
renderForm();
await userEvent.click(screen.getByTestId("upload-privacy-private-radio"));
await userEvent.click(screen.getByTestId("upload-privacy-public-radio"));
- const publicRadio = screen.getByTestId(
- "upload-privacy-public-radio",
- ) as HTMLInputElement;
+ const publicRadio = screen.getByTestId("upload-privacy-public-radio") as HTMLInputElement;
expect(publicRadio.checked).toBe(true);
});
@@ -229,7 +210,7 @@ describe("UploadDetailsForm", () => {
const { ref } = renderForm({ onSuccess });
await ref.current!.triggerSubmit();
await waitFor(() =>
- expect(onSuccess).toHaveBeenCalledWith("new-track-id-123"),
+ expect(onSuccess).toHaveBeenCalledWith("new-track-id-123")
);
});
@@ -248,10 +229,10 @@ describe("UploadDetailsForm", () => {
await userEvent.clear(titleInput);
await ref.current!.triggerSubmit();
await waitFor(() =>
- expect(screen.getByTestId("upload-error-message")).toBeInTheDocument(),
+ expect(screen.getByTestId("upload-error-message")).toBeInTheDocument()
);
expect(screen.getByTestId("upload-error-message")).toHaveTextContent(
- /track title is required/i,
+ /track title is required/i
);
});
@@ -261,15 +242,15 @@ describe("UploadDetailsForm", () => {
await act(() => ref.current!.triggerSubmit());
expect(mockUploadTrack).not.toHaveBeenCalled();
});
-
+
it("shows error when audioData is null", async () => {
const { ref } = renderForm({ audioData: null });
await ref.current!.triggerSubmit();
await waitFor(() =>
- expect(screen.getByTestId("upload-error-message")).toBeInTheDocument(),
+ expect(screen.getByTestId("upload-error-message")).toBeInTheDocument()
);
expect(screen.getByTestId("upload-error-message")).toHaveTextContent(
- /missing audio data/i,
+ /missing audio data/i
);
});
@@ -289,10 +270,10 @@ describe("UploadDetailsForm", () => {
const { ref } = renderForm();
await ref.current!.triggerSubmit();
await waitFor(() =>
- expect(screen.getByTestId("upload-error-message")).toBeInTheDocument(),
+ expect(screen.getByTestId("upload-error-message")).toBeInTheDocument()
);
expect(screen.getByTestId("upload-error-message")).toHaveTextContent(
- /upload limit reached/i,
+ /upload limit reached/i
);
});
@@ -301,7 +282,9 @@ describe("UploadDetailsForm", () => {
const setIsLoadingParent = vi.fn();
const { ref } = renderForm({ setIsLoadingParent });
await ref.current!.triggerSubmit();
- await waitFor(() => expect(setIsLoadingParent).toHaveBeenCalledWith(false));
+ await waitFor(() =>
+ expect(setIsLoadingParent).toHaveBeenCalledWith(false)
+ );
});
it("does not call onSuccess when upload fails", async () => {
@@ -313,6 +296,43 @@ describe("UploadDetailsForm", () => {
expect(onSuccess).not.toHaveBeenCalled();
});
+ it("submits optional metadata fields and cover image", async () => {
+ const user = userEvent.setup();
+ const onProgress = vi.fn();
+ const { ref } = renderForm({ onProgress });
+
+ const descriptionInput = screen.getByTestId("upload-description-input");
+ const artistsInput = screen.getByTestId("upload-artists-input");
+ const tagsInput = screen.getByTestId("upload-tags-input");
+ const coverInput = screen.getByTestId("cover-image-input") as HTMLInputElement;
+ const genreInput = screen.getByTestId("dropdown-input");
+ const coverFile = new File(["cover"], "cover.png", { type: "image/png" });
+
+ await user.clear(descriptionInput);
+ await user.type(descriptionInput, " A detailed description ");
+ await user.clear(artistsInput);
+ await user.type(artistsInput, "Artist One, Artist Two");
+ await user.clear(tagsInput);
+ await user.type(tagsInput, "tag-1, tag-2");
+ await user.click(screen.getByTestId("upload-privacy-private-radio"));
+ await user.upload(coverInput, coverFile);
+ await user.click(genreInput);
+ await user.click(await screen.findByText("Electronic"));
+
+ await ref.current!.triggerSubmit();
+ await waitFor(() => expect(mockUploadTrack).toHaveBeenCalledTimes(1));
+
+ const [payload, progressCallback] = mockUploadTrack.mock.calls[0];
+ expect(payload.description).toBe("A detailed description");
+ expect(payload.genre).toBe("Electronic");
+ expect(payload.artists).toBe("Artist One, Artist Two");
+ expect(payload.is_public).toBe(false);
+ expect(payload.cover_image).toBe(coverFile);
+ expect(payload.tags).toEqual(["tag-1", "tag-2"]);
+ expect(typeof progressCallback).toBe("function");
+ expect(onProgress).not.toHaveBeenCalled();
+ });
+
it("forwards upload progress updates from the service", async () => {
const progress = vi.fn();
mockUploadTrack.mockImplementationOnce(async (_payload, onProgress) => {
diff --git a/src/pages/you/albums/AlbumSlugPage.tsx b/src/pages/you/albums/AlbumSlugPage.tsx
index 55705db0..5e9fc828 100644
--- a/src/pages/you/albums/AlbumSlugPage.tsx
+++ b/src/pages/you/albums/AlbumSlugPage.tsx
@@ -237,7 +237,7 @@ function AlbumSlugPage() {
return (
{/* Hero Section using the fetched playlist data */}
({
),
}));
+vi.mock("@/components/playlist/PlaylistCard", () => ({
+ default: ({ playlist }: { playlist: { playlist_id: string } }) => (
+
+ ),
+}));
vi.mock("@/components/UI/UserCard/UserCard", () => ({
default: ({ user }: { user: { id: string } }) => (
@@ -137,8 +142,6 @@ const defaultLikes = {
likedPlaylists: [],
likedAlbums: [],
likedRadioTracks: [],
- likedMixes: [],
- likedGenres: [],
};
const defaultHistory = { entries: [] };
@@ -368,7 +371,7 @@ describe("LibraryPage", () => {
it("calls getMyPlaylists (library service) on mount", async () => {
await renderPage();
- expect(getMyPlaylistsApi).toHaveBeenCalledTimes(1);
+ expect(getMyPlaylistsLib).toHaveBeenCalledTimes(1);
});
it("calls getMyFollowing on mount", async () => {
@@ -576,7 +579,7 @@ describe("LibraryPage", () => {
(getMyPlaylistsApi as ReturnType).mockRejectedValue(new Error("Network error"));
await renderPage();
await waitFor(() => {});
- expect(screen.queryAllByTestId(/^playlist-card-/)).toHaveLength(0);
+ expect(screen.queryAllByTestId(/^album-card-/)).toHaveLength(0);
});
// ── Recently Played — API success path ───────────────────
@@ -615,7 +618,7 @@ describe("LibraryPage", () => {
],
});
await renderPage();
- expect(screen.getByTestId("playlist-card-la1")).toBeInTheDocument();
+ expect(screen.getByTestId("album-card-la1")).toBeInTheDocument();
});
it("renders album cards from API when items have is_album_view true", async () => {
@@ -628,7 +631,7 @@ describe("LibraryPage", () => {
});
await renderPage();
await waitFor(() => {
- expect(screen.getByTestId("playlist-card-a1")).toBeInTheDocument();
+ expect(screen.getByTestId("album-card-a1")).toBeInTheDocument();
});
});
@@ -642,7 +645,7 @@ describe("LibraryPage", () => {
});
await renderPage();
await waitFor(() => {});
- expect(screen.queryByTestId("playlist-card-p1")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("album-card-p1")).not.toBeInTheDocument();
});
it("deduplicates albums with the same id from both APIs", async () => {
@@ -651,7 +654,7 @@ describe("LibraryPage", () => {
(getLikedPlaylists as ReturnType).mockResolvedValue({ data: { items: [albumItem] } });
await renderPage();
await waitFor(() => {
- const cards = screen.getAllByTestId("playlist-card-shared-album");
+ const cards = screen.getAllByTestId("album-card-shared-album");
expect(cards).toHaveLength(1);
});
});
@@ -665,7 +668,7 @@ describe("LibraryPage", () => {
});
await renderPage();
await waitFor(() => {
- const cards = screen.getAllByTestId("playlist-card-dup-album");
+ const cards = screen.getAllByTestId("album-card-dup-album");
expect(cards).toHaveLength(1);
});
});