no log: Improve frontend tests (#2827)

This commit is contained in:
Anderson Shindy Oki 2025-04-21 23:46:52 +09:00 committed by GitHub
parent cce50b2e69
commit 7cb987f55d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 1658 additions and 720 deletions

File diff suppressed because it is too large Load diff

View file

@ -59,6 +59,7 @@
"husky": "^9.0.11",
"jsdom": "^26.0.0",
"lodash": "^4.17.21",
"msw": "^2.7.0",
"postcss-preset-mantine": "^1.14.4",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.2.5",
@ -67,7 +68,7 @@
"recharts": "^2.15.0",
"sass-embedded": "^1.86.1",
"typescript": "^5.4.4",
"vite": "6.2.4",
"vite": "^6.3.2",
"vite-plugin-checker": "^0.6.4",
"vite-plugin-pwa": "^1.0.0",
"vitest": "^3.1.1",

View file

@ -1,9 +1,18 @@
import { http } from "msw";
import { HttpResponse } from "msw";
import { describe, it } from "vitest";
import { render } from "@/tests";
import { customRender } from "@/tests";
import server from "@/tests/mocks/node";
import App from ".";
describe("App", () => {
it("should render without crash", () => {
render(<App />);
server.use(
http.get("/api/system/searches", () => {
return HttpResponse.json({});
}),
);
customRender(<App />);
});
});

View file

@ -1,9 +1,9 @@
import { describe, it } from "vitest";
import { Search } from "@/components/index";
import { render } from "@/tests";
import { customRender } from "@/tests";
describe("Search Bar", () => {
it.skip("should render the closed empty state", () => {
render(<Search />);
customRender(<Search />);
});
});

View file

@ -11,7 +11,6 @@ type MutateActionProps<DATA, VAR> = Omit<
args: () => VAR | null;
onSuccess?: (args: DATA) => void;
onError?: () => void;
noReset?: boolean;
};
function MutateAction<DATA, VAR>({

View file

@ -10,7 +10,6 @@ type MutateButtonProps<DATA, VAR> = Omit<
args: () => VAR | null;
onSuccess?: (args: DATA) => void;
onError?: () => void;
noReset?: boolean;
};
function MutateButton<DATA, VAR>({

View file

@ -1,5 +1,5 @@
import { describe, it } from "vitest";
import { render, screen } from "@/tests";
import { customRender, screen } from "@/tests";
import { Language } from ".";
describe("Language text", () => {
@ -9,13 +9,13 @@ describe("Language text", () => {
};
it("should show short text", () => {
render(<Language.Text value={testLanguage}></Language.Text>);
customRender(<Language.Text value={testLanguage}></Language.Text>);
expect(screen.getByText(testLanguage.code2)).toBeDefined();
});
it("should show long text", () => {
render(<Language.Text value={testLanguage} long></Language.Text>);
customRender(<Language.Text value={testLanguage} long></Language.Text>);
expect(screen.getByText(testLanguage.name)).toBeDefined();
});
@ -23,7 +23,7 @@ describe("Language text", () => {
const testLanguageWithHi: Language.Info = { ...testLanguage, hi: true };
it("should show short text with HI", () => {
render(<Language.Text value={testLanguageWithHi}></Language.Text>);
customRender(<Language.Text value={testLanguageWithHi}></Language.Text>);
const expectedText = `${testLanguageWithHi.code2}:HI`;
@ -31,7 +31,9 @@ describe("Language text", () => {
});
it("should show long text with HI", () => {
render(<Language.Text value={testLanguageWithHi} long></Language.Text>);
customRender(
<Language.Text value={testLanguageWithHi} long></Language.Text>,
);
const expectedText = `${testLanguageWithHi.name} HI`;
@ -44,7 +46,9 @@ describe("Language text", () => {
};
it("should show short text with Forced", () => {
render(<Language.Text value={testLanguageWithForced}></Language.Text>);
customRender(
<Language.Text value={testLanguageWithForced}></Language.Text>,
);
const expectedText = `${testLanguageWithHi.code2}:Forced`;
@ -52,7 +56,9 @@ describe("Language text", () => {
});
it("should show long text with Forced", () => {
render(<Language.Text value={testLanguageWithForced} long></Language.Text>);
customRender(
<Language.Text value={testLanguageWithForced} long></Language.Text>,
);
const expectedText = `${testLanguageWithHi.name} Forced`;
@ -73,7 +79,7 @@ describe("Language list", () => {
];
it("should show all languages", () => {
render(<Language.List value={elements}></Language.List>);
customRender(<Language.List value={elements}></Language.List>);
elements.forEach((value) => {
expect(screen.getByText(value.name)).toBeDefined();

View file

@ -1,7 +1,7 @@
import { faStickyNote } from "@fortawesome/free-regular-svg-icons";
import userEvent from "@testing-library/user-event";
import { describe, it, vitest } from "vitest";
import { render, screen } from "@/tests";
import { customRender, screen } from "@/tests";
import Action from "./Action";
const testLabel = "Test Label";
@ -9,7 +9,7 @@ const testIcon = faStickyNote;
describe("Action button", () => {
it("should be a button", () => {
render(<Action icon={testIcon} label={testLabel}></Action>);
customRender(<Action icon={testIcon} label={testLabel}></Action>);
const element = screen.getByRole("button", { name: testLabel });
expect(element.getAttribute("type")).toEqual("button");
@ -17,7 +17,7 @@ describe("Action button", () => {
});
it("should show icon", () => {
render(<Action icon={testIcon} label={testLabel}></Action>);
customRender(<Action icon={testIcon} label={testLabel}></Action>);
// TODO: use getBy...
const element = screen.getByRole("img", { hidden: true });
@ -27,7 +27,7 @@ describe("Action button", () => {
it("should call on-click event when clicked", async () => {
const onClickFn = vitest.fn();
render(
customRender(
<Action icon={testIcon} label={testLabel} onClick={onClickFn}></Action>,
);

View file

@ -1,6 +1,6 @@
import userEvent from "@testing-library/user-event";
import { describe, it, vitest } from "vitest";
import { render, screen } from "@/tests";
import { customRender, screen } from "@/tests";
import ChipInput from "./ChipInput";
describe("ChipInput", () => {
@ -8,7 +8,7 @@ describe("ChipInput", () => {
// TODO: Support default value
it.skip("should works with default value", () => {
render(<ChipInput defaultValue={existedValues}></ChipInput>);
customRender(<ChipInput defaultValue={existedValues}></ChipInput>);
existedValues.forEach((value) => {
expect(screen.getByText(value)).toBeDefined();
@ -16,7 +16,7 @@ describe("ChipInput", () => {
});
it("should works with value", () => {
render(<ChipInput value={existedValues}></ChipInput>);
customRender(<ChipInput value={existedValues}></ChipInput>);
existedValues.forEach((value) => {
expect(screen.getByText(value)).toBeDefined();
@ -29,7 +29,9 @@ describe("ChipInput", () => {
expect(values).toContain(typedValue);
});
render(<ChipInput value={existedValues} onChange={mockedFn}></ChipInput>);
customRender(
<ChipInput value={existedValues} onChange={mockedFn}></ChipInput>,
);
const element = screen.getByRole("searchbox");

View file

@ -1,6 +1,6 @@
import userEvent from "@testing-library/user-event";
import { describe, it, vitest } from "vitest";
import { render, screen } from "@/tests";
import { customRender, screen } from "@/tests";
import { Selector, SelectorOption } from "./Selector";
const selectorName = "Test Selections";
@ -18,7 +18,9 @@ const testOptions: SelectorOption<string>[] = [
describe("Selector", () => {
describe("options", () => {
it("should work with the SelectorOption", () => {
render(<Selector name={selectorName} options={testOptions}></Selector>);
customRender(
<Selector name={selectorName} options={testOptions}></Selector>,
);
testOptions.forEach((o) => {
expect(screen.getByText(o.label)).toBeDefined();
@ -26,7 +28,9 @@ describe("Selector", () => {
});
it("should display when clicked", async () => {
render(<Selector name={selectorName} options={testOptions}></Selector>);
customRender(
<Selector name={selectorName} options={testOptions}></Selector>,
);
const element = screen.getByTestId("input-selector");
@ -41,7 +45,7 @@ describe("Selector", () => {
it("shouldn't show default value", async () => {
const option = testOptions[0];
render(
customRender(
<Selector
name={selectorName}
options={testOptions}
@ -54,7 +58,7 @@ describe("Selector", () => {
it("shouldn't show value", async () => {
const option = testOptions[0];
render(
customRender(
<Selector
name={selectorName}
options={testOptions}
@ -72,7 +76,7 @@ describe("Selector", () => {
const mockedFn = vitest.fn((value: string | null) => {
expect(value).toEqual(clickedOption.value);
});
render(
customRender(
<Selector
name={selectorName}
options={testOptions}
@ -112,7 +116,7 @@ describe("Selector", () => {
const mockedFn = vitest.fn((value: { name: string } | null) => {
expect(value).toEqual(clickedOption.value);
});
render(
customRender(
<Selector
name={selectorName}
options={objectOptions}
@ -134,7 +138,7 @@ describe("Selector", () => {
describe("placeholder", () => {
it("should show when no selection", () => {
const placeholder = "Empty Selection";
render(
customRender(
<Selector
name={selectorName}
options={testOptions}

View file

@ -1,10 +1,10 @@
import { describe, it } from "vitest";
import { render, screen } from "@/tests";
import { customRender, screen } from "@/tests";
import Authentication from "./Authentication";
describe("Authentication", () => {
it("should render without crash", () => {
render(<Authentication></Authentication>);
customRender(<Authentication></Authentication>);
expect(screen.getByPlaceholderText("Username")).toBeDefined();
expect(screen.getByPlaceholderText("Password")).toBeDefined();

View file

@ -0,0 +1,71 @@
/* eslint-disable camelcase */
import { http } from "msw";
import { HttpResponse } from "msw";
import { customRender, screen, waitFor } from "@/tests";
import server from "@/tests/mocks/node";
import BlacklistMoviesView from ".";
describe("Blacklist Movies", () => {
it("should render with blacklisted movies", async () => {
server.use(
http.get("/api/system/settings", () => {
return HttpResponse.json({
general: {
theme: "auto",
},
});
}),
);
server.use(
http.get("/api/movies/blacklist", () => {
return HttpResponse.json({
data: [
{
title: "Batman vs Teenage Mutant Ninja Turtles",
radarrId: 50,
provider: "yifysubtitles",
subs_id:
"https://yifysubtitles.ch/subtitles/batman-vs-teenage-mutant-ninja-turtles-2019-english-yify-19252",
language: {
name: "English",
code2: "en",
code3: "eng",
forced: false,
hi: false,
},
timestamp: "28 seconds ago",
parsed_timestamp: "01/23/25 05:39:36",
},
],
});
}),
);
customRender(<BlacklistMoviesView />);
await waitFor(() => {
expect(screen.getByText("yifysubtitles")).toBeInTheDocument();
});
});
it("should render without blacklisted movies", async () => {
server.use(
http.get("/api/movies/blacklist", () => {
return HttpResponse.json({
data: [],
});
}),
);
customRender(<BlacklistMoviesView />);
await waitFor(() => {
expect(
screen.getByText("No blacklisted movies subtitles"),
).toBeInTheDocument();
});
server.resetHandlers();
});
});

View file

@ -82,7 +82,6 @@ const Table: FunctionComponent<Props> = ({ blacklist }) => {
return (
<MutateAction
label="Remove from Blacklist"
noReset
icon={faTrash}
mutation={remove}
args={() => ({

View file

@ -0,0 +1,62 @@
/* eslint-disable camelcase */
import { http } from "msw";
import { HttpResponse } from "msw";
import { customRender, screen, waitFor } from "@/tests";
import server from "@/tests/mocks/node";
import BlacklistSeriesView from ".";
describe("Blacklist Series", () => {
it("should render without blacklisted series", async () => {
server.use(
http.get("/api/episodes/blacklist", () => {
return HttpResponse.json({
data: [],
});
}),
);
customRender(<BlacklistSeriesView />);
await waitFor(() => {
expect(
screen.getByText("No blacklisted series subtitles"),
).toBeInTheDocument();
});
});
it("should render with blacklisted series", async () => {
server.use(
http.get("/api/episodes/blacklist", () => {
// TODO: Replace with Factory
return HttpResponse.json({
data: [
{
seriesTitle: "Dragon Ball DAIMA",
episode_number: "1x14",
episodeTitle: "Taboo",
sonarrSeriesId: 56,
provider: "animetosho",
subs_id:
"https://animetosho.org/storage/attach/0022fd50/2293072.xz",
language: {
name: "English",
code2: "en",
code3: "eng",
forced: false,
hi: false,
},
timestamp: "now",
parsed_timestamp: "01/24/25 01:38:03",
},
],
});
}),
);
customRender(<BlacklistSeriesView />);
await waitFor(() => {
expect(screen.getByText("animetosho")).toBeInTheDocument();
});
});
});

View file

@ -89,7 +89,6 @@ const Table: FunctionComponent<Props> = ({ blacklist }) => {
return (
<MutateAction
label="Remove from Blacklist"
noReset
icon={faTrash}
mutation={removeFromBlacklist}
args={() => ({

View file

@ -1,16 +0,0 @@
import { renderTest, RenderTestCase } from "@/tests/render";
import BlacklistMoviesView from "./Movies";
import BlacklistSeriesView from "./Series";
const cases: RenderTestCase[] = [
{
name: "movie page",
ui: BlacklistMoviesView,
},
{
name: "series page",
ui: BlacklistSeriesView,
},
];
renderTest("Blacklist", cases);

View file

@ -0,0 +1,127 @@
/* eslint-disable camelcase */
import { http } from "msw";
import { HttpResponse } from "msw";
import { customRender, screen, waitFor } from "@/tests";
import server from "@/tests/mocks/node";
import MoviesHistoryView from ".";
const mockMovieHistory = {
data: [
{
action: "download",
title: "The Dark Knight (2008) [1080p.BluRay.x264.DTS-HD.MA.5.1]",
radarrId: 1,
language: { code2: "en", name: "English" },
score: 0.95,
matches: ["scene", "release"],
dont_matches: [],
timestamp: "2024-03-20T10:00:00Z",
parsed_timestamp: "March 20, 2024 10:00:00",
description: "Test description",
upgradable: true,
blacklisted: false,
provider: "opensubtitles",
subs_id: "123",
subtitles_path: "/path/to/subtitles.srt",
},
],
total: 1,
page: 1,
per_page: 10,
};
describe("History Movies", () => {
beforeEach(() => {
server.use(
http.get("/api/movies/history", () => {
return HttpResponse.json(mockMovieHistory);
}),
http.get("/api/providers", () => {
return HttpResponse.json({
data: ["test-provider"],
});
}),
http.get("/api/system/languages", () => {
return HttpResponse.json({
en: { code2: "en", name: "English" },
});
}),
);
});
it("should render the movies history table", async () => {
customRender(<MoviesHistoryView />);
await waitFor(() => {
expect(
screen.getByText(
"The Dark Knight (2008) [1080p.BluRay.x264.DTS-HD.MA.5.1]",
),
).toBeInTheDocument();
});
expect(screen.getByText("Name")).toBeInTheDocument();
expect(screen.getByText("Language")).toBeInTheDocument();
expect(screen.getByText("Score")).toBeInTheDocument();
expect(screen.getByText("Match")).toBeInTheDocument();
expect(screen.getByText("Date")).toBeInTheDocument();
expect(screen.getByText("Info")).toBeInTheDocument();
expect(screen.getByText("Upgradable")).toBeInTheDocument();
expect(screen.getByText("Blacklist")).toBeInTheDocument();
});
it("should display movie information correctly", async () => {
customRender(<MoviesHistoryView />);
await waitFor(() => {
expect(
screen.getByText(
"The Dark Knight (2008) [1080p.BluRay.x264.DTS-HD.MA.5.1]",
),
).toBeInTheDocument();
});
expect(screen.getByText("English")).toBeInTheDocument();
expect(screen.getByText("0.95")).toBeInTheDocument();
});
it("should show blacklist button when movie is not blacklisted", async () => {
customRender(<MoviesHistoryView />);
await waitFor(() => {
expect(screen.getByLabelText("Add to Blacklist")).toBeInTheDocument();
});
});
it("should show empty state when no history is found", async () => {
server.use(
http.get("/api/movies/history", () => {
return HttpResponse.json({
data: [],
total: 0,
page: 1,
per_page: 10,
});
}),
);
customRender(<MoviesHistoryView />);
await waitFor(() => {
expect(
screen.getByText("Nothing Found in Movies History"),
).toBeInTheDocument();
});
});
it("should navigate to movie details when clicking on movie title", async () => {
customRender(<MoviesHistoryView />);
await waitFor(() => {
const movieLink = screen.getByText(
"The Dark Knight (2008) [1080p.BluRay.x264.DTS-HD.MA.5.1]",
);
expect(movieLink).toHaveAttribute("href", "/movies/1");
});
});
});

View file

@ -0,0 +1,48 @@
/* eslint-disable camelcase */
import { http } from "msw";
import { HttpResponse } from "msw";
import { customRender, screen, waitFor } from "@/tests";
import server from "@/tests/mocks/node";
import SeriesHistoryView from ".";
describe("History Series", () => {
it("should render with series", async () => {
server.use(
http.get("/api/episodes/history", () => {
return HttpResponse.json({
data: [
{
seriesTitle: "Breaking Bad",
episode_number: "S05E07",
episodeTitle: "Pilot",
language: { code2: "en", name: "English" },
action: "download",
timestamp: "2023-05-10",
parsed_timestamp: "May 10, 2023",
sonarrSeriesId: 123,
sonarrEpisodeId: 456,
description: "Test description",
score: 100,
matches: [],
dont_matches: [],
upgradable: false,
blacklisted: false,
},
],
page: 1,
totalPages: 1,
totalItems: 1,
});
}),
);
customRender(<SeriesHistoryView />);
await waitFor(() => {
expect(screen.getByText("Breaking Bad")).toBeInTheDocument();
});
expect(screen.getByText("S05E07")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,37 @@
import { http } from "msw";
import { HttpResponse } from "msw";
import { customRender } from "@/tests";
import server from "@/tests/mocks/node";
import HistoryStats from "./HistoryStats";
describe("History Stats", () => {
it("should render without stats", async () => {
server.use(
http.get("/api/providers", () => {
return HttpResponse.json({
data: [],
});
}),
);
server.use(
http.get("/api/system/languages", () => {
return HttpResponse.json({});
}),
);
server.use(
http.get("/api/history/stats", () => {
return HttpResponse.json({
series: [],
});
}),
);
server.use(
http.get("/api/system/providers", () => {
return HttpResponse.json({});
}),
);
customRender(<HistoryStats />);
});
});

View file

@ -1,21 +0,0 @@
import { renderTest, RenderTestCase } from "@/tests/render";
import HistoryStats from "./Statistics/HistoryStats";
import MoviesHistoryView from "./Movies";
import SeriesHistoryView from "./Series";
const cases: RenderTestCase[] = [
{
name: "movie page",
ui: MoviesHistoryView,
},
{
name: "series page",
ui: SeriesHistoryView,
},
{
name: "statistics page",
ui: HistoryStats,
},
];
renderTest("History", cases);

View file

@ -1,16 +1,46 @@
import { describe } from "vitest";
import { render } from "@/tests";
import { http } from "msw";
import { HttpResponse } from "msw";
import { beforeEach, describe, it } from "vitest";
import { customRender, screen } from "@/tests";
import server from "@/tests/mocks/node";
import MovieMassEditor from "./Editor";
import MovieView from ".";
describe("Movies page", () => {
beforeEach(() => {
server.use(
http.get("/api/movies", () => {
return HttpResponse.json({
data: [],
});
}),
);
});
it("should render", () => {
render(<MovieView />);
customRender(<MovieView />);
});
});
describe("Movies editor page", () => {
beforeEach(() => {
server.use(
http.get("/api/movies", () => {
return HttpResponse.json({
data: [],
});
}),
);
server.use(
http.get("/api/system/languages/profiles", () => {
return HttpResponse.json([]);
}),
);
});
it("should render", () => {
render(<MovieMassEditor />);
customRender(<MovieMassEditor />);
expect(screen.getByText("Actions")).toBeInTheDocument();
});
});

View file

@ -1,16 +1,44 @@
import { describe } from "vitest";
import { render } from "@/tests";
import { http } from "msw";
import { HttpResponse } from "msw";
import { beforeEach, describe, it } from "vitest";
import { customRender } from "@/tests";
import server from "@/tests/mocks/node";
import SeriesMassEditor from "./Editor";
import SeriesView from ".";
describe("Series page", () => {
beforeEach(() => {
server.use(
http.get("/api/series", () => {
return HttpResponse.json({
data: [],
});
}),
);
});
it("should render", () => {
render(<SeriesView />);
customRender(<SeriesView />);
});
});
describe("Series editor page", () => {
beforeEach(() => {
server.use(
http.get("/api/series", () => {
return HttpResponse.json({
data: [],
});
}),
);
server.use(
http.get("/api/system/languages/profiles", () => {
return HttpResponse.json([]);
}),
);
});
it("should render", () => {
render(<SeriesMassEditor />);
customRender(<SeriesMassEditor />);
});
});

View file

@ -1,11 +1,11 @@
import { Text } from "@mantine/core";
import { describe, it } from "vitest";
import { render, screen } from "@/tests";
import { customRender, screen } from "@/tests";
import Layout from "./Layout";
describe("Settings layout", () => {
it.concurrent("should be able to render without issues", () => {
render(
customRender(
<Layout name="Test Settings">
<Text>Value</Text>
</Layout>,
@ -13,7 +13,7 @@ describe("Settings layout", () => {
});
it.concurrent("save button should be disabled by default", () => {
render(
customRender(
<Layout name="Test Settings">
<Text>Value</Text>
</Layout>,

View file

@ -1,12 +1,13 @@
import { Text } from "@mantine/core";
import { describe, it } from "vitest";
import { render, screen } from "@/tests";
import { customRender, screen } from "@/tests";
import { Section } from "./Section";
describe("Settings section", () => {
const header = "Section Header";
it("should show header", () => {
render(<Section header="Section Header"></Section>);
customRender(<Section header="Section Header"></Section>);
expect(screen.getByText(header)).toBeDefined();
expect(screen.getByRole("separator")).toBeDefined();
@ -14,7 +15,7 @@ describe("Settings section", () => {
it("should show children", () => {
const text = "Section Child";
render(
customRender(
<Section header="Section Header">
<Text>{text}</Text>
</Section>,
@ -26,7 +27,7 @@ describe("Settings section", () => {
it("should work with hidden", () => {
const text = "Section Child";
render(
customRender(
<Section header="Section Header" hidden>
<Text>{text}</Text>
</Section>,

View file

@ -2,7 +2,7 @@ import { FunctionComponent, PropsWithChildren, ReactElement } from "react";
import { useForm } from "@mantine/form";
import { describe, it } from "vitest";
import { FormContext, FormValues } from "@/pages/Settings/utilities/FormValues";
import { render, screen } from "@/tests";
import { customRender, screen } from "@/tests";
import { Number, Text } from "./forms";
const FormSupport: FunctionComponent<PropsWithChildren> = ({ children }) => {
@ -16,7 +16,7 @@ const FormSupport: FunctionComponent<PropsWithChildren> = ({ children }) => {
};
const formRender = (ui: ReactElement) =>
render(<FormSupport>{ui}</FormSupport>);
customRender(<FormSupport>{ui}</FormSupport>);
describe("Settings form", () => {
describe("number component", () => {

View file

@ -1,11 +1,11 @@
import { http } from "msw";
import { HttpResponse } from "msw";
import server from "@/tests/mocks/node";
import { renderTest, RenderTestCase } from "@/tests/render";
import SettingsGeneralView from "./General";
import SettingsLanguagesView from "./Languages";
import SettingsNotificationsView from "./Notifications";
import SettingsProvidersView from "./Providers";
import SettingsRadarrView from "./Radarr";
import SettingsSchedulerView from "./Scheduler";
import SettingsSonarrView from "./Sonarr";
import SettingsSubtitlesView from "./Subtitles";
import SettingsUIView from "./UI";
@ -17,27 +17,32 @@ const cases: RenderTestCase[] = [
{
name: "languages page",
ui: SettingsLanguagesView,
setupEach: () => {
server.use(
http.get("/api/system/languages", () => {
return HttpResponse.json({});
}),
);
server.use(
http.get("/api/system/languages/profiles", () => {
return HttpResponse.json({
data: [],
});
}),
);
},
},
{
name: "notifications page",
ui: SettingsNotificationsView,
},
// TODO: Test Notifications Page
{
name: "providers page",
ui: SettingsProvidersView,
},
{
name: "radarr page",
ui: SettingsRadarrView,
},
// TODO: Test Radarr Page
{
name: "scheduler page",
ui: SettingsSchedulerView,
},
{
name: "sonarr page",
ui: SettingsSonarrView,
},
// TODO: Test Sonarr Page
{
name: "subtitles page",
ui: SettingsSubtitlesView,

View file

@ -0,0 +1,67 @@
import { http } from "msw";
import { HttpResponse } from "msw";
import { customRender, screen, waitFor } from "@/tests";
import server from "@/tests/mocks/node";
import SystemAnnouncementsView from ".";
describe("System Announcements", () => {
it("should render with empty announcements", async () => {
server.use(
http.get("/api/system/announcements", () => {
return HttpResponse.json({
data: [],
});
}),
);
customRender(<SystemAnnouncementsView />);
await waitFor(() => {
expect(
screen.getByText(/No announcements for now, come back later!/i),
).toBeInTheDocument();
});
});
it("should render with announcements", async () => {
const mockAnnouncements = [
{
text: "New Subtitle Provider!",
dismissible: true,
},
{
text: "Python Deprecated!",
dismissible: false,
},
];
server.use(
http.get("/api/system/announcements", () => {
return HttpResponse.json({
data: mockAnnouncements,
});
}),
);
customRender(<SystemAnnouncementsView />);
await waitFor(() => {
expect(screen.getByText("New Subtitle Provider!")).toBeInTheDocument();
});
expect(screen.getByText("Python Deprecated!")).toBeInTheDocument();
const dismissButtons = screen.getAllByLabelText("Dismiss announcement");
const dismissableButton = dismissButtons.find((button) =>
button.closest("tr")?.textContent?.includes("New Subtitle Provider!"),
);
const nonDismissableButton = dismissButtons.find((button) =>
button.closest("tr")?.textContent?.includes("Python Deprecated!"),
);
expect(dismissableButton).not.toBeDisabled();
expect(nonDismissableButton).toBeDisabled();
});
});

View file

@ -0,0 +1,21 @@
import { http } from "msw";
import { HttpResponse } from "msw";
import { customRender } from "@/tests";
import server from "@/tests/mocks/node";
import SystemBackupsView from ".";
describe("System Backups", () => {
it("should render with backups", async () => {
server.use(
http.get("/api/system/backups", () => {
return HttpResponse.json({
data: [],
});
}),
);
customRender(<SystemBackupsView />);
// TODO: Assert
});
});

View file

@ -0,0 +1,21 @@
import { http } from "msw";
import { HttpResponse } from "msw";
import { customRender } from "@/tests";
import server from "@/tests/mocks/node";
import SystemLogsView from ".";
describe("System Logs", () => {
it("should render with logs", async () => {
server.use(
http.get("/api/system/logs", () => {
return HttpResponse.json({
data: [],
});
}),
);
customRender(<SystemLogsView />);
// TODO: Assert
});
});

View file

@ -0,0 +1,55 @@
import { http } from "msw";
import { HttpResponse } from "msw";
import { customRender, screen, waitFor } from "@/tests";
import server from "@/tests/mocks/node";
import SystemProvidersView from ".";
describe("System Providers", () => {
it("should render with providers", async () => {
server.use(
http.get("/api/providers", () => {
return HttpResponse.json({
data: [
{ name: "OpenSubtitles", status: "active", retry: "0" },
{ name: "Subscene", status: "inactive", retry: "3" },
{ name: "Addic7ed", status: "disabled", retry: "1" },
],
});
}),
);
customRender(<SystemProvidersView />);
await waitFor(() => {
expect(screen.getByText("OpenSubtitles")).toBeInTheDocument();
});
expect(screen.getByText("OpenSubtitles")).toBeInTheDocument();
expect(screen.getByText("Subscene")).toBeInTheDocument();
expect(screen.getByText("Addic7ed")).toBeInTheDocument();
expect(screen.getByText("active")).toBeInTheDocument();
expect(screen.getByText("inactive")).toBeInTheDocument();
expect(screen.getByText("disabled")).toBeInTheDocument();
expect(screen.getByText("0")).toBeInTheDocument();
expect(screen.getByText("3")).toBeInTheDocument();
expect(screen.getByText("1")).toBeInTheDocument();
// Verify toolbar buttons are present
expect(screen.getByText("Refresh")).toBeInTheDocument();
expect(screen.getByText("Reset")).toBeInTheDocument();
});
it("should render with no providers", async () => {
server.use(
http.get("/api/providers", () => {
return HttpResponse.json({
data: [],
});
}),
);
customRender(<SystemProvidersView />);
});
});

View file

@ -0,0 +1,79 @@
import { http } from "msw";
import { HttpResponse } from "msw";
import { customRender, screen } from "@/tests";
import server from "@/tests/mocks/node";
import SystemReleasesView from ".";
describe("System Releases", () => {
it("should render with releases", async () => {
const mockReleases = [
{
name: "v1.0.0",
body: [
"Added support for embedded subtitles in MKV files",
"Improved subtitle synchronization accuracy",
],
date: "2024-03-20",
prerelease: false,
current: true,
},
{
name: "v1.1.0-beta",
body: [
"Added support for multiple subtitle providers",
"Enhanced subtitle language detection",
],
date: "2024-03-21",
prerelease: true,
current: false,
},
];
server.use(
http.get("/api/system/releases", () => {
return HttpResponse.json({
data: mockReleases,
});
}),
);
customRender(<SystemReleasesView />);
await screen.findByText("v1.0.0");
await screen.findByText("v1.1.0-beta");
expect(screen.getByText("v1.0.0")).toBeInTheDocument();
expect(screen.getByText("v1.1.0-beta")).toBeInTheDocument();
expect(screen.getByText("2024-03-20")).toBeInTheDocument();
expect(screen.getByText("2024-03-21")).toBeInTheDocument();
expect(screen.getByText("Master")).toBeInTheDocument();
expect(screen.getByText("Development")).toBeInTheDocument();
expect(screen.getByText("Installed")).toBeInTheDocument();
expect(
screen.getByText("Added support for embedded subtitles in MKV files"),
).toBeInTheDocument();
expect(
screen.getByText("Improved subtitle synchronization accuracy"),
).toBeInTheDocument();
expect(
screen.getByText("Added support for multiple subtitle providers"),
).toBeInTheDocument();
expect(
screen.getByText("Enhanced subtitle language detection"),
).toBeInTheDocument();
});
it("should render empty state when no releases", async () => {
server.use(
http.get("/api/system/releases", () => {
return HttpResponse.json({
data: [],
});
}),
);
customRender(<SystemReleasesView />);
expect(screen.queryByRole("card")).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,29 @@
import { http } from "msw";
import { HttpResponse } from "msw";
import { customRender } from "@/tests";
import server from "@/tests/mocks/node";
import SystemStatusView from ".";
describe("System Status", () => {
it("should render with status", async () => {
server.use(
http.get("/api/system/status", () => {
return HttpResponse.json({
data: [],
});
}),
);
server.use(
http.get("/api/system/health", () => {
return HttpResponse.json({
data: [],
});
}),
);
customRender(<SystemStatusView />);
// TODO: Assert
});
});

View file

@ -0,0 +1,69 @@
/* eslint-disable camelcase */
import { http } from "msw";
import { HttpResponse } from "msw";
import { customRender, screen, waitFor } from "@/tests";
import server from "@/tests/mocks/node";
import SystemTasksView from ".";
describe("System Tasks", () => {
it("should render without tasks", async () => {
server.use(
http.get("/api/system/tasks", () => {
return HttpResponse.json({
data: [],
});
}),
);
customRender(<SystemTasksView />);
await waitFor(() => {
expect(screen.getByText("Refresh")).toBeInTheDocument();
});
});
it("should render with system tasks", async () => {
const mockTasks = [
{
name: "Scan Series",
interval: "1 hour",
next_run_in: "30 minutes",
job_id: "series_scan",
job_running: false,
},
{
name: "Scan Movies",
interval: "1 hour",
next_run_in: "45 minutes",
job_id: "movies_scan",
job_running: true,
},
];
server.use(
http.get("/api/system/tasks", () => {
return HttpResponse.json({
data: mockTasks,
});
}),
);
customRender(<SystemTasksView />);
await waitFor(() => {
expect(screen.getByText("Scan Series")).toBeInTheDocument();
});
expect(screen.getByText("Scan Movies")).toBeInTheDocument();
expect(screen.getByText("30 minutes")).toBeInTheDocument();
expect(screen.getByText("45 minutes")).toBeInTheDocument();
expect(screen.getByText("Name")).toBeInTheDocument();
expect(screen.getByText("Interval")).toBeInTheDocument();
expect(screen.getByText("Next Execution")).toBeInTheDocument();
expect(screen.getByText("Run")).toBeInTheDocument();
const runButtons = screen.getAllByLabelText("Run Job");
expect(runButtons).toHaveLength(2);
});
});

View file

@ -1,41 +0,0 @@
import SystemAnnouncementsView from "@/pages/System/Announcements";
import { renderTest, RenderTestCase } from "@/tests/render";
import SystemBackupsView from "./Backups";
import SystemLogsView from "./Logs";
import SystemProvidersView from "./Providers";
import SystemReleasesView from "./Releases";
import SystemStatusView from "./Status";
import SystemTasksView from "./Tasks";
const cases: RenderTestCase[] = [
{
name: "backups page",
ui: SystemBackupsView,
},
{
name: "logs page",
ui: SystemLogsView,
},
{
name: "providers page",
ui: SystemProvidersView,
},
{
name: "releases page",
ui: SystemReleasesView,
},
{
name: "status page",
ui: SystemStatusView,
},
{
name: "tasks page",
ui: SystemTasksView,
},
{
name: "announcements page",
ui: SystemAnnouncementsView,
},
];
renderTest("System", cases);

View file

@ -0,0 +1,62 @@
/* eslint-disable camelcase */
import { http } from "msw";
import { HttpResponse } from "msw";
import { customRender, screen } from "@/tests";
import server from "@/tests/mocks/node";
import WantedMoviesView from ".";
describe("Wanted Movies", () => {
it("should render with wanted movies", async () => {
const mockMovies = [
{
title: "The Shawshank Redemption",
radarrId: 1,
missing_subtitles: [
{
code2: "en",
name: "English",
hi: false,
forced: false,
},
],
},
];
server.use(
http.get("/api/movies/wanted", () => {
return HttpResponse.json({
data: mockMovies,
});
}),
);
customRender(<WantedMoviesView />);
const movieTitle = await screen.findByText("The Shawshank Redemption");
expect(movieTitle).toBeInTheDocument();
const movieLink = screen.getByRole("link", {
name: "The Shawshank Redemption",
});
expect(movieLink).toHaveAttribute("href", "/movies/1");
});
it("should render empty state when no wanted movies", async () => {
server.use(
http.get("/api/movies/wanted", () => {
return HttpResponse.json({
data: [],
});
}),
);
customRender(<WantedMoviesView />);
const table = await screen.findByRole("table");
expect(table).toBeInTheDocument();
const movieTitle = screen.queryByText("The Shawshank Redemption");
expect(movieTitle).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,67 @@
/* eslint-disable camelcase */
import { http } from "msw";
import { HttpResponse } from "msw";
import { customRender, screen } from "@/tests";
import server from "@/tests/mocks/node";
import WantedSeriesView from ".";
describe("Wanted Series", () => {
it("should render with wanted series", async () => {
const mockData = {
data: [
{
sonarrSeriesId: 1,
sonarrEpisodeId: 101,
seriesTitle: "Breaking Bad",
episode_number: "S01E01",
episodeTitle: "Pilot",
missing_subtitles: [
{
code2: "en",
name: "English",
hi: false,
forced: false,
},
],
},
],
total: 1,
page: 1,
per_page: 10,
};
server.use(
http.get("/api/episodes/wanted", () => {
return HttpResponse.json(mockData);
}),
);
customRender(<WantedSeriesView />);
await screen.findByText("Breaking Bad");
expect(screen.getByText("Name")).toBeInTheDocument();
expect(screen.getByText("Episode")).toBeInTheDocument();
expect(screen.getByText("Missing")).toBeInTheDocument();
expect(screen.getByText("Breaking Bad")).toBeInTheDocument();
expect(screen.getByText("S01E01")).toBeInTheDocument();
expect(screen.getByText("Pilot")).toBeInTheDocument();
});
it("should render empty state when no wanted series", async () => {
server.use(
http.get("/api/episodes/wanted", () => {
return HttpResponse.json({
data: [],
total: 0,
page: 1,
per_page: 10,
});
}),
);
customRender(<WantedSeriesView />);
await screen.findByText(/No missing Series subtitles/i);
});
});

View file

@ -1,16 +0,0 @@
import { renderTest, RenderTestCase } from "@/tests/render";
import WantedMoviesView from "./Movies";
import WantedSeriesView from "./Series";
const cases: RenderTestCase[] = [
{
name: "movie page",
ui: WantedMoviesView,
},
{
name: "series page",
ui: WantedSeriesView,
},
];
renderTest("Wanted", cases);

View file

@ -1,22 +1,22 @@
import { render } from "@/tests";
import { customRender } from "@/tests";
import CriticalError from "./CriticalError";
import NotFound from "./NotFound";
import UIError from "./UIError";
describe("Not found page", () => {
it("should display message", () => {
render(<NotFound />);
customRender(<NotFound />);
});
});
describe("Critical error page", () => {
it("should disable error", () => {
render(<CriticalError message="Test error"></CriticalError>);
customRender(<CriticalError message="Test error"></CriticalError>);
});
});
describe("UI error page", () => {
it("should disable error", () => {
render(<UIError error={new Error("Test error")}></UIError>);
customRender(<UIError error={new Error("Test error")}></UIError>);
});
});

View file

@ -57,14 +57,6 @@ function MassEditor<T extends Item.Base>(props: MassEditorProps<T>) {
];
}, [profileOptions.options]);
const getKey = useCallback((value: Language.Profile | null) => {
if (value) {
return value.name;
}
return "Clear";
}, []);
const { mutateAsync } = mutation;
/**
@ -136,7 +128,6 @@ function MassEditor<T extends Item.Base>(props: MassEditorProps<T>) {
placeholder="Change Profile"
withCheckIcon={false}
options={profileOptionsWithAction}
getkey={getKey}
disabled={selections.length === 0}
comboboxProps={{
store: combobox,

View file

@ -35,6 +35,7 @@ const customRender = (
// re-export everything
export * from "@testing-library/react";
// override render method
export { customRender as render };
export { customRender };
export { render as rawRender };

View file

@ -0,0 +1,5 @@
import { setupServer } from "msw/node";
const server = setupServer();
export default server;

View file

@ -1,16 +1,23 @@
import { FunctionComponent } from "react";
import { render } from ".";
import { customRender } from ".";
export interface RenderTestCase {
name: string;
ui: FunctionComponent;
setupEach?: () => void;
}
export function renderTest(name: string, cases: RenderTestCase[]) {
describe(name, () => {
beforeEach(() => {
cases.forEach((element) => {
element.setupEach?.();
});
});
cases.forEach((element) => {
it(`${element.name.toLowerCase()} should render`, () => {
render(<element.ui />);
customRender(<element.ui />);
});
});
});

View file

@ -1,30 +0,0 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import { vitest } from "vitest";
import "@testing-library/jest-dom";
// From https://stackoverflow.com/questions/39830580/jest-test-fails-typeerror-window-matchmedia-is-not-a-function
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vitest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vitest.fn(), // Deprecated
removeListener: vitest.fn(), // Deprecated
addEventListener: vitest.fn(),
removeEventListener: vitest.fn(),
dispatchEvent: vitest.fn(),
})),
});
// From https://github.com/mantinedev/mantine/blob/master/configuration/jest/jsdom.mocks.js
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
window.ResizeObserver = ResizeObserver;
window.scrollTo = () => {};

View file

@ -0,0 +1,70 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import { http } from "msw";
import { HttpResponse } from "msw";
import { vi, vitest } from "vitest";
import "@testing-library/jest-dom";
import queryClient from "@/apis/queries";
import server from "./mocks/node";
vi.mock("recharts", async () => {
const OriginalRechartsModule = await vi.importActual("recharts");
return {
...OriginalRechartsModule,
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
<div style={{ width: "100%", height: "100%" }}>{children}</div>
),
};
});
// From https://stackoverflow.com/questions/39830580/jest-test-fails-typeerror-window-matchmedia-is-not-a-function
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vitest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vitest.fn(), // Deprecated
removeListener: vitest.fn(), // Deprecated
addEventListener: vitest.fn(),
removeEventListener: vitest.fn(),
dispatchEvent: vitest.fn(),
})),
});
// From https://github.com/mantinedev/mantine/blob/master/configuration/jest/jsdom.mocks.js
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
window.ResizeObserver = ResizeObserver;
window.scrollTo = () => {};
beforeAll(() => {
server.listen({ onUnhandledRequest: "error" });
});
beforeEach(() => {
server.resetHandlers();
server.use(
http.get("/api/system/settings", () => {
return HttpResponse.json({
general: {
theme: "auto",
},
});
}),
);
});
afterEach(() => {
server.resetHandlers();
queryClient.clear();
});
afterAll(() => server.close());

View file

@ -6,6 +6,10 @@ import { isProdEnv } from ".";
type LoggerType = "info" | "warning" | "error";
export function LOG(type: LoggerType, msg: string, ...payload: any[]) {
if (import.meta.env.MODE === "test") {
return;
}
if (!isProdEnv) {
let logger = console.log;
if (type === "warning") {

View file

@ -133,7 +133,7 @@ export default defineConfig(({ mode, command }) => {
test: {
globals: true,
environment: "jsdom",
setupFiles: "./src/tests/setup.ts",
setupFiles: "./src/tests/setup.tsx",
},
server: {
proxy: {