165 lines
4.9 KiB
TypeScript
165 lines
4.9 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { api } from './api.js';
|
|
|
|
global.fetch = vi.fn();
|
|
|
|
function mockJsonResponse(body: unknown, init: Partial<Response> = {}): Response {
|
|
return {
|
|
ok: init.ok ?? true,
|
|
status: init.status ?? 200,
|
|
statusText: init.statusText ?? 'OK',
|
|
json: vi.fn().mockResolvedValue(body),
|
|
} as unknown as Response;
|
|
}
|
|
|
|
describe('API Client', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
window.localStorage.clear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
window.localStorage.clear();
|
|
});
|
|
|
|
describe('getServices', () => {
|
|
it('fetches services successfully', async () => {
|
|
const mockServices = [
|
|
{
|
|
id: 'test-service',
|
|
name: 'Test Service',
|
|
scriptPath: '../deploy-test.sh',
|
|
healthUrl: 'https://test.example.com/health',
|
|
repoPath: '../test-repo',
|
|
status: 'up' as const,
|
|
version: '1.0.0',
|
|
productId: 'devops-internal',
|
|
},
|
|
];
|
|
|
|
vi.mocked(global.fetch).mockResolvedValueOnce(mockJsonResponse(mockServices));
|
|
|
|
const services = await api.getServices();
|
|
|
|
expect(services).toEqual(mockServices);
|
|
expect(global.fetch).toHaveBeenCalledWith(
|
|
'http://localhost:4004/api/services',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('throws the normalized API error object on fetch failure', async () => {
|
|
vi.mocked(global.fetch).mockResolvedValueOnce(mockJsonResponse({ error: 'boom' }, {
|
|
ok: false,
|
|
status: 500,
|
|
statusText: 'Internal Server Error',
|
|
}));
|
|
|
|
await expect(api.getServices()).rejects.toMatchObject({
|
|
error: 'API error: 500 Internal Server Error',
|
|
status: 500,
|
|
});
|
|
});
|
|
|
|
it('includes an auth token when available', async () => {
|
|
window.localStorage.setItem('access_token', 'test-token');
|
|
vi.mocked(global.fetch).mockResolvedValueOnce(mockJsonResponse([]));
|
|
|
|
await api.getServices();
|
|
|
|
expect(global.fetch).toHaveBeenCalledWith(
|
|
'http://localhost:4004/api/services',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer test-token',
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getHermesOps', () => {
|
|
it('fetches the live Hermes operations snapshot', async () => {
|
|
const snapshot = {
|
|
generatedAt: '2026-05-27T13:03:14.848Z',
|
|
tailscaleIp: '100.87.53.10',
|
|
emergencyDriveUpload: {
|
|
name: 'hermes-emergency-drive-upload.timer',
|
|
active: true,
|
|
nextRun: 'Thu 2026-05-28 03:26:15 UTC',
|
|
lastRun: null,
|
|
},
|
|
instances: [],
|
|
warnings: [],
|
|
};
|
|
|
|
vi.mocked(global.fetch).mockResolvedValueOnce(mockJsonResponse(snapshot));
|
|
|
|
await expect(api.getHermesOps()).resolves.toEqual(snapshot);
|
|
expect(global.fetch).toHaveBeenCalledWith(
|
|
'http://localhost:4004/api/hermes/ops',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
'Content-Type': 'application/json',
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('state-changing requests', () => {
|
|
it('triggers a deployment without CSRF when no user token exists', async () => {
|
|
const mockResponse = {
|
|
deploymentId: 'deployment-123',
|
|
status: 'running',
|
|
};
|
|
|
|
vi.mocked(global.fetch).mockResolvedValueOnce(mockJsonResponse(mockResponse));
|
|
|
|
const result = await api.triggerDeployment('test-service');
|
|
|
|
expect(result).toEqual(mockResponse);
|
|
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
expect(global.fetch).toHaveBeenCalledWith(
|
|
'http://localhost:4004/api/deployments/trigger/test-service',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('fetches and attaches CSRF tokens for authenticated mutations', async () => {
|
|
window.localStorage.setItem('access_token', 'test-token');
|
|
vi.mocked(global.fetch)
|
|
.mockResolvedValueOnce(mockJsonResponse({ csrfToken: 'csrf-token' }))
|
|
.mockResolvedValueOnce(mockJsonResponse({ message: 'Seeded default services' }));
|
|
|
|
const result = await api.seedServices();
|
|
|
|
expect(result).toEqual({ message: 'Seeded default services' });
|
|
expect(global.fetch).toHaveBeenNthCalledWith(
|
|
1,
|
|
'http://localhost:4004/api/csrf-token',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({ Authorization: 'Bearer test-token' }),
|
|
})
|
|
);
|
|
expect(global.fetch).toHaveBeenNthCalledWith(
|
|
2,
|
|
'http://localhost:4004/api/seed',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer test-token',
|
|
'X-CSRF-Token': 'csrf-token',
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
});
|