first commit
This commit is contained in:
commit
b54624be96
226 files changed
+108840
No files matched your search
@@ -0,0 +1,75 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { api, ApiError, errorMessage } from '../apiClient.ts';
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200, headers: Record<string, string> = {}) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
});
|
||||
|
||||
describe('apiClient', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
document.cookie = 'csrftoken=; Max-Age=0; path=/';
|
||||
});
|
||||
|
||||
it('unwraps paginated list results', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
count: 1,
|
||||
next: null,
|
||||
previous: null,
|
||||
results: [{ id: 7, site_name: 'Sukawarna', user: 1 }],
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const sites = await api.sites.list();
|
||||
expect(sites).toEqual([{ id: 7, site_name: 'Sukawarna', user: 1 }]);
|
||||
});
|
||||
|
||||
it('unwraps a bare array list', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(jsonResponse([{ id: 2, site_name: 'Cilayang', user: 1 }]))
|
||||
);
|
||||
|
||||
const sites = await api.sites.list();
|
||||
expect(sites[0]?.site_name).toBe('Cilayang');
|
||||
});
|
||||
|
||||
it('sends CSRF token on POST', async () => {
|
||||
document.cookie = 'csrftoken=abc123; path=/';
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ id: 1, user_name: 'admin' }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await api.auth.login('admin', 'admin123');
|
||||
|
||||
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
|
||||
const headers = new Headers(init.headers);
|
||||
expect(headers.get('X-CSRFToken')).toBe('abc123');
|
||||
expect(init.credentials).toBe('include');
|
||||
});
|
||||
|
||||
it('throws ApiError with detail and status', async () => {
|
||||
document.cookie = 'csrftoken=abc123; path=/';
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(jsonResponse({ detail: 'Bad credentials' }, 400))
|
||||
);
|
||||
|
||||
await expect(api.auth.login('x', 'y')).rejects.toMatchObject({
|
||||
name: 'ApiError',
|
||||
status: 400,
|
||||
message: 'Bad credentials',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps unknown errors through errorMessage', () => {
|
||||
expect(errorMessage(new ApiError('Gagal', 502, null))).toBe('Gagal');
|
||||
expect(errorMessage(new Error('boom'))).toBe('boom');
|
||||
expect(errorMessage('nope')).toBe('Terjadi kesalahan');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
import type {
|
||||
AIInsight,
|
||||
ChickenCounting,
|
||||
ChickenWeight,
|
||||
CitySite,
|
||||
CitySiteWrite,
|
||||
CountingWrite,
|
||||
Cycle,
|
||||
CycleFilter,
|
||||
CycleWrite,
|
||||
Flock,
|
||||
FlockWrite,
|
||||
InitialBalanceResult,
|
||||
Karung,
|
||||
KarungRequestResult,
|
||||
Kandang,
|
||||
KandangWrite,
|
||||
KPI,
|
||||
LatestAvg,
|
||||
ManualInput,
|
||||
ManualInputWrite,
|
||||
Paginated,
|
||||
PanelIoT,
|
||||
Site,
|
||||
SiteWrite,
|
||||
User,
|
||||
UserWrite,
|
||||
} from '../types/api.ts';
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_API_BASE || '/api/v1').replace(/\/$/, '');
|
||||
|
||||
export const AUTH_UNAUTHORIZED_EVENT = 'dashboard-auth-unauthorized';
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
body: unknown;
|
||||
|
||||
constructor(message: string, status: number, body: unknown) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
export function errorMessage(err: unknown): string {
|
||||
if (err instanceof ApiError) return err.message;
|
||||
if (err instanceof Error) return err.message;
|
||||
return 'Terjadi kesalahan';
|
||||
}
|
||||
|
||||
function getCookie(name: string): string | null {
|
||||
const escaped = name.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1');
|
||||
const match = document.cookie.match(new RegExp(`(?:^|; )${escaped}=([^;]*)`));
|
||||
return match?.[1] ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
function parseDetail(body: unknown): string {
|
||||
if (!body || typeof body !== 'object') return 'Request failed';
|
||||
const detail = (body as { detail?: unknown }).detail;
|
||||
if (typeof detail === 'string') {
|
||||
if (detail.toLowerCase().includes('csrf')) {
|
||||
return 'Permintaan ditolak (CSRF). Muat ulang halaman lalu coba lagi.';
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
if (Array.isArray(detail)) return detail.map(String).join(', ');
|
||||
const entries = Object.entries(body as Record<string, unknown>)
|
||||
.filter(([key]) => key !== 'detail')
|
||||
.map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(', ') : String(value)}`);
|
||||
return entries.length > 0 ? entries.join('; ') : 'Request failed';
|
||||
}
|
||||
|
||||
async function ensureCsrf(): Promise<string> {
|
||||
const res = await fetch(`${API_BASE}/auth/csrf/`, { credentials: 'include' });
|
||||
if (!res.ok) {
|
||||
throw new ApiError('Gagal mengambil CSRF token', res.status, null);
|
||||
}
|
||||
const data = (await res.json()) as { csrfToken?: string };
|
||||
return data.csrfToken || getCookie('csrftoken') || '';
|
||||
}
|
||||
|
||||
function isCsrfFailure(status: number, body: unknown): boolean {
|
||||
if (status !== 403) return false;
|
||||
const detail = parseDetail(body).toLowerCase();
|
||||
return detail.includes('csrf');
|
||||
}
|
||||
|
||||
type Query = Record<string, string | number | boolean | null | undefined>;
|
||||
|
||||
function withQuery(path: string, query?: Query): string {
|
||||
if (!query) return path;
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
params.set(key, String(value));
|
||||
}
|
||||
const qs = params.toString();
|
||||
return qs ? `${path}?${qs}` : path;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}, csrfRetried = false): Promise<T> {
|
||||
const method = (init.method || 'GET').toUpperCase();
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set('Accept', 'application/json');
|
||||
if (init.body && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
if (method !== 'GET' && method !== 'HEAD') {
|
||||
const csrf = await ensureCsrf();
|
||||
if (csrf) headers.set('X-CSRFToken', csrf);
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
method,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (res.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
let body: unknown = null;
|
||||
if (text) {
|
||||
try {
|
||||
body = JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
body = text;
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
if (isCsrfFailure(res.status, body) && !csrfRetried && method !== 'GET' && method !== 'HEAD') {
|
||||
return request<T>(path, init, true);
|
||||
}
|
||||
if (res.status === 401) {
|
||||
window.dispatchEvent(new CustomEvent(AUTH_UNAUTHORIZED_EVENT));
|
||||
}
|
||||
throw new ApiError(parseDetail(body), res.status, body);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
function unwrapList<T>(data: Paginated<T> | T[]): T[] {
|
||||
if (Array.isArray(data)) return data;
|
||||
return data.results ?? [];
|
||||
}
|
||||
|
||||
const get = <T>(path: string, query?: Query) => request<T>(withQuery(path, query));
|
||||
const post = <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: 'POST', body: body !== undefined ? JSON.stringify(body) : undefined });
|
||||
const patch = <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) });
|
||||
const del = (path: string) => request<void>(path, { method: 'DELETE' });
|
||||
|
||||
export const api = {
|
||||
auth: {
|
||||
csrf: () => get<{ csrfToken: string }>('/auth/csrf/'),
|
||||
login: (user_name: string, password: string) =>
|
||||
post<User>('/auth/login/', { user_name, password }),
|
||||
logout: () => post<void>('/auth/logout/'),
|
||||
me: () => get<User>('/auth/me/'),
|
||||
},
|
||||
citySites: {
|
||||
list: async () => unwrapList(await get<Paginated<CitySite> | CitySite[]>('/city-sites/')),
|
||||
create: (body: CitySiteWrite) => post<CitySite>('/city-sites/', body),
|
||||
update: (id: number, body: Partial<CitySiteWrite>) =>
|
||||
patch<CitySite>(`/city-sites/${id}/`, body),
|
||||
remove: (id: number) => del(`/city-sites/${id}/`),
|
||||
},
|
||||
users: {
|
||||
list: async () => unwrapList(await get<Paginated<User> | User[]>('/users/')),
|
||||
create: (body: UserWrite) => post<User>('/users/', body),
|
||||
update: (id: number, body: Partial<UserWrite>) => patch<User>(`/users/${id}/`, body),
|
||||
setPassword: (id: number, password: string) =>
|
||||
post<User>(`/users/${id}/set-password/`, { password }),
|
||||
listBuhs: async () => unwrapList(await get<Paginated<User> | User[]>('/users/buhs/')),
|
||||
listPendingManaged: async () =>
|
||||
unwrapList(await get<Paginated<User> | User[]>('/users/pending-managed/')),
|
||||
requestManagedBy: (buh_id: number) =>
|
||||
post<User>('/users/request-managed-by/', { buh_id }),
|
||||
cancelManagedByRequest: () => post<User>('/users/cancel-managed-by-request/', {}),
|
||||
approveManagedBy: (gmId: number) => post<User>(`/users/${gmId}/approve-managed-by/`, {}),
|
||||
rejectManagedBy: (gmId: number) => post<User>(`/users/${gmId}/reject-managed-by/`, {}),
|
||||
},
|
||||
sites: {
|
||||
list: async (query?: { user_id?: number }) =>
|
||||
unwrapList(await get<Paginated<Site> | Site[]>('/sites/', query)),
|
||||
create: (body: SiteWrite) => post<Site>('/sites/', body),
|
||||
update: (id: number, body: Partial<SiteWrite>) => patch<Site>(`/sites/${id}/`, body),
|
||||
remove: (id: number) => del(`/sites/${id}/`),
|
||||
},
|
||||
kandangs: {
|
||||
list: async (query?: { site_id?: number }) =>
|
||||
unwrapList(await get<Paginated<Kandang> | Kandang[]>('/kandangs/', query)),
|
||||
create: (body: KandangWrite) => post<Kandang>('/kandangs/', body),
|
||||
update: (id: number, body: Partial<KandangWrite>) => patch<Kandang>(`/kandangs/${id}/`, body),
|
||||
remove: (id: number) => del(`/kandangs/${id}/`),
|
||||
},
|
||||
flocks: {
|
||||
list: async (query?: { kandang_id?: number }) =>
|
||||
unwrapList(await get<Paginated<Flock> | Flock[]>('/flocks/', query)),
|
||||
create: (body: FlockWrite) => post<Flock>('/flocks/', body),
|
||||
update: (id: number, body: Partial<FlockWrite>) => patch<Flock>(`/flocks/${id}/`, body),
|
||||
remove: (id: number) => del(`/flocks/${id}/`),
|
||||
},
|
||||
cycles: {
|
||||
list: async (query?: CycleFilter) =>
|
||||
unwrapList(await get<Paginated<Cycle> | Cycle[]>('/cycles/', query)),
|
||||
create: (body: CycleWrite) => post<Cycle>('/cycles/', body),
|
||||
update: (id: number, body: Partial<CycleWrite>) => patch<Cycle>(`/cycles/${id}/`, body),
|
||||
remove: (id: number) => del(`/cycles/${id}/`),
|
||||
initialBalance: (id: number, body: { date: string; feed_in_manual: number }) =>
|
||||
post<InitialBalanceResult>(`/cycles/${id}/initial-balance/`, body),
|
||||
initialBalanceCompare: (
|
||||
id: number,
|
||||
query: { date: string; manual?: number }
|
||||
) => get<InitialBalanceResult>(`/cycles/${id}/initial-balance-compare/`, query),
|
||||
approveClose: (id: number) =>
|
||||
post<Cycle | { cycle: Cycle; site_error?: string }>(`/cycles/${id}/approve-close/`, {}),
|
||||
rejectClose: (id: number) =>
|
||||
post<Cycle | { cycle: Cycle; site_error?: string }>(`/cycles/${id}/reject-close/`, {}),
|
||||
},
|
||||
kpis: {
|
||||
list: async (query?: CycleFilter) =>
|
||||
unwrapList(await get<Paginated<KPI> | KPI[]>('/kpis/', query)),
|
||||
},
|
||||
manualInputs: {
|
||||
list: async (query?: CycleFilter) =>
|
||||
unwrapList(await get<Paginated<ManualInput> | ManualInput[]>('/manual-inputs/', query)),
|
||||
create: (body: ManualInputWrite) => post<ManualInput>('/manual-inputs/', body),
|
||||
update: (id: number, body: Partial<ManualInputWrite>) =>
|
||||
patch<ManualInput>(`/manual-inputs/${id}/`, body),
|
||||
},
|
||||
countings: {
|
||||
list: async (query?: CycleFilter) =>
|
||||
unwrapList(
|
||||
await get<Paginated<ChickenCounting> | ChickenCounting[]>('/chicken-countings/', query)
|
||||
),
|
||||
create: (body: CountingWrite) => post<ChickenCounting>('/chicken-countings/', body),
|
||||
update: (id: number, body: Partial<CountingWrite>) =>
|
||||
patch<ChickenCounting>(`/chicken-countings/${id}/`, body),
|
||||
remove: (id: number) => del(`/chicken-countings/${id}/`),
|
||||
sync: (body: { cycle_id: number }) =>
|
||||
post<{ upserted: number; dates: string[] }>('/chicken-countings/sync/', body),
|
||||
},
|
||||
weights: {
|
||||
list: async (query?: CycleFilter) =>
|
||||
unwrapList(await get<Paginated<ChickenWeight> | ChickenWeight[]>('/chicken-weights/', query)),
|
||||
latestAvg: (query?: CycleFilter) => get<LatestAvg>('/chicken-weights/latest-avg/', query),
|
||||
},
|
||||
karungs: {
|
||||
list: async (query?: CycleFilter) =>
|
||||
unwrapList(await get<Paginated<Karung> | Karung[]>('/karungs/', query)),
|
||||
latest: (query?: CycleFilter) => get<Karung>('/karungs/latest/', query),
|
||||
request: (body: { cycle_id: number; section?: 'today' | 'yesterday_history' }) =>
|
||||
post<KarungRequestResult>('/karungs/request/', body),
|
||||
},
|
||||
panelIot: {
|
||||
list: async (query?: CycleFilter) =>
|
||||
unwrapList(await get<Paginated<PanelIoT> | PanelIoT[]>('/panel-iot/', query)),
|
||||
dates: async (query?: CycleFilter) => get<string[]>('/panel-iot/dates/', query),
|
||||
},
|
||||
insights: {
|
||||
list: async (query?: CycleFilter) =>
|
||||
unwrapList(await get<Paginated<AIInsight> | AIInsight[]>('/ai-insights/', query)),
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user