Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
"extends": ["next/core-web-vitals", "next/typescript"],
"plugins": ["testing-library", "jest-dom"],
"overrides": [
{
"files": [
"**/__tests__/**/*.{js,jsx,ts,tsx}",
"**/?(*.)+(spec|test).[jt]s?(x)"
],
"extends": ["plugin:testing-library/react", "plugin:jest-dom/recommended"]
}
]
}
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: CI

on:
push:
branches:
- "**"
pull_request:
branches:
- "**"
workflow_dispatch:

jobs:
build-and-test:
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"

- name: Install dependencies
run: npm ci

- name: Prettier check
run: npm run prettier:check

- name: ESLint
run: npm run lint

- name: Run tests
run: npm test
8 changes: 8 additions & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

npm run prettier

npx --yes lint-staged

npm test
71 changes: 71 additions & 0 deletions __tests__/components/Header.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Header } from "@/components/Header";

const mockPush = jest.fn();

jest.mock("next/navigation", () => {
const actual = jest.requireActual("next/dist/client/components/navigation");
return {
...actual,
useRouter: () => ({ push: mockPush }),
};
});

describe("Header", () => {
beforeEach(() => {
mockPush.mockReset();
// default fetch mock
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ items: [] }),
}) as any;
});

it("navigates on Enter with non-empty value", async () => {
render(<Header />);
const input = screen.getByPlaceholderText("Enter faceit username");
await userEvent.type(input, "s1mple{enter}");
expect(mockPush).toHaveBeenCalledWith("/profile?search=s1mple");
});

it("clears input and results on clear button click", async () => {
render(<Header />);
const input = screen.getByPlaceholderText("Enter faceit username");
await userEvent.type(input, "device");

const clear = screen.getByRole("button");
await userEvent.click(clear);

// After clearing, input should be empty
expect((input as HTMLInputElement).value).toBe("");
});

it("shows fetched suggestions when typing more than 2 chars", async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => ({
items: [{ player_id: "1", nickname: "navi", avatar: "/ava.png" }],
}),
});

render(<Header />);
const input = screen.getByPlaceholderText("Enter faceit username");
await userEvent.type(input, "navi");

await waitFor(() => expect(screen.getByText("navi")).toBeInTheDocument());
});

it("renders error message when fetch fails", async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
status: 500,
statusText: "Server Error",
});
render(<Header />);
const input = screen.getByPlaceholderText("Enter faceit username");
await userEvent.type(input, "abc");

await screen.findByText(/Error:/);
});
});
68 changes: 68 additions & 0 deletions __tests__/components/Main.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { render, screen, waitFor } from "@testing-library/react";
import { Main } from "@/components/Main";

describe("Main", () => {
beforeEach(() => {
jest.resetAllMocks();
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({
cover_image: "/cover.jpg",
country: "ua",
avatar: "/ava.jpg",
player_id: "pid",
games: {
cs2: {
faceit_elo: 2000,
skill_level: 8,
region: "eu",
game_player_name: "nick",
},
csgo: {
faceit_elo: 0,
skill_level: 0,
region: "eu",
game_player_name: "nick",
},
},
steam_id_64: "123",
}),
}) as any;
});

it("fetches user data on username change and updates state callbacks", async () => {
const setGames = jest.fn();
const setUserId = jest.fn();
render(
<Main username="s1mple" setGames={setGames} setUserId={setUserId} />,
);

await waitFor(() =>
expect(screen.getByText(/Current ELO:/)).toBeInTheDocument(),
);
expect(setGames).toHaveBeenCalledWith(
expect.objectContaining({ cs2: expect.any(Object) }),
);
expect(setUserId).toHaveBeenCalledWith("pid");
});

it("does not fetch when username is empty", async () => {
render(<Main username="" setUserId={jest.fn()} />);
expect(global.fetch).not.toHaveBeenCalled();
});

it("handles network error gracefully", async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
status: 500,
statusText: "err",
});
const setUserId = jest.fn();
render(<Main username="err" setUserId={setUserId} />);
// Should render empty fragment when error, no crash
await waitFor(() => {
// No username visible
expect(screen.queryByText("err")).not.toBeInTheDocument();
});
});
});
28 changes: 28 additions & 0 deletions __tests__/components/PlayerAvaSearch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import PlayerAvaSearch from "@/components/PlayerAvaSearch";

describe("PlayerAvaSearch", () => {
it("renders provided avatar", () => {
render(<PlayerAvaSearch avatar="/avatar.png" nickname="nick" />);
const img = screen.getByAltText("nick") as HTMLImageElement;
expect(img).toBeInTheDocument();
expect(img.src).toContain("/avatar.png");
});

it("falls back to default image on error", async () => {
render(<PlayerAvaSearch avatar="/broken.png" nickname="nick" />);
const img = screen.getByAltText("nick") as HTMLImageElement;
// trigger onError
img.dispatchEvent(new Event("error"));

// After error, fallback image should appear
const fallback = await screen.findByAltText("avatar");
expect(fallback).toBeInTheDocument();
});

it("renders fallback when avatar is empty", () => {
render(<PlayerAvaSearch avatar="" nickname="nick" />);
expect(screen.getByAltText("avatar")).toBeInTheDocument();
});
});
21 changes: 21 additions & 0 deletions __tests__/pages/root.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { render, screen } from "@testing-library/react";
import Home from "@/app/page";
import RootLayout from "@/app/layout";

describe("Root pages", () => {
it("renders home with Header", () => {
render(<Home />);
expect(screen.getByText(/Faceit-Next/)).toBeInTheDocument();
});

it("wraps children with layout html structure", () => {
render(
<RootLayout>
<div>child</div>
</RootLayout>,
);
expect(screen.getByText("child")).toBeInTheDocument();
// Ensure html scaffolding exists
expect(document.documentElement.lang).toBe("en");
});
});
Loading