Commit current project

This commit is contained in:
2026-03-29 22:44:13 +02:00
parent b3bccb2ae3
commit 7f9469c07d
77 changed files with 20495 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import WatchlistManager from '../../src/components/WatchlistManager';
import { addWatchedStreamer } from '../../src/lib/streamMonitor';
describe('WatchlistManager component', () => {
beforeEach(() => {
localStorage.clear();
});
it('adds and removes a watched streamer using localStorage', async () => {
const availableChannels = [
{ id: '1', name: 'chan1', displayName: 'Channel One', title: '', profileImageUrl: '' },
{ id: '2', name: 'chan2', displayName: 'Channel Two', title: '', profileImageUrl: '' },
];
const onClose = vi.fn();
// Programmatically add one streamer to localStorage before rendering
addWatchedStreamer({ id: '1', name: 'chan1', displayName: 'Channel One' });
render(<WatchlistManager availableChannels={availableChannels} onClose={onClose} />);
// The watched streamer should appear
expect(screen.queryByText('No streamers being watched yet')).not.toBeInTheDocument();
expect(screen.getByText('Channel One')).toBeInTheDocument();
// localStorage should contain the saved streamer
const saved = JSON.parse(localStorage.getItem('mixchat_watched_streamers') || '[]');
expect(saved).toHaveLength(1);
expect(saved[0].id).toBe('1');
// Remove the streamer via the UI
const removeButton = screen.getByTitle('Remove from watchlist');
await userEvent.click(removeButton);
// Should be empty again (wait for DOM update)
await screen.findByText('No streamers being watched yet');
const savedAfter = JSON.parse(localStorage.getItem('mixchat_watched_streamers') || '[]');
expect(savedAfter).toHaveLength(0);
});
});

View File

@@ -0,0 +1,43 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import YouTubeLinker from '../../src/components/YouTubeLinker';
import { addYoutubeLink } from '../../src/lib/youtubeLinks';
describe('YouTubeLinker component', () => {
beforeEach(() => {
localStorage.clear();
});
it('displays an existing link and allows removal', async () => {
const availableChannels = [
{ id: 'a', name: 'chanA', displayName: 'Channel A', title: 'Channel A title', profileImageUrl: '' },
{ id: 'b', name: 'chanB', displayName: 'Channel B', title: 'Channel B title', profileImageUrl: '' },
];
// Programmatically add a link
addYoutubeLink({
twitchChannelId: 'a',
twitchDisplayName: 'Channel A',
youtubeUrl: 'https://www.youtube.com/@channelA',
});
const onClose = vi.fn();
render(<YouTubeLinker availableChannels={availableChannels} onClose={onClose} />);
// The linked item should appear
expect(screen.getByText('Channel A')).toBeInTheDocument();
expect(screen.getByText('https://www.youtube.com/@channelA')).toBeInTheDocument();
// Remove via UI
const removeButton = screen.getByTitle('Remove YouTube link');
await userEvent.click(removeButton);
// Now the empty message should be shown
await screen.findByText('No YouTube channels linked yet');
const saved = JSON.parse(localStorage.getItem('mixchat_youtube_links') || '[]');
expect(saved).toHaveLength(0);
});
});

View File

@@ -0,0 +1,122 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { getFollowedChannels } from "../../src/lib/twitch";
describe("getFollowedChannels pagination + enrichment", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns only online channels with profile images and viewer counts", async () => {
// We'll simulate multiple fetch responses depending on the URL
const fetchMock: any = vi.fn().mockImplementation(async (input: any) => {
const url =
typeof input === "string" ? input : input.url?.toString() || "";
if (url.includes("/channels/followed")) {
// Serve two pages: first with cursor, second without
if (!fetchMock.page1Served) {
fetchMock.page1Served = true;
return {
ok: true,
json: async () => ({
data: [
{
broadcaster_id: "10",
broadcaster_login: "ch10",
broadcaster_name: "Ch10",
game_name: "Game1",
thumbnail_url: "thumb1",
},
{
broadcaster_id: "20",
broadcaster_login: "ch20",
broadcaster_name: "Ch20",
game_name: "Game2",
thumbnail_url: "thumb2",
},
],
pagination: { cursor: "CURSOR1" },
}),
};
}
// second page
return {
ok: true,
json: async () => ({
data: [
{
broadcaster_id: "30",
broadcaster_login: "ch30",
broadcaster_name: "Ch30",
game_name: "Game3",
thumbnail_url: "thumb3",
},
],
pagination: {},
}),
};
}
if (url.includes("/users?")) {
// User profile enrichment
return {
ok: true,
json: async () => ({
data: [
{ id: "10", profile_image_url: "p10" },
{ id: "20", profile_image_url: "p20" },
{ id: "30", profile_image_url: "p30" },
],
}),
};
}
if (url.includes("/streams?")) {
// Streams: only user 20 is live
return {
ok: true,
json: async () => ({
data: [
{
user_id: "20",
viewer_count: 50,
title: "Live Now",
started_at: "2024-01-01T00:00:00Z",
},
],
}),
};
}
// default
return { ok: false, status: 404 };
});
vi.stubGlobal("fetch", fetchMock as any);
const result = await getFollowedChannels("token", "client", "me");
// Only channel with id '20' should be online
expect(result).toHaveLength(1);
const ch = result[0];
expect(ch.id).toBe("20");
expect(ch.profileImageUrl).toBe("p20");
expect(ch.viewerCount).toBe(50);
});
it("throws when channels endpoint returns 401 Unauthorized", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({ ok: false, status: 401 }),
);
await expect(getFollowedChannels("token", "client", "me")).rejects.toThrow(
"Twitch API error: 401 - Unauthorized. Token may be expired or invalid.",
);
});
});

