mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-08 22:48:54 +00:00
feat: add frontend action buttons, fix notification auth bug, add 53 Vitest tests
Bug fix: - markNotificationRead was using raw fetch() without auth headers, bypassing the shared client's Authorization header. Moved to api/client.ts to use fetchJSON with proper auth. New action buttons: - CertificatesPage: "New Certificate" modal with form fields - CertificateDetailPage: "Deploy" button with target selector modal, "Archive" button with confirmation - IssuersPage: "Test Connection" and "Delete" per-row actions - TargetsPage: "Delete" per-row action - PoliciesPage: "Enable/Disable" toggle and "Delete" per-row actions New API client functions: - updateCertificate, archiveCertificate, registerAgent, createPolicy, updatePolicy, deletePolicy, getPolicyViolations, createIssuer, testIssuerConnection, deleteIssuer, createTarget, deleteTarget, markNotificationRead Frontend tests (53 tests, 2 files): - client.test.ts: 35 tests covering all API endpoints, auth headers, 401 handling, error parsing, HTTP methods, request bodies - utils.test.ts: 18 tests covering formatDate, formatDateTime, timeAgo, daysUntil, expiryColor CI: Added "Run Frontend Tests" step to frontend-build job Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
setApiKey,
|
||||
getApiKey,
|
||||
getCertificates,
|
||||
getCertificate,
|
||||
getCertificateVersions,
|
||||
createCertificate,
|
||||
triggerRenewal,
|
||||
triggerDeployment,
|
||||
updateCertificate,
|
||||
archiveCertificate,
|
||||
getAgents,
|
||||
getAgent,
|
||||
registerAgent,
|
||||
getJobs,
|
||||
cancelJob,
|
||||
getNotifications,
|
||||
markNotificationRead,
|
||||
getAuditEvents,
|
||||
getPolicies,
|
||||
updatePolicy,
|
||||
deletePolicy,
|
||||
getIssuers,
|
||||
testIssuerConnection,
|
||||
deleteIssuer,
|
||||
getTargets,
|
||||
createTarget,
|
||||
deleteTarget,
|
||||
getHealth,
|
||||
} from './client';
|
||||
|
||||
// Mock global fetch
|
||||
const mockFetch = vi.fn();
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
function mockJsonResponse(data: unknown, status = 200) {
|
||||
return Promise.resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: () => Promise.resolve(data),
|
||||
statusText: 'OK',
|
||||
} as Response);
|
||||
}
|
||||
|
||||
function mockErrorResponse(status: number, body: { message?: string; error?: string } = {}) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status,
|
||||
json: () => Promise.resolve(body),
|
||||
statusText: 'Error',
|
||||
} as Response);
|
||||
}
|
||||
|
||||
describe('API Client', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
setApiKey(null);
|
||||
});
|
||||
|
||||
// ─── Auth ───────────────────────────────────────────
|
||||
|
||||
describe('API Key management', () => {
|
||||
it('stores and retrieves API key', () => {
|
||||
expect(getApiKey()).toBeNull();
|
||||
setApiKey('test-key-123');
|
||||
expect(getApiKey()).toBe('test-key-123');
|
||||
});
|
||||
|
||||
it('clears API key', () => {
|
||||
setApiKey('test-key');
|
||||
setApiKey(null);
|
||||
expect(getApiKey()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auth headers', () => {
|
||||
it('sends Authorization header when API key is set', async () => {
|
||||
setApiKey('my-secret-key');
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
|
||||
|
||||
await getCertificates();
|
||||
|
||||
const [, init] = mockFetch.mock.calls[0];
|
||||
expect(init.headers['Authorization']).toBe('Bearer my-secret-key');
|
||||
expect(init.headers['Content-Type']).toBe('application/json');
|
||||
});
|
||||
|
||||
it('omits Authorization header when no API key', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
|
||||
|
||||
await getCertificates();
|
||||
|
||||
const [, init] = mockFetch.mock.calls[0];
|
||||
expect(init.headers['Authorization']).toBeUndefined();
|
||||
expect(init.headers['Content-Type']).toBe('application/json');
|
||||
});
|
||||
|
||||
it('dispatches auth-required event on 401', async () => {
|
||||
const listener = vi.fn();
|
||||
window.addEventListener('certctl:auth-required', listener);
|
||||
mockFetch.mockReturnValueOnce(mockErrorResponse(401));
|
||||
|
||||
await expect(getCertificates()).rejects.toThrow('Authentication required');
|
||||
expect(listener).toHaveBeenCalled();
|
||||
|
||||
window.removeEventListener('certctl:auth-required', listener);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error handling ─────────────────────────────────
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('throws with server error message', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockErrorResponse(400, { message: 'Invalid request' }));
|
||||
await expect(getCertificates()).rejects.toThrow('Invalid request');
|
||||
});
|
||||
|
||||
it('throws with error field from response', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockErrorResponse(500, { error: 'Internal error' }));
|
||||
await expect(getCertificates()).rejects.toThrow('Internal error');
|
||||
});
|
||||
|
||||
it('falls back to HTTP status text', async () => {
|
||||
mockFetch.mockReturnValueOnce(
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: () => Promise.reject(new Error('not json')),
|
||||
statusText: 'Service Unavailable',
|
||||
} as Response),
|
||||
);
|
||||
await expect(getCertificates()).rejects.toThrow('Service Unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Certificates ───────────────────────────────────
|
||||
|
||||
describe('Certificates', () => {
|
||||
it('getCertificates sends GET with default pagination', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
|
||||
await getCertificates();
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/api/v1/certificates?page=1&per_page=50',
|
||||
expect.objectContaining({ headers: expect.any(Object) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('getCertificates passes filter params', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
|
||||
await getCertificates({ status: 'Active', environment: 'production' });
|
||||
const url = mockFetch.mock.calls[0][0] as string;
|
||||
expect(url).toContain('status=Active');
|
||||
expect(url).toContain('environment=production');
|
||||
});
|
||||
|
||||
it('getCertificate fetches single cert by ID', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'mc-test', common_name: 'test.com' }));
|
||||
const cert = await getCertificate('mc-test');
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/certificates/mc-test');
|
||||
expect(cert.id).toBe('mc-test');
|
||||
});
|
||||
|
||||
it('getCertificateVersions fetches versions', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
|
||||
await getCertificateVersions('mc-test');
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/certificates/mc-test/versions');
|
||||
});
|
||||
|
||||
it('createCertificate sends POST with body', async () => {
|
||||
const certData = { common_name: 'new.example.com', issuer_id: 'iss-local' };
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'mc-new', ...certData }));
|
||||
await createCertificate(certData);
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/certificates');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(JSON.parse(init.body)).toEqual(certData);
|
||||
});
|
||||
|
||||
it('updateCertificate sends PUT', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'mc-test', status: 'Active' }));
|
||||
await updateCertificate('mc-test', { status: 'Active' });
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/certificates/mc-test');
|
||||
expect(init.method).toBe('PUT');
|
||||
});
|
||||
|
||||
it('archiveCertificate sends DELETE', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'archived' }));
|
||||
await archiveCertificate('mc-test');
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/certificates/mc-test');
|
||||
expect(init.method).toBe('DELETE');
|
||||
});
|
||||
|
||||
it('triggerRenewal sends POST', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'renewal triggered' }));
|
||||
await triggerRenewal('mc-test');
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/certificates/mc-test/renew');
|
||||
expect(init.method).toBe('POST');
|
||||
});
|
||||
|
||||
it('triggerDeployment sends POST with target_id', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'deployment triggered' }));
|
||||
await triggerDeployment('mc-test', 't-nginx');
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/certificates/mc-test/deploy');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(JSON.parse(init.body)).toEqual({ target_id: 't-nginx' });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Agents ─────────────────────────────────────────
|
||||
|
||||
describe('Agents', () => {
|
||||
it('getAgents sends GET with pagination', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
|
||||
await getAgents();
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/agents?page=1&per_page=50');
|
||||
});
|
||||
|
||||
it('getAgent fetches by ID', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'a-web01', name: 'web01' }));
|
||||
const agent = await getAgent('a-web01');
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/agents/a-web01');
|
||||
expect(agent.id).toBe('a-web01');
|
||||
});
|
||||
|
||||
it('registerAgent sends POST', async () => {
|
||||
const agentData = { name: 'new-agent', hostname: 'host01' };
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'a-new', ...agentData }));
|
||||
await registerAgent(agentData);
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/agents');
|
||||
expect(init.method).toBe('POST');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Jobs ───────────────────────────────────────────
|
||||
|
||||
describe('Jobs', () => {
|
||||
it('getJobs sends GET with filters', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
|
||||
await getJobs({ status: 'Pending', type: 'Renewal' });
|
||||
const url = mockFetch.mock.calls[0][0] as string;
|
||||
expect(url).toContain('status=Pending');
|
||||
expect(url).toContain('type=Renewal');
|
||||
});
|
||||
|
||||
it('cancelJob sends POST', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'cancelled' }));
|
||||
await cancelJob('job-123');
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/jobs/job-123/cancel');
|
||||
expect(init.method).toBe('POST');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Notifications ──────────────────────────────────
|
||||
|
||||
describe('Notifications', () => {
|
||||
it('getNotifications sends GET', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
|
||||
await getNotifications();
|
||||
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/notifications');
|
||||
});
|
||||
|
||||
it('markNotificationRead sends POST with auth headers', async () => {
|
||||
setApiKey('test-key');
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'marked as read' }));
|
||||
await markNotificationRead('notif-123');
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/notifications/notif-123/read');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers['Authorization']).toBe('Bearer test-key');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Policies ───────────────────────────────────────
|
||||
|
||||
describe('Policies', () => {
|
||||
it('getPolicies sends GET', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
|
||||
await getPolicies();
|
||||
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/policies');
|
||||
});
|
||||
|
||||
it('updatePolicy sends PUT with partial data', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'pol-1', enabled: false }));
|
||||
await updatePolicy('pol-1', { enabled: false });
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/policies/pol-1');
|
||||
expect(init.method).toBe('PUT');
|
||||
expect(JSON.parse(init.body)).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('deletePolicy sends DELETE', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'deleted' }));
|
||||
await deletePolicy('pol-1');
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/policies/pol-1');
|
||||
expect(init.method).toBe('DELETE');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Issuers ────────────────────────────────────────
|
||||
|
||||
describe('Issuers', () => {
|
||||
it('getIssuers sends GET', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
|
||||
await getIssuers();
|
||||
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/issuers');
|
||||
});
|
||||
|
||||
it('testIssuerConnection sends POST', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'ok' }));
|
||||
await testIssuerConnection('iss-local');
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/issuers/iss-local/test');
|
||||
expect(init.method).toBe('POST');
|
||||
});
|
||||
|
||||
it('deleteIssuer sends DELETE', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'deleted' }));
|
||||
await deleteIssuer('iss-local');
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/issuers/iss-local');
|
||||
expect(init.method).toBe('DELETE');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Targets ────────────────────────────────────────
|
||||
|
||||
describe('Targets', () => {
|
||||
it('getTargets sends GET', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
|
||||
await getTargets();
|
||||
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/targets');
|
||||
});
|
||||
|
||||
it('createTarget sends POST', async () => {
|
||||
const targetData = { name: 'nginx-01', type: 'nginx', hostname: 'web01.example.com' };
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 't-new', ...targetData }));
|
||||
await createTarget(targetData);
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/targets');
|
||||
expect(init.method).toBe('POST');
|
||||
});
|
||||
|
||||
it('deleteTarget sends DELETE', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'deleted' }));
|
||||
await deleteTarget('t-nginx');
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/targets/t-nginx');
|
||||
expect(init.method).toBe('DELETE');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Audit ──────────────────────────────────────────
|
||||
|
||||
describe('Audit', () => {
|
||||
it('getAuditEvents sends GET with filters', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
|
||||
await getAuditEvents({ resource_type: 'certificate' });
|
||||
const url = mockFetch.mock.calls[0][0] as string;
|
||||
expect(url).toContain('resource_type=certificate');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Health ─────────────────────────────────────────
|
||||
|
||||
describe('Health', () => {
|
||||
it('getHealth calls /health endpoint', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ status: 'ok' }));
|
||||
const result = await getHealth();
|
||||
expect(mockFetch.mock.calls[0][0]).toBe('/health');
|
||||
expect(result.status).toBe('ok');
|
||||
});
|
||||
});
|
||||
});
|
||||
+40
-1
@@ -1,4 +1,4 @@
|
||||
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, Issuer, Target, PaginatedResponse } from './types';
|
||||
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, Issuer, Target, PaginatedResponse } from './types';
|
||||
|
||||
const BASE = '/api/v1';
|
||||
|
||||
@@ -70,6 +70,12 @@ export const createCertificate = (data: Partial<Certificate>) =>
|
||||
export const triggerRenewal = (id: string) =>
|
||||
fetchJSON<{ message: string }>(`${BASE}/certificates/${id}/renew`, { method: 'POST' });
|
||||
|
||||
export const updateCertificate = (id: string, data: Partial<Certificate>) =>
|
||||
fetchJSON<Certificate>(`${BASE}/certificates/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
|
||||
export const archiveCertificate = (id: string) =>
|
||||
fetchJSON<{ message: string }>(`${BASE}/certificates/${id}`, { method: 'DELETE' });
|
||||
|
||||
export const triggerDeployment = (id: string, targetId: string) =>
|
||||
fetchJSON<{ message: string }>(`${BASE}/certificates/${id}/deploy`, {
|
||||
method: 'POST',
|
||||
@@ -85,6 +91,9 @@ export const getAgents = (params: Record<string, string> = {}) => {
|
||||
export const getAgent = (id: string) =>
|
||||
fetchJSON<Agent>(`${BASE}/agents/${id}`);
|
||||
|
||||
export const registerAgent = (data: Partial<Agent>) =>
|
||||
fetchJSON<Agent>(`${BASE}/agents`, { method: 'POST', body: JSON.stringify(data) });
|
||||
|
||||
// Jobs
|
||||
export const getJobs = (params: Record<string, string> = {}) => {
|
||||
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||
@@ -100,6 +109,9 @@ export const getNotifications = (params: Record<string, string> = {}) => {
|
||||
return fetchJSON<PaginatedResponse<Notification>>(`${BASE}/notifications?${qs}`);
|
||||
};
|
||||
|
||||
export const markNotificationRead = (id: string) =>
|
||||
fetchJSON<{ message: string }>(`${BASE}/notifications/${id}/read`, { method: 'POST' });
|
||||
|
||||
// Audit
|
||||
export const getAuditEvents = (params: Record<string, string> = {}) => {
|
||||
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||
@@ -112,17 +124,44 @@ export const getPolicies = (params: Record<string, string> = {}) => {
|
||||
return fetchJSON<PaginatedResponse<PolicyRule>>(`${BASE}/policies?${qs}`);
|
||||
};
|
||||
|
||||
export const createPolicy = (data: Partial<PolicyRule>) =>
|
||||
fetchJSON<PolicyRule>(`${BASE}/policies`, { method: 'POST', body: JSON.stringify(data) });
|
||||
|
||||
export const updatePolicy = (id: string, data: Partial<PolicyRule>) =>
|
||||
fetchJSON<PolicyRule>(`${BASE}/policies/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
|
||||
export const deletePolicy = (id: string) =>
|
||||
fetchJSON<{ message: string }>(`${BASE}/policies/${id}`, { method: 'DELETE' });
|
||||
|
||||
export const getPolicyViolations = (id: string) =>
|
||||
fetchJSON<PaginatedResponse<PolicyViolation>>(`${BASE}/policies/${id}/violations`);
|
||||
|
||||
// Issuers
|
||||
export const getIssuers = (params: Record<string, string> = {}) => {
|
||||
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||
return fetchJSON<PaginatedResponse<Issuer>>(`${BASE}/issuers?${qs}`);
|
||||
};
|
||||
|
||||
export const createIssuer = (data: Partial<Issuer>) =>
|
||||
fetchJSON<Issuer>(`${BASE}/issuers`, { method: 'POST', body: JSON.stringify(data) });
|
||||
|
||||
export const testIssuerConnection = (id: string) =>
|
||||
fetchJSON<{ message: string }>(`${BASE}/issuers/${id}/test`, { method: 'POST' });
|
||||
|
||||
export const deleteIssuer = (id: string) =>
|
||||
fetchJSON<{ message: string }>(`${BASE}/issuers/${id}`, { method: 'DELETE' });
|
||||
|
||||
// Targets
|
||||
export const getTargets = (params: Record<string, string> = {}) => {
|
||||
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||
return fetchJSON<PaginatedResponse<Target>>(`${BASE}/targets?${qs}`);
|
||||
};
|
||||
|
||||
export const createTarget = (data: Partial<Target>) =>
|
||||
fetchJSON<Target>(`${BASE}/targets`, { method: 'POST', body: JSON.stringify(data) });
|
||||
|
||||
export const deleteTarget = (id: string) =>
|
||||
fetchJSON<{ message: string }>(`${BASE}/targets/${id}`, { method: 'DELETE' });
|
||||
|
||||
// Health
|
||||
export const getHealth = () => fetchJSON<{ status: string }>('/health');
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { formatDate, formatDateTime, timeAgo, daysUntil, expiryColor } from './utils';
|
||||
|
||||
describe('Utility functions', () => {
|
||||
describe('formatDate', () => {
|
||||
it('returns dash for empty string', () => {
|
||||
expect(formatDate('')).toBe('—');
|
||||
});
|
||||
|
||||
it('formats ISO date string', () => {
|
||||
const result = formatDate('2026-06-15T12:00:00Z');
|
||||
expect(result).toContain('Jun');
|
||||
expect(result).toContain('15');
|
||||
expect(result).toContain('2026');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDateTime', () => {
|
||||
it('returns dash for empty string', () => {
|
||||
expect(formatDateTime('')).toBe('—');
|
||||
});
|
||||
|
||||
it('formats ISO datetime string with time', () => {
|
||||
const result = formatDateTime('2026-06-15T14:30:00Z');
|
||||
expect(result).toContain('Jun');
|
||||
expect(result).toContain('15');
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeAgo', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-03-15T12:00:00Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns dash for empty string', () => {
|
||||
expect(timeAgo('')).toBe('—');
|
||||
});
|
||||
|
||||
it('returns "just now" for recent times', () => {
|
||||
expect(timeAgo('2026-03-15T11:59:45Z')).toBe('just now');
|
||||
});
|
||||
|
||||
it('returns minutes ago', () => {
|
||||
expect(timeAgo('2026-03-15T11:45:00Z')).toBe('15m ago');
|
||||
});
|
||||
|
||||
it('returns hours ago', () => {
|
||||
expect(timeAgo('2026-03-15T09:00:00Z')).toBe('3h ago');
|
||||
});
|
||||
|
||||
it('returns days ago', () => {
|
||||
expect(timeAgo('2026-03-12T12:00:00Z')).toBe('3d ago');
|
||||
});
|
||||
|
||||
it('returns formatted date for old dates', () => {
|
||||
const result = timeAgo('2025-01-15T12:00:00Z');
|
||||
expect(result).toContain('2025');
|
||||
});
|
||||
});
|
||||
|
||||
describe('daysUntil', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-03-15T12:00:00Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns 0 for empty string', () => {
|
||||
expect(daysUntil('')).toBe(0);
|
||||
});
|
||||
|
||||
it('returns positive days for future date', () => {
|
||||
expect(daysUntil('2026-03-25T12:00:00Z')).toBe(10);
|
||||
});
|
||||
|
||||
it('returns negative days for past date', () => {
|
||||
expect(daysUntil('2026-03-10T12:00:00Z')).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expiryColor', () => {
|
||||
it('returns red for expired (0 days)', () => {
|
||||
expect(expiryColor(0)).toContain('red');
|
||||
});
|
||||
|
||||
it('returns red for <= 7 days', () => {
|
||||
expect(expiryColor(5)).toContain('red');
|
||||
});
|
||||
|
||||
it('returns amber for <= 14 days', () => {
|
||||
expect(expiryColor(12)).toContain('amber');
|
||||
});
|
||||
|
||||
it('returns amber for <= 30 days', () => {
|
||||
expect(expiryColor(25)).toContain('amber');
|
||||
});
|
||||
|
||||
it('returns green for > 30 days', () => {
|
||||
expect(expiryColor(60)).toContain('emerald');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user