1
0
Fork 0
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:
Sebastian Frick 2026-02-18 12:23:11 +01:00
parent 87123df2e4
commit bbe9e87c33
65 changed files with 6955 additions and 1 deletions

View 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');
});
});

View 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();
});
});