72
test/lib/twitch.test.ts Normal file
View File

@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { getStreamStatuses, getRecentMessages } from "../../src/lib/twitch";
describe("twitch lib mocked HTTP tests", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("getStreamStatuses returns correct live/offline mapping", async () => {
// Stub fetch to return one live stream for user '1'
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: [
{
user_id: "1",
title: "Live Stream",
viewer_count: 123,
started_at: "2024-01-01T00:00:00Z",
},
],
}),
}),
);
const result = await getStreamStatuses("token", "client", ["1", "2"]);
expect(result["1"]).toBeDefined();
expect(result["1"].isLive).toBe(true);
expect(result["1"].title).toBe("Live Stream");
expect(result["1"].viewerCount).toBe(123);
expect(result["2"]).toBeDefined();
expect(result["2"].isLive).toBe(false);
});
it("getRecentMessages parses raw IRC messages", async () => {
const rawMsg =
"@badge-info=;badges=moderator/1;color=#1E90FF;display-name=Mod;emotes=;tmi-sent-ts=1620000000000;user-id=456 :mod!mod@mod.tmi.twitch.tv PRIVMSG #channel :Hello there";
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ messages: [rawMsg] }),
}),
);
const messages = await getRecentMessages("channel");
expect(messages).toHaveLength(1);
const m = messages[0];
expect(m.author).toBe("mod");
expect(m.content).toBe("Hello there");
expect(m.userId).toBe("456");
expect(m.isPreloaded).toBe(true);
});
it("getRecentMessages returns empty array on non-ok response", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({ ok: false, status: 500 }),
);
const messages = await getRecentMessages("channel");
expect(messages).toEqual([]);
});
});

70
test/lib/youtube.test.ts Normal file
View File

@@ -0,0 +1,70 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { getYoutubeUser } from "../../src/lib/youtube";
describe("getYoutubeUser", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns mapped user on success", async () => {
const mockResponse = {
items: [
{
id: "chan1",
snippet: {
title: "Channel Title",
thumbnails: { default: { url: "http://example.com/avatar.png" } },
},
},
],
};
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => mockResponse,
}),
);
const result = await getYoutubeUser("token-abc");
expect(result).toEqual({
userId: "chan1",
displayName: "Channel Title",
profileImageUrl: "http://example.com/avatar.png",
});
});
it("throws when response not ok", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
statusText: "Unauthorized",
}),
);
await expect(getYoutubeUser("bad-token")).rejects.toThrow(
"Failed to get YouTube user: Unauthorized",
);
});
it("throws when no items returned", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ items: [] }),
}),
);
await expect(getYoutubeUser("token-xyz")).rejects.toThrow(
"No YouTube channel found",
);
});
});

View File

@@ -0,0 +1,55 @@
import { vi, describe, it, expect, beforeEach } from "vitest";
// Mock the youtube lib before importing the handler
vi.mock("../../../../src/lib/youtube", () => {
return {
getYoutubeAccessToken: vi.fn().mockResolvedValue({
access_token: "access123",
refresh_token: "refresh123",
expires_in: 3600,
token_type: "Bearer",
}),
getYoutubeUser: vi.fn().mockResolvedValue({
userId: "user123",
displayName: "Test User",
profileImageUrl: "http://example.com/img.png",
}),
};
});
describe("YouTube callback route", () => {
beforeEach(() => {
vi.resetAllMocks();
});
it("sets cookies and returns session HTML", async () => {
const { GET } = await import("../../../../src/pages/auth/youtube/callback");
const cookies = { set: vi.fn() } as any;
const url = new URL("http://localhost/?code=abc123&state=test_state");
const res: Response = await GET({ url, cookies } as any);
expect(res).toBeDefined();
expect((res as any).status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("text/html");
const body = await res.text();
expect(body).toContain("mixchat_sessions");
expect(body).toContain("user123");
expect(body).toContain("access123");
expect(cookies.set).toHaveBeenCalled();
expect(cookies.set).toHaveBeenCalledWith(
"youtube_token",
"access123",
expect.any(Object),
);
expect(cookies.set).toHaveBeenCalledWith(
"youtube_refresh_token",
"refresh123",
expect.any(Object),
);
});
});

19
test/setup.ts Normal file
View File

@@ -0,0 +1,19 @@
import { expect, type Assertion, type AsymmetricMatchersContaining } from 'vitest';
import * as matchers from '@testing-library/jest-dom/matchers';
import { type TestingLibraryMatchers } from '@testing-library/jest-dom/matchers';
import '@testing-library/jest-dom';
declare module 'vitest' {
interface Assertion<T = any> extends TestingLibraryMatchers<typeof expect.stringContaining, T> { }
interface AsymmetricMatchersContaining extends TestingLibraryMatchers<any, any> { }
}
expect.extend(matchers);
// In Node 20 / Vitest environment, Response and Headers are already
// defined globally. Polyfilling them from node-fetch is no longer
// required and avoids deprecation warnings.
if (typeof globalThis.Response === 'undefined') {
console.warn('Polyfilling Response for test environment');
}