This commit is contained in:
2026-08-10 12:01:03 +07:00
commit 265b1202ad
95 changed files with 27718 additions and 0 deletions

146
src/api/client.test.ts Normal file
View File

@@ -0,0 +1,146 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { api, ApiError, setStoredTokens, clearStoredTokens } from './client';
import { useAuthStore } from '../store/useStore';
const ACCESS = 'access-token';
const REFRESH = 'refresh-token';
type FetchMock = ReturnType<typeof vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>>;
function mockFetch(
impl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>,
): FetchMock {
const fn = vi.fn(impl);
globalThis.fetch = fn as typeof fetch;
return fn;
}
function urlOf(input: RequestInfo | URL): string {
if (typeof input === 'string') return input;
if (input instanceof URL) return input.href;
return input.url;
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
describe('api client', () => {
beforeEach(() => {
clearStoredTokens();
localStorage.clear();
useAuthStore.setState({
access: null,
refresh: null,
expiresAt: null,
user: null,
remember: true,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('injects Authorization header when token is stored', async () => {
setStoredTokens({ access_token: ACCESS, refresh_token: REFRESH, expires_at: 9999999999 });
const fetchMock = mockFetch(async () => jsonResponse({ ok: true }));
await api.getMe();
const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined;
expect(init?.headers).toMatchObject({ Authorization: `Bearer ${ACCESS}` });
});
it('does not inject header on auth:false calls', async () => {
const fetchMock = mockFetch(async () =>
jsonResponse({ device_price: 100, addons: [] }),
);
await api.getPlans();
const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined;
expect(init?.headers).not.toHaveProperty('Authorization');
});
it('serialises body as JSON for POST', async () => {
const fetchMock = mockFetch(async (_input, init) => {
expect(init?.body).toBe(JSON.stringify({ provider: 'credentials', username: 'u', password: 'p' }));
return jsonResponse({
access_token: 'a',
refresh_token: 'r',
expires_at: 1,
user: { username: 'u', telegram_id: null, referal_code: 'x' },
});
});
await api.login({ provider: 'credentials', username: 'u', password: 'p' });
expect(fetchMock).toHaveBeenCalledOnce();
});
it('throws ApiError with backend detail on non-2xx', async () => {
mockFetch(async () => jsonResponse({ detail: 'bad credentials' }, 401));
await expect(
api.login({ provider: 'credentials', username: 'u', password: 'p' }),
).rejects.toMatchObject({ message: 'bad credentials', status: 401 });
});
it('maps validation error array to a joined message', async () => {
mockFetch(async () =>
jsonResponse(
{ detail: [{ msg: 'field required' }, { msg: 'too short' }] },
422,
),
);
await expect(api.getPlans()).rejects.toBeInstanceOf(ApiError);
});
it('refreshes once on 401 then retries the original request', async () => {
setStoredTokens({ access_token: ACCESS, refresh_token: REFRESH, expires_at: 9999999999 });
let calls = 0;
mockFetch(async (input) => {
calls++;
const url = urlOf(input);
if (url.startsWith('/auth/refresh')) {
return jsonResponse({ access_token: 'new', refresh_token: 'new-r', expires_at: 9999999999 });
}
if (calls === 1) return jsonResponse({ detail: 'expired' }, 401);
return jsonResponse({ username: 'u', telegram_id: null, referal_code: 'x' });
});
const me = await api.getMe();
expect(me.username).toBe('u');
expect(localStorage.getItem('malenia.access')).toBe('new');
});
it('clears tokens and rejects when refresh fails on 401', async () => {
setStoredTokens({ access_token: ACCESS, refresh_token: REFRESH, expires_at: 9999999999 });
mockFetch(async (input) => {
const url = urlOf(input);
if (url.startsWith('/auth/refresh')) return jsonResponse({ detail: 'invalid' }, 401);
return jsonResponse({ detail: 'expired' }, 401);
});
await expect(api.getMe()).rejects.toMatchObject({ status: 401 });
expect(localStorage.getItem('malenia.access')).toBeNull();
});
it('checkout sends DELETE-style? no — POST body with order details', async () => {
const fetchMock = mockFetch(async () =>
jsonResponse({
order_id: 'ord-1',
total_amount: 100,
bonus_paid: 0,
amount_to_pay: 100,
payment_link: 'https://pay',
}, 201),
);
const res = await api.checkout({ devices: 3, addons: [], duration_days: 30 });
expect(res.order_id).toBe('ord-1');
expect(fetchMock).toHaveBeenCalledOnce();
});
it('deleteDevice uses DELETE method and encodes hwid', async () => {
const fetchMock = mockFetch(async () => jsonResponse({ success: true }));
await api.deleteDevice('hw id/123');
const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined;
expect(init?.method).toBe('DELETE');
const url = fetchMock.mock.calls[0]?.[0] as string;
expect(url).toContain(encodeURIComponent('hw id/123'));
});
});

184
src/api/client.ts Normal file
View File

@@ -0,0 +1,184 @@
import { useAuthStore } from '../store/useStore';
import type {
CheckoutResponse,
Device,
LoginPayload,
LoginResponse,
OperationResult,
OrderDetails,
PricingPlans,
SignupPayload,
SubscriptionData,
Tokens,
UserInfo,
} from '../types';
const ACCESS_KEY = 'malenia.access';
const REFRESH_KEY = 'malenia.refresh';
const EXPIRES_KEY = 'malenia.expires_at';
/* ── Token storage helpers (mirror of store, used by client) ── */
function readToken(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
export function setStoredTokens(tokens: Tokens): void {
try {
localStorage.setItem(ACCESS_KEY, tokens.access_token);
localStorage.setItem(REFRESH_KEY, tokens.refresh_token);
localStorage.setItem(EXPIRES_KEY, String(tokens.expires_at));
} catch {
/* ignore */
}
}
export function clearStoredTokens(): void {
try {
localStorage.removeItem(ACCESS_KEY);
localStorage.removeItem(REFRESH_KEY);
localStorage.removeItem(EXPIRES_KEY);
} catch {
/* ignore */
}
}
function getRefreshToken(): string | null {
return readToken(REFRESH_KEY);
}
/* ── RequestError ─────────────────────────────────────────── */
export class ApiError extends Error {
readonly status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'ApiError';
this.status = status;
}
}
async function parseError(res: Response): Promise<ApiError> {
let detail = `Запрос не выполнен (${res.status})`;
try {
const data = (await res.json()) as unknown;
if (Array.isArray((data as { detail?: unknown }).detail)) {
const arr = (data as { detail: { msg?: string }[] }).detail;
detail = arr.map((e) => e.msg ?? '').filter(Boolean).join('; ') || detail;
} else if (typeof (data as { detail?: unknown }).detail === 'string') {
detail = (data as { detail: string }).detail;
} else if (typeof data === 'string' && data.length > 0) {
detail = data;
}
} catch {
/* keep default */
}
return new ApiError(detail, res.status);
}
let refreshing: Promise<boolean> | null = null;
async function refreshTokens(): Promise<boolean> {
if (refreshing) return refreshing;
const refresh = getRefreshToken();
if (!refresh) return false;
refreshing = (async () => {
try {
const res = await fetch(
`/auth/refresh?refresh_token=${encodeURIComponent(refresh)}&iss=credentials`,
{ method: 'POST' },
);
if (!res.ok) return false;
const tokens = (await res.json()) as Tokens;
setStoredTokens(tokens);
useAuthStore.getState().setTokens(tokens);
return true;
} catch {
return false;
} finally {
refreshing = null;
}
})();
return refreshing;
}
interface RequestOptions {
method?: string;
body?: unknown;
signal?: AbortSignal;
auth?: boolean;
}
async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const { method = 'GET', body, signal, auth = true } = opts;
function buildHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (auth) {
const access = readToken(ACCESS_KEY);
if (access) headers.Authorization = `Bearer ${access}`;
}
return headers;
}
let res = await fetch(path, {
method,
headers: buildHeaders(),
body: body === undefined ? undefined : JSON.stringify(body),
signal,
});
if (res.status === 401 && auth) {
const ok = await refreshTokens();
if (ok) {
res = await fetch(path, {
method,
headers: buildHeaders(),
body: body === undefined ? undefined : JSON.stringify(body),
signal,
});
} else {
useAuthStore.getState().clear();
clearStoredTokens();
throw new ApiError('Сессия истекла', 401);
}
}
if (!res.ok) throw await parseError(res);
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
/* ── Public API ───────────────────────────────────────────── */
export const api = {
signup(payload: SignupPayload): Promise<LoginResponse> {
return request<LoginResponse>('/auth/signup', { method: 'POST', body: payload, auth: false });
},
login(payload: LoginPayload): Promise<LoginResponse> {
return request<LoginResponse>('/auth/login', { method: 'POST', body: payload, auth: false });
},
getPlans(): Promise<PricingPlans> {
return request<PricingPlans>('/plans/', { auth: false });
},
checkout(order: OrderDetails): Promise<CheckoutResponse> {
return request<CheckoutResponse>('/orders/checkout', { method: 'POST', body: order });
},
getMe(): Promise<UserInfo> {
return request<UserInfo>('/users/me');
},
getSubscription(): Promise<SubscriptionData> {
return request<SubscriptionData>('/users/subscription');
},
getDevices(): Promise<Device[]> {
return request<Device[]>('/users/subscription/hwid');
},
deleteDevice(hwid: string): Promise<OperationResult> {
return request<OperationResult>(
`/users/subscription/hwid?hwid=${encodeURIComponent(hwid)}`,
{ method: 'DELETE' },
);
},
};