v0.0.01b
This commit is contained in:
146
src/api/client.test.ts
Normal file
146
src/api/client.test.ts
Normal 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
184
src/api/client.ts
Normal 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' },
|
||||
);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user