182 lines
6.9 KiB
TypeScript
182 lines
6.9 KiB
TypeScript
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
|
import { render, screen, waitFor } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
|
import { AccountPage } from './AccountPage';
|
|
import { api } from '../api/client';
|
|
import { useAuthStore } from '../store/useStore';
|
|
|
|
vi.mock('qrcode', () => ({
|
|
default: { toDataURL: () => Promise.resolve('data:image/png;base64,qr') },
|
|
}));
|
|
|
|
vi.mock('../api/client', () => ({
|
|
api: {
|
|
getPlans: vi.fn(),
|
|
checkout: vi.fn(),
|
|
getMe: vi.fn(),
|
|
getSubscription: vi.fn(),
|
|
getDevices: vi.fn(),
|
|
deleteDevice: vi.fn(),
|
|
login: vi.fn(),
|
|
signup: vi.fn(),
|
|
},
|
|
ApiError: class ApiError extends Error {
|
|
constructor(m: string, public status: number) {
|
|
super(m);
|
|
}
|
|
},
|
|
setStoredTokens: vi.fn(),
|
|
clearStoredTokens: vi.fn(),
|
|
}));
|
|
|
|
function renderAccount(authed = true) {
|
|
if (authed) {
|
|
useAuthStore.setState({
|
|
access: 'a',
|
|
refresh: 'r',
|
|
expiresAt: 9999999999,
|
|
user: { username: 'alice', telegram_id: 'alice_id', referal_code: 'c' },
|
|
remember: true,
|
|
});
|
|
} else {
|
|
useAuthStore.setState({
|
|
access: null,
|
|
refresh: null,
|
|
expiresAt: null,
|
|
user: null,
|
|
remember: true,
|
|
});
|
|
}
|
|
return render(
|
|
<MemoryRouter initialEntries={['/account']}>
|
|
<Routes>
|
|
<Route path="/account" element={<AccountPage />} />
|
|
<Route path="/login" element={<div>login page</div>} />
|
|
<Route path="/" element={<div>landing</div>} />
|
|
</Routes>
|
|
</MemoryRouter>,
|
|
);
|
|
}
|
|
|
|
const ME = { username: 'alice', telegram_id: 'alice_id', referal_code: 'c' };
|
|
const SUB = {
|
|
devices: 6,
|
|
expires_at: '2026-03-14T00:00:00Z',
|
|
addon_ids: [],
|
|
subscription_link: 'link',
|
|
has_subscription: true,
|
|
};
|
|
const DEVICES = [
|
|
{ os: 'macos', model: 'MacBook Pro', client: 'Malenia', hwid: 'h1' },
|
|
{ os: 'ios', model: 'iPhone 15', client: 'Malenia', hwid: 'h2' },
|
|
];
|
|
|
|
describe('AccountPage', () => {
|
|
beforeEach(() => {
|
|
vi.mocked(api.getMe).mockReset();
|
|
vi.mocked(api.getSubscription).mockReset();
|
|
vi.mocked(api.getDevices).mockReset();
|
|
vi.mocked(api.deleteDevice).mockReset();
|
|
});
|
|
|
|
afterEach(() => vi.restoreAllMocks());
|
|
|
|
it('redirects to /login when not authenticated', async () => {
|
|
renderAccount(false);
|
|
await waitFor(() => expect(screen.getByText('login page')).toBeInTheDocument());
|
|
});
|
|
|
|
it('renders hero username and stat strip', async () => {
|
|
vi.mocked(api.getMe).mockResolvedValue(ME);
|
|
vi.mocked(api.getSubscription).mockResolvedValue(SUB);
|
|
vi.mocked(api.getDevices).mockResolvedValue(DEVICES);
|
|
renderAccount();
|
|
expect(await screen.findByText('alice')).toBeInTheDocument();
|
|
expect(screen.getByText('@alice_id')).toBeInTheDocument();
|
|
expect(screen.getAllByText(/iPhone 15/)).toHaveLength(1);
|
|
});
|
|
|
|
it('does not render a subscription status before subscription data loads', async () => {
|
|
vi.mocked(api.getMe).mockResolvedValue(ME);
|
|
vi.mocked(api.getSubscription).mockResolvedValue(SUB);
|
|
vi.mocked(api.getDevices).mockResolvedValue(DEVICES);
|
|
renderAccount();
|
|
|
|
expect(screen.queryByText(/активная подписка отсутствует/i)).not.toBeInTheDocument();
|
|
expect(await screen.findByText(/подписка до/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders the subscription link and copy action', async () => {
|
|
vi.mocked(api.getMe).mockResolvedValue(ME);
|
|
vi.mocked(api.getSubscription).mockResolvedValue(SUB);
|
|
vi.mocked(api.getDevices).mockResolvedValue(DEVICES);
|
|
renderAccount();
|
|
expect(await screen.findByText(SUB.subscription_link)).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /копировать ссылку подписки/i })).toBeEnabled();
|
|
});
|
|
|
|
it('renders a referral link with copy and QR actions', async () => {
|
|
vi.mocked(api.getMe).mockResolvedValue(ME);
|
|
vi.mocked(api.getSubscription).mockResolvedValue(SUB);
|
|
vi.mocked(api.getDevices).mockResolvedValue(DEVICES);
|
|
renderAccount();
|
|
|
|
const link = `${window.location.origin}/?ref=c`;
|
|
expect(await screen.findByText(link)).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /показать qr-код реферальной ссылки/i })).toBeEnabled();
|
|
});
|
|
|
|
it('opens and closes the subscription QR code dialog', async () => {
|
|
vi.mocked(api.getMe).mockResolvedValue(ME);
|
|
vi.mocked(api.getSubscription).mockResolvedValue(SUB);
|
|
vi.mocked(api.getDevices).mockResolvedValue(DEVICES);
|
|
const user = userEvent.setup();
|
|
renderAccount();
|
|
|
|
await screen.findByText(SUB.subscription_link);
|
|
await user.click(screen.getByRole('button', { name: /показать qr-код ссылки подписки/i }));
|
|
expect(await screen.findByRole('dialog', { name: /откройте на устройстве/i })).toBeInTheDocument();
|
|
expect(await screen.findByAltText(/qr-код ссылки подписки/i)).toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole('button', { name: /закрыть/i }));
|
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('renders empty state when no devices', async () => {
|
|
vi.mocked(api.getMe).mockResolvedValue(ME);
|
|
vi.mocked(api.getSubscription).mockResolvedValue(SUB);
|
|
vi.mocked(api.getDevices).mockResolvedValue([]);
|
|
renderAccount();
|
|
expect(await screen.findByText(/нет подключённых устройств/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('opens confirmation modal and deletes device on confirm', async () => {
|
|
vi.mocked(api.getMe).mockResolvedValue(ME);
|
|
vi.mocked(api.getSubscription).mockResolvedValue(SUB);
|
|
vi.mocked(api.getDevices).mockResolvedValue(DEVICES);
|
|
vi.mocked(api.deleteDevice).mockResolvedValue({ success: true });
|
|
const user = userEvent.setup();
|
|
renderAccount();
|
|
await screen.findByText(/iPhone 15/);
|
|
await user.click(screen.getByRole('button', { name: /удалить iPhone 15/i }));
|
|
expect(await screen.findByText(/удалить это устройство\?/i)).toBeInTheDocument();
|
|
const confirmBtns = screen.getAllByRole('button', { name: /^удалить$/i });
|
|
await user.click(confirmBtns[confirmBtns.length - 1]!);
|
|
await waitFor(() => expect(api.deleteDevice).toHaveBeenCalledWith('h2'));
|
|
await waitFor(() => expect(screen.queryByText(/iPhone 15/)).not.toBeInTheDocument());
|
|
});
|
|
|
|
it('signs out and navigates to landing', async () => {
|
|
vi.mocked(api.getMe).mockResolvedValue(ME);
|
|
vi.mocked(api.getSubscription).mockResolvedValue(SUB);
|
|
vi.mocked(api.getDevices).mockResolvedValue(DEVICES);
|
|
const user = userEvent.setup();
|
|
renderAccount();
|
|
await screen.findByText(/alice/);
|
|
await user.click(screen.getByRole('button', { name: /выйти/i }));
|
|
await waitFor(() => expect(screen.getByText('landing')).toBeInTheDocument());
|
|
expect(useAuthStore.getState().access).toBeNull();
|
|
});
|
|
});
|