mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 19:10:22 +01:00
feat(frontend): TypeScript-Monorepo mit Terminal-UI für Effigenix ERP
Monorepo-Setup (pnpm workspaces) mit vier shared Packages und einer TUI-App: Shared Packages: - @effigenix/types: TypeScript-DTOs (UserDTO, RoleDTO, AuthDTO, Enums) - @effigenix/config: API-Konfiguration und Shared Constants - @effigenix/validation: Zod-Schemas für Username, E-Mail und Passwort - @effigenix/api-client: axios-Client mit JWT-Handling (proaktiver + reaktiver Token-Refresh), AuthInterceptor, ErrorInterceptor, Resources für auth/users/roles TUI (apps/cli, Ink 5 / React): - Authentication: Login/Logout, Session-Restore beim Start, JWT-Refresh - User Management: Liste, Anlage (Zod-Inline-Validation), Detailansicht, Passwort ändern, Sperren/Entsperren mit ConfirmDialog - Role Management: Liste, Detailansicht, Zuweisen/Entfernen per RoleSelectList (↑↓) - UX: SuccessDisplay (Auto-Dismiss 3 s), ConfirmDialog (J/N), FormInput mit Inline-Fehlern, StatusBar mit API-URL - Layout: Fullscreen-Modus (alternate screen buffer), Header mit eingeloggtem User - Tests: vitest + ink-testing-library (15 Tests)
This commit is contained in:
parent
87123df2e4
commit
bbe9e87c33
65 changed files with 6955 additions and 1 deletions
62
frontend/packages/api-client/src/__tests__/errors.test.ts
Normal file
62
frontend/packages/api-client/src/__tests__/errors.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
ApiError,
|
||||
AuthenticationError,
|
||||
NetworkError,
|
||||
RefreshTokenExpiredError,
|
||||
} from '../errors.js';
|
||||
|
||||
describe('ApiError', () => {
|
||||
it('stores status code and message', () => {
|
||||
const error = new ApiError('Not found', 404);
|
||||
expect(error.message).toBe('Not found');
|
||||
expect(error.status).toBe(404);
|
||||
expect(error.name).toBe('ApiError');
|
||||
});
|
||||
|
||||
it('stores optional validation errors', () => {
|
||||
const validationErrors = [{ field: 'email', message: 'Invalid email' }];
|
||||
const error = new ApiError('Validation failed', 400, undefined, validationErrors);
|
||||
expect(error.validationErrors).toEqual(validationErrors);
|
||||
});
|
||||
|
||||
it('stores optional code', () => {
|
||||
const error = new ApiError('Conflict', 409, 'USERNAME_TAKEN');
|
||||
expect(error.code).toBe('USERNAME_TAKEN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthenticationError', () => {
|
||||
it('defaults to 401 status', () => {
|
||||
const error = new AuthenticationError();
|
||||
expect(error.status).toBe(401);
|
||||
expect(error.name).toBe('AuthenticationError');
|
||||
});
|
||||
|
||||
it('accepts custom status for 403', () => {
|
||||
const error = new AuthenticationError('Forbidden', 403);
|
||||
expect(error.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NetworkError', () => {
|
||||
it('defaults to non-timeout', () => {
|
||||
const error = new NetworkError();
|
||||
expect(error.isTimeout).toBe(false);
|
||||
expect(error.name).toBe('NetworkError');
|
||||
});
|
||||
|
||||
it('flags timeout errors', () => {
|
||||
const error = new NetworkError('Request timed out', true);
|
||||
expect(error.isTimeout).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RefreshTokenExpiredError', () => {
|
||||
it('is an AuthenticationError with correct message', () => {
|
||||
const error = new RefreshTokenExpiredError();
|
||||
expect(error).toBeInstanceOf(AuthenticationError);
|
||||
expect(error.message).toBe('Session expired. Please log in again.');
|
||||
expect(error.name).toBe('RefreshTokenExpiredError');
|
||||
});
|
||||
});
|
||||
154
frontend/packages/api-client/src/__tests__/interceptors.test.ts
Normal file
154
frontend/packages/api-client/src/__tests__/interceptors.test.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import axios from 'axios';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { createApiClient } from '../client.js';
|
||||
import { ApiError, AuthenticationError, NetworkError, RefreshTokenExpiredError } from '../errors.js';
|
||||
import type { TokenProvider } from '../token-provider.js';
|
||||
|
||||
function makeTokenProvider(overrides: Partial<TokenProvider> = {}): TokenProvider {
|
||||
return {
|
||||
getAccessToken: vi.fn().mockResolvedValue('access-token'),
|
||||
getRefreshToken: vi.fn().mockResolvedValue('refresh-token'),
|
||||
saveTokens: vi.fn().mockResolvedValue(undefined),
|
||||
clearTokens: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Auth interceptor', () => {
|
||||
it('attaches Authorization header to requests', async () => {
|
||||
const tokenProvider = makeTokenProvider();
|
||||
const client = createApiClient({}, tokenProvider);
|
||||
const mock = new MockAdapter(client);
|
||||
|
||||
mock.onGet('/test').reply(200, { ok: true });
|
||||
|
||||
await client.get('/test');
|
||||
|
||||
expect(mock.history['get']?.[0]?.headers?.['Authorization']).toBe('Bearer access-token');
|
||||
});
|
||||
|
||||
it('does not attach header when no token is stored', async () => {
|
||||
const tokenProvider = makeTokenProvider({
|
||||
getAccessToken: vi.fn().mockResolvedValue(null),
|
||||
});
|
||||
const client = createApiClient({}, tokenProvider);
|
||||
const mock = new MockAdapter(client);
|
||||
|
||||
mock.onGet('/test').reply(200, {});
|
||||
|
||||
await client.get('/test');
|
||||
|
||||
expect(mock.history['get']?.[0]?.headers?.['Authorization']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error interceptor', () => {
|
||||
it('converts 401 to AuthenticationError', async () => {
|
||||
const client = createApiClient({}, makeTokenProvider({
|
||||
getRefreshToken: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
const mock = new MockAdapter(client);
|
||||
|
||||
mock.onGet('/secure').reply(401, { message: 'Unauthorized' });
|
||||
|
||||
await expect(client.get('/secure')).rejects.toBeInstanceOf(AuthenticationError);
|
||||
});
|
||||
|
||||
it('converts 404 to ApiError with correct status', async () => {
|
||||
const client = createApiClient({}, makeTokenProvider());
|
||||
const mock = new MockAdapter(client);
|
||||
|
||||
mock.onGet('/missing').reply(404, { message: 'Not found' });
|
||||
|
||||
await expect(client.get('/missing')).rejects.toMatchObject({
|
||||
status: 404,
|
||||
message: 'Not found',
|
||||
});
|
||||
});
|
||||
|
||||
it('converts network timeout to NetworkError with isTimeout=true', async () => {
|
||||
const client = createApiClient({ timeoutMs: 100 }, makeTokenProvider());
|
||||
const mock = new MockAdapter(client);
|
||||
|
||||
mock.onGet('/slow').timeout();
|
||||
|
||||
const error = await client.get('/slow').catch((e: unknown) => e);
|
||||
expect(error).toBeInstanceOf(NetworkError);
|
||||
expect((error as NetworkError).isTimeout).toBe(true);
|
||||
});
|
||||
|
||||
it('converts network error to NetworkError', async () => {
|
||||
const client = createApiClient({}, makeTokenProvider());
|
||||
const mock = new MockAdapter(client);
|
||||
|
||||
mock.onGet('/down').networkError();
|
||||
|
||||
await expect(client.get('/down')).rejects.toBeInstanceOf(NetworkError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Refresh interceptor', () => {
|
||||
it('retries request with new token after successful refresh', async () => {
|
||||
const tokenProvider = makeTokenProvider();
|
||||
const client = createApiClient({}, tokenProvider);
|
||||
const mock = new MockAdapter(client);
|
||||
|
||||
// First call returns 401, then after refresh returns 200
|
||||
let callCount = 0;
|
||||
mock.onGet('/api/data').reply(() => {
|
||||
callCount++;
|
||||
return callCount === 1 ? [401, { message: 'Unauthorized' }] : [200, { data: 'ok' }];
|
||||
});
|
||||
|
||||
mock.onPost('/api/auth/refresh').reply(200, {
|
||||
accessToken: 'new-access-token',
|
||||
refreshToken: 'new-refresh-token',
|
||||
expiresAt: '2026-02-17T15:00:00Z',
|
||||
});
|
||||
|
||||
const response = await client.get('/api/data');
|
||||
expect(response.data).toEqual({ data: 'ok' });
|
||||
expect(tokenProvider.saveTokens).toHaveBeenCalledWith(
|
||||
'new-access-token',
|
||||
'new-refresh-token',
|
||||
'2026-02-17T15:00:00Z',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws RefreshTokenExpiredError when no refresh token is stored', async () => {
|
||||
const tokenProvider = makeTokenProvider({
|
||||
getRefreshToken: vi.fn().mockResolvedValue(null),
|
||||
});
|
||||
const client = createApiClient({}, tokenProvider);
|
||||
const mock = new MockAdapter(client);
|
||||
|
||||
mock.onGet('/api/data').reply(401, { message: 'Unauthorized' });
|
||||
|
||||
await expect(client.get('/api/data')).rejects.toBeInstanceOf(RefreshTokenExpiredError);
|
||||
expect(tokenProvider.clearTokens).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws RefreshTokenExpiredError when refresh request fails', async () => {
|
||||
const tokenProvider = makeTokenProvider();
|
||||
const client = createApiClient({}, tokenProvider);
|
||||
const mock = new MockAdapter(client);
|
||||
|
||||
mock.onGet('/api/data').reply(401, { message: 'Unauthorized' });
|
||||
mock.onPost('/api/auth/refresh').reply(401, { message: 'Refresh token expired' });
|
||||
|
||||
await expect(client.get('/api/data')).rejects.toBeInstanceOf(RefreshTokenExpiredError);
|
||||
expect(tokenProvider.clearTokens).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not retry login endpoint on 401', async () => {
|
||||
const tokenProvider = makeTokenProvider();
|
||||
const client = createApiClient({}, tokenProvider);
|
||||
const mock = new MockAdapter(client);
|
||||
|
||||
mock.onPost('/api/auth/login').reply(401, { message: 'Invalid credentials' });
|
||||
|
||||
await expect(client.post('/api/auth/login', {})).rejects.toBeInstanceOf(AuthenticationError);
|
||||
expect(tokenProvider.saveTokens).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue