v3: src/ gets one layout rule and keeps to it — each layer holds one kind of module, and every layer is split into the same domains — the previous pass sorted components into folders but left hooks, helpers and a barrel inside them, kept hooks/, utils/ and contexts/ flat, and still grouped most tests by theme. The rule: components/ holds only components (.tsx), contexts/ holds FooContext.ts + FooProvider.tsx pairs, hooks/ every hook, utils/ every non-React module, pages/ one component per route and nothing else. Each of components/, contexts/, hooks/ and utils/ is split into the same domains — auth, category, chat, comment, dump, editor, feed, form, layout, player, playlist, profile, social, theme, ws — plus shared/ for what belongs to no one domain, so the chat is components/chat, contexts/chat, hooks/chat and utils/chat. components/ui becomes components/shared to match. Out of components/: useApiForm goes to hooks/form, chat's message.ts and time.ts to utils/chat (with chatDraft and chatText, now draft.ts and text.ts), the emoji skin tone to utils/editor, the remembered volume to utils/player; player/icons.tsx is PlayerIcons.tsx. components/form/index.ts is gone: everything is imported from its own file, FormProvider straight from react-hook-form, and expectOk sits beside friendlyFetchError in utils/shared/api.ts (apiError.ts), since pages that have no form used it too. Out of pages/: the index's feed views, tab bar and presence row move to components/feed; HotFeed and NewFeed, identical but for the sort, are one DumpFeed with an order prop, which also takes over MainFeedProps from pages/index/types.ts. App is a named export like everything else. Tests mirror the tree one file per module — test/<layer>/<domain>/<Module>.test.tsx — so the themed files (hooks/misc, data, editor, sync; utils/misc, text, storage, remote, journal; contexts/providers; pages/auth and UserSubpages; layout/Fabs) are split by subject, the setup each piece needs kept and the rest dropped; the floating-button test becomes one test per button, 627 tests now. The README states the rule. No behaviour changes; the catalogues only move their source references.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
123
src/test/contexts/auth/AuthProvider.test.tsx
Normal file
123
src/test/contexts/auth/AuthProvider.test.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { AuthProvider } from "../../../contexts/auth/AuthProvider.tsx";
|
||||
import { useAuth } from "../../../hooks/auth/useAuth.ts";
|
||||
import { AUTH_STORAGE_KEY } from "../../../utils/auth/storage.ts";
|
||||
import { calls, deferred, ok, route } from "../../fetch.ts";
|
||||
import { makeSession, makeToken, storeSession, wire } from "../../fixtures.ts";
|
||||
|
||||
const authWrapper = ({ children }: { children: ReactNode }) => (
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
);
|
||||
|
||||
describe("AuthProvider", () => {
|
||||
test("starts from the stored session", () => {
|
||||
const session = makeSession({ username: "ann" });
|
||||
storeSession(session);
|
||||
route("GET /api/users/me", () => wire(session.user));
|
||||
const { result } = renderHook(useAuth, { wrapper: authWrapper });
|
||||
expect(result.current.user?.username).toBe("ann");
|
||||
});
|
||||
|
||||
test("a token the server rejects is dropped", async () => {
|
||||
const session = makeSession();
|
||||
storeSession(session);
|
||||
route("GET /api/users/me", () => new Response(null, { status: 401 }));
|
||||
const { result } = renderHook(useAuth, { wrapper: authWrapper });
|
||||
await waitFor(() => expect(result.current.user).toBeNull());
|
||||
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
|
||||
expect(calls("GET /api/users/me")[0].headers.get("Authorization")).toBe(
|
||||
`Bearer ${session.token}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("a role changed on the server is picked up", async () => {
|
||||
const session = makeSession({ role: "user" });
|
||||
storeSession(session);
|
||||
route(
|
||||
"GET /api/users/me",
|
||||
() => wire({ ...session.user, role: "moderator" }),
|
||||
);
|
||||
const { result } = renderHook(useAuth, { wrapper: authWrapper });
|
||||
await waitFor(() => expect(result.current.user?.role).toBe("moderator"));
|
||||
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.role).toBe(
|
||||
"moderator",
|
||||
);
|
||||
});
|
||||
|
||||
test("offline doesn't sign anyone out", async () => {
|
||||
storeSession(makeSession());
|
||||
route("GET /api/users/me", () => new TypeError("offline"));
|
||||
const { result } = renderHook(useAuth, { wrapper: authWrapper });
|
||||
await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(1));
|
||||
expect(result.current.user).not.toBeNull();
|
||||
});
|
||||
|
||||
test("revalidates when the tab regains focus", async () => {
|
||||
const session = makeSession();
|
||||
storeSession(session);
|
||||
route("GET /api/users/me", () => wire(session.user));
|
||||
renderHook(useAuth, { wrapper: authWrapper });
|
||||
await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(1));
|
||||
act(() => {
|
||||
globalThis.dispatchEvent(new Event("focus"));
|
||||
});
|
||||
await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(2));
|
||||
});
|
||||
|
||||
test("a session without a media token fetches one", async () => {
|
||||
const session = { ...makeSession(), mediaToken: undefined };
|
||||
storeSession(session);
|
||||
route("GET /api/users/me", () => wire(session.user));
|
||||
route("GET /api/users/me/media-token", () => ({ mediaToken: "m-1" }));
|
||||
const { result } = renderHook(useAuth, { wrapper: authWrapper });
|
||||
await waitFor(() => expect(result.current.mediaToken).toBe("m-1"));
|
||||
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).mediaToken).toBe(
|
||||
"m-1",
|
||||
);
|
||||
});
|
||||
|
||||
test("follows sign-in and sign-out from other tabs", async () => {
|
||||
route("GET /api/users/me", () => ok(null));
|
||||
const { result } = renderHook(useAuth, { wrapper: authWrapper });
|
||||
expect(result.current.user).toBeNull();
|
||||
|
||||
const session = makeSession({ username: "elsewhere" });
|
||||
storeSession(session);
|
||||
act(() => {
|
||||
globalThis.dispatchEvent(
|
||||
new StorageEvent("storage", { key: AUTH_STORAGE_KEY }),
|
||||
);
|
||||
});
|
||||
expect(result.current.user?.username).toBe("elsewhere");
|
||||
|
||||
localStorage.removeItem(AUTH_STORAGE_KEY);
|
||||
act(() => {
|
||||
globalThis.dispatchEvent(
|
||||
new StorageEvent("storage", { key: "unrelated" }),
|
||||
);
|
||||
});
|
||||
expect(result.current.user).not.toBeNull();
|
||||
act(() => {
|
||||
globalThis.dispatchEvent(new StorageEvent("storage", { key: null }));
|
||||
});
|
||||
expect(result.current.user).toBeNull();
|
||||
});
|
||||
|
||||
test("a 401 for an old token doesn't sign out a newer session", async () => {
|
||||
const first = makeSession();
|
||||
storeSession(first);
|
||||
const pending = deferred();
|
||||
route("GET /api/users/me", () => pending.promise, { once: true });
|
||||
route("GET /api/users/me", () => wire(first.user));
|
||||
const { result } = renderHook(useAuth, { wrapper: authWrapper });
|
||||
const second = { ...first, token: makeToken() };
|
||||
act(() => result.current.login(second));
|
||||
await act(async () => pending.resolve(new Response(null, { status: 401 })));
|
||||
expect(result.current.token).toBe(second.token);
|
||||
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).token).toBe(
|
||||
second.token,
|
||||
);
|
||||
});
|
||||
});
|
||||
39
src/test/contexts/category/CategoriesProvider.test.tsx
Normal file
39
src/test/contexts/category/CategoriesProvider.test.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { CategoriesProvider } from "../../../contexts/category/CategoriesProvider.tsx";
|
||||
import { useCategories } from "../../../hooks/category/useCategories.ts";
|
||||
import { fail, route } from "../../fetch.ts";
|
||||
import { makeCategory, wire } from "../../fixtures.ts";
|
||||
|
||||
describe("CategoriesProvider", () => {
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<CategoriesProvider>{children}</CategoriesProvider>
|
||||
);
|
||||
|
||||
test("loads and indexes categories", async () => {
|
||||
const music = makeCategory({ id: "c1", slug: "music" });
|
||||
route("GET /api/categories", () => wire([music]));
|
||||
const { result } = renderHook(useCategories, { wrapper });
|
||||
await waitFor(() => expect(result.current.isLoaded).toBe(true));
|
||||
expect(result.current.byId.get("c1")?.slug).toBe("music");
|
||||
expect(result.current.categories[0].createdAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
test("refresh refetches", async () => {
|
||||
let list = [makeCategory({ id: "a" })];
|
||||
route("GET /api/categories", () => wire(list));
|
||||
const { result } = renderHook(useCategories, { wrapper });
|
||||
await waitFor(() => expect(result.current.categories).toHaveLength(1));
|
||||
list = [...list, makeCategory({ id: "b" })];
|
||||
await act(() => result.current.refresh());
|
||||
expect(result.current.categories).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("a failure still ends loading", async () => {
|
||||
route("GET /api/categories", () => fail("x", 500));
|
||||
const { result } = renderHook(useCategories, { wrapper });
|
||||
await waitFor(() => expect(result.current.isLoaded).toBe(true));
|
||||
expect(result.current.categories).toEqual([]);
|
||||
});
|
||||
});
|
||||
56
src/test/contexts/chat/ChatProvider.test.tsx
Normal file
56
src/test/contexts/chat/ChatProvider.test.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { act, renderHook, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { AuthProvider } from "../../../contexts/auth/AuthProvider.tsx";
|
||||
import { ChatProvider } from "../../../contexts/chat/ChatProvider.tsx";
|
||||
import { useChat } from "../../../hooks/chat/useChat.ts";
|
||||
import { route } from "../../fetch.ts";
|
||||
import { makeSession } from "../../fixtures.ts";
|
||||
import { currentPath, renderApp } from "../../render.tsx";
|
||||
|
||||
describe("ChatProvider", () => {
|
||||
test("opens only for signed-in users", async () => {
|
||||
const guest = renderHook(useChat, {
|
||||
wrapper: ({ children }) => (
|
||||
<AuthProvider>
|
||||
<MemoryRouter>
|
||||
<ChatProvider>{children}</ChatProvider>
|
||||
</MemoryRouter>
|
||||
</AuthProvider>
|
||||
),
|
||||
});
|
||||
act(() => guest.result.current.openChat());
|
||||
expect(guest.result.current.isChatOpen).toBe(false);
|
||||
});
|
||||
|
||||
test("?chat=open deep-links into it, and closing strips the param", async () => {
|
||||
const session = makeSession();
|
||||
route("GET /api/chat", () => ({ items: [], hasMore: false }));
|
||||
let chat!: ReturnType<typeof useChat>;
|
||||
function Probe() {
|
||||
chat = useChat();
|
||||
return null;
|
||||
}
|
||||
renderApp(<Probe />, { session, route: "/somewhere?chat=open&x=1" });
|
||||
expect(chat.isChatOpen).toBe(true);
|
||||
await screen.findByRole("dialog", { name: "Chat" });
|
||||
act(() => chat.closeChat());
|
||||
expect(chat.isChatOpen).toBe(false);
|
||||
expect(currentPath()).toBe("/somewhere?x=1");
|
||||
});
|
||||
|
||||
test("openChat / closeChat", () => {
|
||||
const session = makeSession();
|
||||
route("GET /api/chat", () => ({ items: [], hasMore: false }));
|
||||
let chat!: ReturnType<typeof useChat>;
|
||||
function Probe() {
|
||||
chat = useChat();
|
||||
return null;
|
||||
}
|
||||
renderApp(<Probe />, { session });
|
||||
act(() => chat.openChat());
|
||||
expect(chat.isChatOpen).toBe(true);
|
||||
act(() => chat.closeChat());
|
||||
expect(chat.isChatOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,17 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { act, render, renderHook, waitFor } from "@testing-library/react";
|
||||
import { memo, type ReactNode, useContext, useState } from "react";
|
||||
import { AuthContext } from "../../contexts/AuthContext.ts";
|
||||
import { PlayerProvider } from "../../contexts/PlayerProvider.tsx";
|
||||
import { AuthContext } from "../../../contexts/auth/AuthContext.ts";
|
||||
import { PlayerProvider } from "../../../contexts/player/PlayerProvider.tsx";
|
||||
import {
|
||||
PlayerContext,
|
||||
type PlayerItem,
|
||||
PlayerTimeContext,
|
||||
} from "../../contexts/PlayerContext.ts";
|
||||
import type { AuthResponse } from "../../model.ts";
|
||||
import { STREAM_STALE_MS } from "../../utils/streamSources.ts";
|
||||
import { calls, deferred, fail, route } from "../fetch.ts";
|
||||
import { makeSession } from "../fixtures.ts";
|
||||
} from "../../../contexts/player/PlayerContext.ts";
|
||||
import type { AuthResponse } from "../../../model.ts";
|
||||
import { STREAM_STALE_MS } from "../../../utils/player/streamSources.ts";
|
||||
import { calls, deferred, fail, route } from "../../fetch.ts";
|
||||
import { makeSession } from "../../fixtures.ts";
|
||||
|
||||
const embed = (n: number): PlayerItem => ({
|
||||
kind: "embed",
|
||||
@@ -1,323 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { act, renderHook, screen, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { AuthProvider } from "../../contexts/AuthProvider.tsx";
|
||||
import { FollowProvider } from "../../contexts/FollowProvider.tsx";
|
||||
import { CategoriesProvider } from "../../contexts/CategoriesProvider.tsx";
|
||||
import { ThemeProvider } from "../../contexts/ThemeProvider.tsx";
|
||||
import { ChatProvider } from "../../contexts/ChatProvider.tsx";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { useFollows } from "../../hooks/useFollows.ts";
|
||||
import { useCategories } from "../../hooks/useCategories.ts";
|
||||
import { useTheme } from "../../hooks/useTheme.ts";
|
||||
import { useChat } from "../../hooks/useChat.ts";
|
||||
import { AUTH_STORAGE_KEY } from "../../utils/authStorage.ts";
|
||||
import { calls, deferred, fail, ok, route } from "../fetch.ts";
|
||||
import {
|
||||
makeCategory,
|
||||
makeSession,
|
||||
makeToken,
|
||||
storeSession,
|
||||
wire,
|
||||
} from "../fixtures.ts";
|
||||
import { currentPath, renderApp } from "../render.tsx";
|
||||
|
||||
const authWrapper = ({ children }: { children: ReactNode }) => (
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
);
|
||||
|
||||
describe("AuthProvider", () => {
|
||||
test("starts from the stored session", () => {
|
||||
const session = makeSession({ username: "ann" });
|
||||
storeSession(session);
|
||||
route("GET /api/users/me", () => wire(session.user));
|
||||
const { result } = renderHook(useAuth, { wrapper: authWrapper });
|
||||
expect(result.current.user?.username).toBe("ann");
|
||||
});
|
||||
|
||||
test("a token the server rejects is dropped", async () => {
|
||||
const session = makeSession();
|
||||
storeSession(session);
|
||||
route("GET /api/users/me", () => new Response(null, { status: 401 }));
|
||||
const { result } = renderHook(useAuth, { wrapper: authWrapper });
|
||||
await waitFor(() => expect(result.current.user).toBeNull());
|
||||
expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull();
|
||||
expect(calls("GET /api/users/me")[0].headers.get("Authorization")).toBe(
|
||||
`Bearer ${session.token}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("a role changed on the server is picked up", async () => {
|
||||
const session = makeSession({ role: "user" });
|
||||
storeSession(session);
|
||||
route(
|
||||
"GET /api/users/me",
|
||||
() => wire({ ...session.user, role: "moderator" }),
|
||||
);
|
||||
const { result } = renderHook(useAuth, { wrapper: authWrapper });
|
||||
await waitFor(() => expect(result.current.user?.role).toBe("moderator"));
|
||||
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.role).toBe(
|
||||
"moderator",
|
||||
);
|
||||
});
|
||||
|
||||
test("offline doesn't sign anyone out", async () => {
|
||||
storeSession(makeSession());
|
||||
route("GET /api/users/me", () => new TypeError("offline"));
|
||||
const { result } = renderHook(useAuth, { wrapper: authWrapper });
|
||||
await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(1));
|
||||
expect(result.current.user).not.toBeNull();
|
||||
});
|
||||
|
||||
test("revalidates when the tab regains focus", async () => {
|
||||
const session = makeSession();
|
||||
storeSession(session);
|
||||
route("GET /api/users/me", () => wire(session.user));
|
||||
renderHook(useAuth, { wrapper: authWrapper });
|
||||
await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(1));
|
||||
act(() => {
|
||||
globalThis.dispatchEvent(new Event("focus"));
|
||||
});
|
||||
await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(2));
|
||||
});
|
||||
|
||||
test("a session without a media token fetches one", async () => {
|
||||
const session = { ...makeSession(), mediaToken: undefined };
|
||||
storeSession(session);
|
||||
route("GET /api/users/me", () => wire(session.user));
|
||||
route("GET /api/users/me/media-token", () => ({ mediaToken: "m-1" }));
|
||||
const { result } = renderHook(useAuth, { wrapper: authWrapper });
|
||||
await waitFor(() => expect(result.current.mediaToken).toBe("m-1"));
|
||||
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).mediaToken).toBe(
|
||||
"m-1",
|
||||
);
|
||||
});
|
||||
|
||||
test("follows sign-in and sign-out from other tabs", async () => {
|
||||
route("GET /api/users/me", () => ok(null));
|
||||
const { result } = renderHook(useAuth, { wrapper: authWrapper });
|
||||
expect(result.current.user).toBeNull();
|
||||
|
||||
const session = makeSession({ username: "elsewhere" });
|
||||
storeSession(session);
|
||||
act(() => {
|
||||
globalThis.dispatchEvent(
|
||||
new StorageEvent("storage", { key: AUTH_STORAGE_KEY }),
|
||||
);
|
||||
});
|
||||
expect(result.current.user?.username).toBe("elsewhere");
|
||||
|
||||
localStorage.removeItem(AUTH_STORAGE_KEY);
|
||||
act(() => {
|
||||
globalThis.dispatchEvent(
|
||||
new StorageEvent("storage", { key: "unrelated" }),
|
||||
);
|
||||
});
|
||||
expect(result.current.user).not.toBeNull();
|
||||
act(() => {
|
||||
globalThis.dispatchEvent(new StorageEvent("storage", { key: null }));
|
||||
});
|
||||
expect(result.current.user).toBeNull();
|
||||
});
|
||||
|
||||
test("a 401 for an old token doesn't sign out a newer session", async () => {
|
||||
const first = makeSession();
|
||||
storeSession(first);
|
||||
const pending = deferred();
|
||||
route("GET /api/users/me", () => pending.promise, { once: true });
|
||||
route("GET /api/users/me", () => wire(first.user));
|
||||
const { result } = renderHook(useAuth, { wrapper: authWrapper });
|
||||
const second = { ...first, token: makeToken() };
|
||||
act(() => result.current.login(second));
|
||||
await act(async () => pending.resolve(new Response(null, { status: 401 })));
|
||||
expect(result.current.token).toBe(second.token);
|
||||
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).token).toBe(
|
||||
second.token,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FollowProvider", () => {
|
||||
function setup(
|
||||
status = { followedUserIds: ["u1"], followedPlaylistIds: ["p1"] },
|
||||
) {
|
||||
const session = makeSession();
|
||||
storeSession(session);
|
||||
route("GET /api/users/me", () => wire(session.user));
|
||||
route("GET /api/follows/status", () => status);
|
||||
return renderHook(useFollows, {
|
||||
wrapper: ({ children }) => (
|
||||
<AuthProvider>
|
||||
<FollowProvider>{children}</FollowProvider>
|
||||
</AuthProvider>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
test("loads what the user follows", async () => {
|
||||
const { result } = setup();
|
||||
await waitFor(() => expect(result.current.isLoaded).toBe(true));
|
||||
expect(result.current.followedUserIds.has("u1")).toBe(true);
|
||||
expect(result.current.followedPlaylistIds.has("p1")).toBe(true);
|
||||
});
|
||||
|
||||
test("guests follow nobody and never ask", async () => {
|
||||
const { result } = renderHook(useFollows, {
|
||||
wrapper: ({ children }) => (
|
||||
<AuthProvider>
|
||||
<FollowProvider>{children}</FollowProvider>
|
||||
</AuthProvider>
|
||||
),
|
||||
});
|
||||
await act(async () => {});
|
||||
expect(result.current.isLoaded).toBe(false);
|
||||
expect(calls("GET /api/follows/status")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("follow and unfollow are optimistic and roll back on failure", async () => {
|
||||
const { result } = setup({ followedUserIds: [], followedPlaylistIds: [] });
|
||||
await waitFor(() => expect(result.current.isLoaded).toBe(true));
|
||||
|
||||
route("POST /api/follows/users/:id", () => ({}));
|
||||
await act(() => result.current.followUser("u2"));
|
||||
expect(result.current.followedUserIds.has("u2")).toBe(true);
|
||||
|
||||
route("DELETE /api/follows/users/:id", () => fail("no", 500));
|
||||
await act(() => result.current.unfollowUser("u2"));
|
||||
expect(result.current.followedUserIds.has("u2")).toBe(true);
|
||||
|
||||
route("POST /api/follows/playlists/:id", () => new TypeError("offline"));
|
||||
await act(() => result.current.followPlaylist("p2"));
|
||||
expect(result.current.followedPlaylistIds.has("p2")).toBe(false);
|
||||
|
||||
route("POST /api/follows/playlists/:id", () => ({}));
|
||||
await act(() => result.current.followPlaylist("p2"));
|
||||
route("DELETE /api/follows/playlists/:id", () => ({}));
|
||||
await act(() => result.current.unfollowPlaylist("p2"));
|
||||
expect(result.current.followedPlaylistIds.has("p2")).toBe(false);
|
||||
expect(calls("DELETE /api/follows/playlists/:id")[0].path).toBe(
|
||||
"/api/follows/playlists/p2",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CategoriesProvider", () => {
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<CategoriesProvider>{children}</CategoriesProvider>
|
||||
);
|
||||
|
||||
test("loads and indexes categories", async () => {
|
||||
const music = makeCategory({ id: "c1", slug: "music" });
|
||||
route("GET /api/categories", () => wire([music]));
|
||||
const { result } = renderHook(useCategories, { wrapper });
|
||||
await waitFor(() => expect(result.current.isLoaded).toBe(true));
|
||||
expect(result.current.byId.get("c1")?.slug).toBe("music");
|
||||
expect(result.current.categories[0].createdAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
test("refresh refetches", async () => {
|
||||
let list = [makeCategory({ id: "a" })];
|
||||
route("GET /api/categories", () => wire(list));
|
||||
const { result } = renderHook(useCategories, { wrapper });
|
||||
await waitFor(() => expect(result.current.categories).toHaveLength(1));
|
||||
list = [...list, makeCategory({ id: "b" })];
|
||||
await act(() => result.current.refresh());
|
||||
expect(result.current.categories).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("a failure still ends loading", async () => {
|
||||
route("GET /api/categories", () => fail("x", 500));
|
||||
const { result } = renderHook(useCategories, { wrapper });
|
||||
await waitFor(() => expect(result.current.isLoaded).toBe(true));
|
||||
expect(result.current.categories).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ThemeProvider", () => {
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
);
|
||||
const root = document.documentElement;
|
||||
|
||||
test("smooth and auto by default, with no attributes", () => {
|
||||
const { result } = renderHook(useTheme, { wrapper });
|
||||
expect(result.current.style).toBe("smooth");
|
||||
expect(result.current.colorScheme).toBe("auto");
|
||||
expect(root.hasAttribute("data-style")).toBe(false);
|
||||
expect(root.hasAttribute("data-color-scheme")).toBe(false);
|
||||
});
|
||||
|
||||
test("applies and persists a choice", () => {
|
||||
const { result } = renderHook(useTheme, { wrapper });
|
||||
act(() => result.current.setStyle("brutalist"));
|
||||
act(() => result.current.setColorScheme("dark"));
|
||||
expect(root.getAttribute("data-style")).toBe("brutalist");
|
||||
expect(root.getAttribute("data-color-scheme")).toBe("dark");
|
||||
expect(localStorage.getItem("style")).toBe("brutalist");
|
||||
expect(localStorage.getItem("color-scheme")).toBe("dark");
|
||||
act(() => result.current.setStyle("smooth"));
|
||||
act(() => result.current.setColorScheme("auto"));
|
||||
expect(root.hasAttribute("data-style")).toBe(false);
|
||||
expect(root.hasAttribute("data-color-scheme")).toBe(false);
|
||||
});
|
||||
|
||||
test("restores the stored theme, migrating the legacy name", () => {
|
||||
localStorage.setItem("style", "nyt");
|
||||
localStorage.setItem("color-scheme", "light");
|
||||
const { result } = renderHook(useTheme, { wrapper });
|
||||
expect(result.current.style).toBe("nyt");
|
||||
expect(result.current.colorScheme).toBe("light");
|
||||
localStorage.setItem("style", "default");
|
||||
expect(renderHook(useTheme, { wrapper }).result.current.style).toBe(
|
||||
"smooth",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatProvider", () => {
|
||||
test("opens only for signed-in users", async () => {
|
||||
const guest = renderHook(useChat, {
|
||||
wrapper: ({ children }) => (
|
||||
<AuthProvider>
|
||||
<MemoryRouter>
|
||||
<ChatProvider>{children}</ChatProvider>
|
||||
</MemoryRouter>
|
||||
</AuthProvider>
|
||||
),
|
||||
});
|
||||
act(() => guest.result.current.openChat());
|
||||
expect(guest.result.current.isChatOpen).toBe(false);
|
||||
});
|
||||
|
||||
test("?chat=open deep-links into it, and closing strips the param", async () => {
|
||||
const session = makeSession();
|
||||
route("GET /api/chat", () => ({ items: [], hasMore: false }));
|
||||
let chat!: ReturnType<typeof useChat>;
|
||||
function Probe() {
|
||||
chat = useChat();
|
||||
return null;
|
||||
}
|
||||
renderApp(<Probe />, { session, route: "/somewhere?chat=open&x=1" });
|
||||
expect(chat.isChatOpen).toBe(true);
|
||||
await screen.findByRole("dialog", { name: "Chat" });
|
||||
act(() => chat.closeChat());
|
||||
expect(chat.isChatOpen).toBe(false);
|
||||
expect(currentPath()).toBe("/somewhere?x=1");
|
||||
});
|
||||
|
||||
test("openChat / closeChat", () => {
|
||||
const session = makeSession();
|
||||
route("GET /api/chat", () => ({ items: [], hasMore: false }));
|
||||
let chat!: ReturnType<typeof useChat>;
|
||||
function Probe() {
|
||||
chat = useChat();
|
||||
return null;
|
||||
}
|
||||
renderApp(<Probe />, { session });
|
||||
act(() => chat.openChat());
|
||||
expect(chat.isChatOpen).toBe(true);
|
||||
act(() => chat.closeChat());
|
||||
expect(chat.isChatOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
71
src/test/contexts/social/FollowProvider.test.tsx
Normal file
71
src/test/contexts/social/FollowProvider.test.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { AuthProvider } from "../../../contexts/auth/AuthProvider.tsx";
|
||||
import { FollowProvider } from "../../../contexts/social/FollowProvider.tsx";
|
||||
import { useFollows } from "../../../hooks/social/useFollows.ts";
|
||||
import { calls, fail, route } from "../../fetch.ts";
|
||||
import { makeSession, storeSession, wire } from "../../fixtures.ts";
|
||||
|
||||
describe("FollowProvider", () => {
|
||||
function setup(
|
||||
status = { followedUserIds: ["u1"], followedPlaylistIds: ["p1"] },
|
||||
) {
|
||||
const session = makeSession();
|
||||
storeSession(session);
|
||||
route("GET /api/users/me", () => wire(session.user));
|
||||
route("GET /api/follows/status", () => status);
|
||||
return renderHook(useFollows, {
|
||||
wrapper: ({ children }) => (
|
||||
<AuthProvider>
|
||||
<FollowProvider>{children}</FollowProvider>
|
||||
</AuthProvider>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
test("loads what the user follows", async () => {
|
||||
const { result } = setup();
|
||||
await waitFor(() => expect(result.current.isLoaded).toBe(true));
|
||||
expect(result.current.followedUserIds.has("u1")).toBe(true);
|
||||
expect(result.current.followedPlaylistIds.has("p1")).toBe(true);
|
||||
});
|
||||
|
||||
test("guests follow nobody and never ask", async () => {
|
||||
const { result } = renderHook(useFollows, {
|
||||
wrapper: ({ children }) => (
|
||||
<AuthProvider>
|
||||
<FollowProvider>{children}</FollowProvider>
|
||||
</AuthProvider>
|
||||
),
|
||||
});
|
||||
await act(async () => {});
|
||||
expect(result.current.isLoaded).toBe(false);
|
||||
expect(calls("GET /api/follows/status")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("follow and unfollow are optimistic and roll back on failure", async () => {
|
||||
const { result } = setup({ followedUserIds: [], followedPlaylistIds: [] });
|
||||
await waitFor(() => expect(result.current.isLoaded).toBe(true));
|
||||
|
||||
route("POST /api/follows/users/:id", () => ({}));
|
||||
await act(() => result.current.followUser("u2"));
|
||||
expect(result.current.followedUserIds.has("u2")).toBe(true);
|
||||
|
||||
route("DELETE /api/follows/users/:id", () => fail("no", 500));
|
||||
await act(() => result.current.unfollowUser("u2"));
|
||||
expect(result.current.followedUserIds.has("u2")).toBe(true);
|
||||
|
||||
route("POST /api/follows/playlists/:id", () => new TypeError("offline"));
|
||||
await act(() => result.current.followPlaylist("p2"));
|
||||
expect(result.current.followedPlaylistIds.has("p2")).toBe(false);
|
||||
|
||||
route("POST /api/follows/playlists/:id", () => ({}));
|
||||
await act(() => result.current.followPlaylist("p2"));
|
||||
route("DELETE /api/follows/playlists/:id", () => ({}));
|
||||
await act(() => result.current.unfollowPlaylist("p2"));
|
||||
expect(result.current.followedPlaylistIds.has("p2")).toBe(false);
|
||||
expect(calls("DELETE /api/follows/playlists/:id")[0].path).toBe(
|
||||
"/api/follows/playlists/p2",
|
||||
);
|
||||
});
|
||||
});
|
||||
46
src/test/contexts/theme/ThemeProvider.test.tsx
Normal file
46
src/test/contexts/theme/ThemeProvider.test.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { ThemeProvider } from "../../../contexts/theme/ThemeProvider.tsx";
|
||||
import { useTheme } from "../../../hooks/theme/useTheme.ts";
|
||||
|
||||
describe("ThemeProvider", () => {
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
);
|
||||
const root = document.documentElement;
|
||||
|
||||
test("smooth and auto by default, with no attributes", () => {
|
||||
const { result } = renderHook(useTheme, { wrapper });
|
||||
expect(result.current.style).toBe("smooth");
|
||||
expect(result.current.colorScheme).toBe("auto");
|
||||
expect(root.hasAttribute("data-style")).toBe(false);
|
||||
expect(root.hasAttribute("data-color-scheme")).toBe(false);
|
||||
});
|
||||
|
||||
test("applies and persists a choice", () => {
|
||||
const { result } = renderHook(useTheme, { wrapper });
|
||||
act(() => result.current.setStyle("brutalist"));
|
||||
act(() => result.current.setColorScheme("dark"));
|
||||
expect(root.getAttribute("data-style")).toBe("brutalist");
|
||||
expect(root.getAttribute("data-color-scheme")).toBe("dark");
|
||||
expect(localStorage.getItem("style")).toBe("brutalist");
|
||||
expect(localStorage.getItem("color-scheme")).toBe("dark");
|
||||
act(() => result.current.setStyle("smooth"));
|
||||
act(() => result.current.setColorScheme("auto"));
|
||||
expect(root.hasAttribute("data-style")).toBe(false);
|
||||
expect(root.hasAttribute("data-color-scheme")).toBe(false);
|
||||
});
|
||||
|
||||
test("restores the stored theme, migrating the legacy name", () => {
|
||||
localStorage.setItem("style", "nyt");
|
||||
localStorage.setItem("color-scheme", "light");
|
||||
const { result } = renderHook(useTheme, { wrapper });
|
||||
expect(result.current.style).toBe("nyt");
|
||||
expect(result.current.colorScheme).toBe("light");
|
||||
localStorage.setItem("style", "default");
|
||||
expect(renderHook(useTheme, { wrapper }).result.current.style).toBe(
|
||||
"smooth",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,13 +2,13 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useContext } from "react";
|
||||
import { AuthProvider } from "../../contexts/AuthProvider.tsx";
|
||||
import { WSProvider } from "../../contexts/WSProvider.tsx";
|
||||
import { AvatarOverrideContext } from "../../contexts/AvatarOverrideContext.ts";
|
||||
import { useWS, useWSChat, useWSPresence } from "../../hooks/useWS.ts";
|
||||
import { useAuth } from "../../hooks/useAuth.ts";
|
||||
import { FakeWebSocket } from "../ws.ts";
|
||||
import { defaultRoutes } from "../render.tsx";
|
||||
import { AuthProvider } from "../../../contexts/auth/AuthProvider.tsx";
|
||||
import { WSProvider } from "../../../contexts/ws/WSProvider.tsx";
|
||||
import { AvatarOverrideContext } from "../../../contexts/ws/AvatarOverrideContext.ts";
|
||||
import { useWS, useWSChat, useWSPresence } from "../../../hooks/ws/useWS.ts";
|
||||
import { useAuth } from "../../../hooks/auth/useAuth.ts";
|
||||
import { FakeWebSocket } from "../../ws.ts";
|
||||
import { defaultRoutes } from "../../render.tsx";
|
||||
import {
|
||||
makeChatMessage,
|
||||
makeComment,
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
makeUser,
|
||||
storeSession,
|
||||
wire,
|
||||
} from "../fixtures.ts";
|
||||
} from "../../fixtures.ts";
|
||||
|
||||
const me = makeSession({ id: "user-me", username: "me" });
|
||||
|
||||
Reference in New Issue
Block a user