add tests
Some checks failed
Contract Testing / contract-tests (push) Failing after 6m7s
Contract Testing / contract-snapshot (push) Failing after 4m46s

This commit is contained in:
2026-01-22 11:52:42 +01:00
parent 40bc15ff61
commit fb1221701d
112 changed files with 30625 additions and 1059 deletions

View File

@@ -0,0 +1,112 @@
/**
* Health View Data Builder
*
* Transforms health DTO data into UI-ready view models.
* This layer isolates the UI from API churn by providing a stable interface
* between the API layer and the presentation layer.
*/
import type { HealthViewData, HealthStatus, HealthMetrics, HealthComponent, HealthAlert } from '@/lib/view-data/HealthViewData';
import { HealthStatusDisplay } from '@/lib/display-objects/HealthStatusDisplay';
import { HealthMetricDisplay } from '@/lib/display-objects/HealthMetricDisplay';
import { HealthComponentDisplay } from '@/lib/display-objects/HealthComponentDisplay';
import { HealthAlertDisplay } from '@/lib/display-objects/HealthAlertDisplay';
export interface HealthDTO {
status: 'ok' | 'degraded' | 'error' | 'unknown';
timestamp: string;
uptime?: number;
responseTime?: number;
errorRate?: number;
lastCheck?: string;
checksPassed?: number;
checksFailed?: number;
components?: Array<{
name: string;
status: 'ok' | 'degraded' | 'error' | 'unknown';
lastCheck?: string;
responseTime?: number;
errorRate?: number;
}>;
alerts?: Array<{
id: string;
type: 'critical' | 'warning' | 'info';
title: string;
message: string;
timestamp: string;
}>;
}
export class HealthViewDataBuilder {
static build(dto: HealthDTO): HealthViewData {
const now = new Date();
const lastUpdated = dto.timestamp || now.toISOString();
// Build overall status
const overallStatus: HealthStatus = {
status: dto.status,
timestamp: dto.timestamp,
formattedTimestamp: HealthStatusDisplay.formatTimestamp(dto.timestamp),
relativeTime: HealthStatusDisplay.formatRelativeTime(dto.timestamp),
statusLabel: HealthStatusDisplay.formatStatusLabel(dto.status),
statusColor: HealthStatusDisplay.formatStatusColor(dto.status),
statusIcon: HealthStatusDisplay.formatStatusIcon(dto.status),
};
// Build metrics
const metrics: HealthMetrics = {
uptime: HealthMetricDisplay.formatUptime(dto.uptime),
responseTime: HealthMetricDisplay.formatResponseTime(dto.responseTime),
errorRate: HealthMetricDisplay.formatErrorRate(dto.errorRate),
lastCheck: dto.lastCheck || lastUpdated,
formattedLastCheck: HealthMetricDisplay.formatTimestamp(dto.lastCheck || lastUpdated),
checksPassed: dto.checksPassed || 0,
checksFailed: dto.checksFailed || 0,
totalChecks: (dto.checksPassed || 0) + (dto.checksFailed || 0),
successRate: HealthMetricDisplay.formatSuccessRate(dto.checksPassed, dto.checksFailed),
};
// Build components
const components: HealthComponent[] = (dto.components || []).map((component) => ({
name: component.name,
status: component.status,
statusLabel: HealthComponentDisplay.formatStatusLabel(component.status),
statusColor: HealthComponentDisplay.formatStatusColor(component.status),
statusIcon: HealthComponentDisplay.formatStatusIcon(component.status),
lastCheck: component.lastCheck || lastUpdated,
formattedLastCheck: HealthComponentDisplay.formatTimestamp(component.lastCheck || lastUpdated),
responseTime: HealthMetricDisplay.formatResponseTime(component.responseTime),
errorRate: HealthMetricDisplay.formatErrorRate(component.errorRate),
}));
// Build alerts
const alerts: HealthAlert[] = (dto.alerts || []).map((alert) => ({
id: alert.id,
type: alert.type,
title: alert.title,
message: alert.message,
timestamp: alert.timestamp,
formattedTimestamp: HealthAlertDisplay.formatTimestamp(alert.timestamp),
relativeTime: HealthAlertDisplay.formatRelativeTime(alert.timestamp),
severity: HealthAlertDisplay.formatSeverity(alert.type),
severityColor: HealthAlertDisplay.formatSeverityColor(alert.type),
}));
// Calculate derived fields
const hasAlerts = alerts.length > 0;
const hasDegradedComponents = components.some((c) => c.status === 'degraded');
const hasErrorComponents = components.some((c) => c.status === 'error');
return {
overallStatus,
metrics,
components,
alerts,
hasAlerts,
hasDegradedComponents,
hasErrorComponents,
lastUpdated,
formattedLastUpdated: HealthStatusDisplay.formatTimestamp(lastUpdated),
};
}
}

View File

@@ -0,0 +1,53 @@
/**
* Health Alert Display Object
*
* Provides formatting and display logic for health alerts.
* This display object isolates UI-specific formatting from business logic.
*/
export class HealthAlertDisplay {
static formatSeverity(type: 'critical' | 'warning' | 'info'): string {
const severities: Record<string, string> = {
critical: 'Critical',
warning: 'Warning',
info: 'Info',
};
return severities[type] || 'Info';
}
static formatSeverityColor(type: 'critical' | 'warning' | 'info'): string {
const colors: Record<string, string> = {
critical: '#ef4444', // red-500
warning: '#f59e0b', // amber-500
info: '#3b82f6', // blue-500
};
return colors[type] || '#3b82f6';
}
static formatTimestamp(timestamp: string): string {
const date = new Date(timestamp);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
static formatRelativeTime(timestamp: string): string {
const now = new Date();
const date = new Date(timestamp);
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return `${Math.floor(diffDays / 7)}w ago`;
}
}

View File

@@ -0,0 +1,50 @@
/**
* Health Component Display Object
*
* Provides formatting and display logic for health components.
* This display object isolates UI-specific formatting from business logic.
*/
export class HealthComponentDisplay {
static formatStatusLabel(status: 'ok' | 'degraded' | 'error' | 'unknown'): string {
const labels: Record<string, string> = {
ok: 'Healthy',
degraded: 'Degraded',
error: 'Error',
unknown: 'Unknown',
};
return labels[status] || 'Unknown';
}
static formatStatusColor(status: 'ok' | 'degraded' | 'error' | 'unknown'): string {
const colors: Record<string, string> = {
ok: '#10b981', // green-500
degraded: '#f59e0b', // amber-500
error: '#ef4444', // red-500
unknown: '#6b7280', // gray-500
};
return colors[status] || '#6b7280';
}
static formatStatusIcon(status: 'ok' | 'degraded' | 'error' | 'unknown'): string {
const icons: Record<string, string> = {
ok: '✓',
degraded: '⚠',
error: '✕',
unknown: '?',
};
return icons[status] || '?';
}
static formatTimestamp(timestamp: string): string {
const date = new Date(timestamp);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
}

View File

@@ -0,0 +1,61 @@
/**
* Health Metric Display Object
*
* Provides formatting and display logic for health metrics.
* This display object isolates UI-specific formatting from business logic.
*/
export class HealthMetricDisplay {
static formatUptime(uptime?: number): string {
if (uptime === undefined || uptime === null) return 'N/A';
if (uptime < 0) return 'N/A';
// Format as percentage with 2 decimal places
return `${uptime.toFixed(2)}%`;
}
static formatResponseTime(responseTime?: number): string {
if (responseTime === undefined || responseTime === null) return 'N/A';
if (responseTime < 0) return 'N/A';
// Format as milliseconds with appropriate units
if (responseTime < 1000) {
return `${responseTime.toFixed(0)}ms`;
} else if (responseTime < 60000) {
return `${(responseTime / 1000).toFixed(2)}s`;
} else {
return `${(responseTime / 60000).toFixed(2)}m`;
}
}
static formatErrorRate(errorRate?: number): string {
if (errorRate === undefined || errorRate === null) return 'N/A';
if (errorRate < 0) return 'N/A';
// Format as percentage with 2 decimal places
return `${errorRate.toFixed(2)}%`;
}
static formatTimestamp(timestamp: string): string {
const date = new Date(timestamp);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
static formatSuccessRate(checksPassed?: number, checksFailed?: number): string {
const passed = checksPassed || 0;
const failed = checksFailed || 0;
const total = passed + failed;
if (total === 0) return 'N/A';
const successRate = (passed / total) * 100;
return `${successRate.toFixed(1)}%`;
}
}

View File

@@ -0,0 +1,65 @@
/**
* Health Status Display Object
*
* Provides formatting and display logic for health status data.
* This display object isolates UI-specific formatting from business logic.
*/
export class HealthStatusDisplay {
static formatStatusLabel(status: 'ok' | 'degraded' | 'error' | 'unknown'): string {
const labels: Record<string, string> = {
ok: 'Healthy',
degraded: 'Degraded',
error: 'Error',
unknown: 'Unknown',
};
return labels[status] || 'Unknown';
}
static formatStatusColor(status: 'ok' | 'degraded' | 'error' | 'unknown'): string {
const colors: Record<string, string> = {
ok: '#10b981', // green-500
degraded: '#f59e0b', // amber-500
error: '#ef4444', // red-500
unknown: '#6b7280', // gray-500
};
return colors[status] || '#6b7280';
}
static formatStatusIcon(status: 'ok' | 'degraded' | 'error' | 'unknown'): string {
const icons: Record<string, string> = {
ok: '✓',
degraded: '⚠',
error: '✕',
unknown: '?',
};
return icons[status] || '?';
}
static formatTimestamp(timestamp: string): string {
const date = new Date(timestamp);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
static formatRelativeTime(timestamp: string): string {
const now = new Date();
const date = new Date(timestamp);
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return `${Math.floor(diffDays / 7)}w ago`;
}
}

View File

@@ -0,0 +1,298 @@
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { isProductionEnvironment } from '@/lib/config/env';
import { Result } from '@/lib/contracts/Result';
import type { Service } from '@/lib/contracts/services/Service';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
export interface HealthStatus {
status: 'healthy' | 'degraded' | 'unhealthy';
timestamp: string;
dependencies: {
api: HealthDependencyStatus;
database: HealthDependencyStatus;
externalService: HealthDependencyStatus;
};
details?: string;
}
export interface HealthDependencyStatus {
status: 'healthy' | 'degraded' | 'unhealthy';
latency?: number;
error?: string;
}
export type HealthRouteServiceError = 'unavailable' | 'degraded' | 'unknown';
export class HealthRouteService implements Service {
private readonly maxRetries = 3;
private readonly retryDelay = 100;
private readonly timeout = 5000;
async getHealth(): Promise<Result<HealthStatus, HealthRouteServiceError>> {
const logger = new ConsoleLogger();
const baseUrl = getWebsiteApiBaseUrl();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: false,
logToConsole: true,
reportToExternal: isProductionEnvironment(),
});
try {
// Check multiple dependencies with retry logic
const apiHealth = await this.checkApiHealth(baseUrl, errorReporter, logger);
const databaseHealth = await this.checkDatabaseHealth(errorReporter, logger);
const externalServiceHealth = await this.checkExternalServiceHealth(errorReporter, logger);
// Aggregate health status
const aggregatedStatus = this.aggregateHealthStatus(
apiHealth,
databaseHealth,
externalServiceHealth
);
// Make decision based on aggregated status
const decision = this.makeHealthDecision(aggregatedStatus);
return Result.ok({
status: decision,
timestamp: new Date().toISOString(),
dependencies: {
api: apiHealth,
database: databaseHealth,
externalService: externalServiceHealth,
},
});
} catch (error) {
logger.error('HealthRouteService failed', error instanceof Error ? error : undefined, {
error: error,
});
return Result.err('unknown');
}
}
private async checkApiHealth(
baseUrl: string,
errorReporter: EnhancedErrorReporter,
logger: ConsoleLogger
): Promise<HealthDependencyStatus> {
const startTime = Date.now();
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
const response = await fetch(`${baseUrl}/health`, {
signal: controller.signal,
});
clearTimeout(timeoutId);
const latency = Date.now() - startTime;
if (response && response.ok) {
return {
status: 'healthy',
latency,
};
}
if (response && response.status >= 500) {
if (attempt < this.maxRetries) {
await this.delay(this.retryDelay * attempt);
continue;
}
return {
status: 'unhealthy',
latency,
error: `Server error: ${response.status}`,
};
}
return {
status: 'degraded',
latency,
error: response ? `Client error: ${response.status}` : 'No response received',
};
} catch (error) {
const latency = Date.now() - startTime;
if (attempt < this.maxRetries && this.isRetryableError(error)) {
await this.delay(this.retryDelay * attempt);
continue;
}
return {
status: 'unhealthy',
latency,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
return {
status: 'unhealthy',
latency: Date.now() - startTime,
error: 'Max retries exceeded',
};
}
private async checkDatabaseHealth(
errorReporter: EnhancedErrorReporter,
logger: ConsoleLogger
): Promise<HealthDependencyStatus> {
const startTime = Date.now();
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
// Simulate database health check
// In a real implementation, this would query the database
await this.delay(50);
const latency = Date.now() - startTime;
// Simulate occasional database issues
if (Math.random() < 0.1 && attempt < this.maxRetries) {
throw new Error('Database connection timeout');
}
return {
status: 'healthy',
latency,
};
} catch (error) {
const latency = Date.now() - startTime;
if (attempt < this.maxRetries && this.isRetryableError(error)) {
await this.delay(this.retryDelay * attempt);
continue;
}
return {
status: 'unhealthy',
latency,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
return {
status: 'unhealthy',
latency: Date.now() - startTime,
error: 'Max retries exceeded',
};
}
private async checkExternalServiceHealth(
errorReporter: EnhancedErrorReporter,
logger: ConsoleLogger
): Promise<HealthDependencyStatus> {
const startTime = Date.now();
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
// Simulate external service health check
// In a real implementation, this would call an external API
await this.delay(100);
const latency = Date.now() - startTime;
// Simulate occasional external service issues
if (Math.random() < 0.05 && attempt < this.maxRetries) {
throw new Error('External service timeout');
}
return {
status: 'healthy',
latency,
};
} catch (error) {
const latency = Date.now() - startTime;
if (attempt < this.maxRetries && this.isRetryableError(error)) {
await this.delay(this.retryDelay * attempt);
continue;
}
return {
status: 'degraded',
latency,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
return {
status: 'degraded',
latency: Date.now() - startTime,
error: 'Max retries exceeded',
};
}
private aggregateHealthStatus(
api: HealthDependencyStatus,
database: HealthDependencyStatus,
externalService: HealthDependencyStatus
): HealthDependencyStatus {
// If any critical dependency is unhealthy, overall status is unhealthy
if (api.status === 'unhealthy' || database.status === 'unhealthy') {
return {
status: 'unhealthy',
latency: Math.max(api.latency || 0, database.latency || 0, externalService.latency || 0),
error: 'Critical dependency failure',
};
}
// If external service is degraded, overall status is degraded
if (externalService.status === 'degraded') {
return {
status: 'degraded',
latency: Math.max(api.latency || 0, database.latency || 0, externalService.latency || 0),
error: 'External service degraded',
};
}
// If all dependencies are healthy, overall status is healthy
return {
status: 'healthy',
latency: Math.max(api.latency || 0, database.latency || 0, externalService.latency || 0),
};
}
private makeHealthDecision(aggregatedStatus: HealthDependencyStatus): HealthStatus['status'] {
// Decision branches based on aggregated status
if (aggregatedStatus.status === 'unhealthy') {
return 'unhealthy';
}
if (aggregatedStatus.status === 'degraded') {
return 'degraded';
}
// Check latency thresholds
if (aggregatedStatus.latency && aggregatedStatus.latency > 1000) {
return 'degraded';
}
return 'healthy';
}
private isRetryableError(error: unknown): boolean {
if (error instanceof Error) {
const message = error.message.toLowerCase();
return (
message.includes('timeout') ||
message.includes('network') ||
message.includes('connection') ||
message.includes('unavailable')
);
}
return false;
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@@ -0,0 +1,65 @@
/**
* Health View Data Types
*
* Defines the UI model for health monitoring data.
* This layer isolates the UI from API churn by providing a stable interface
* between the API layer and the presentation layer.
*/
export interface HealthStatus {
status: 'ok' | 'degraded' | 'error' | 'unknown';
timestamp: string;
formattedTimestamp: string;
relativeTime: string;
statusLabel: string;
statusColor: string;
statusIcon: string;
}
export interface HealthMetrics {
uptime: string;
responseTime: string;
errorRate: string;
lastCheck: string;
formattedLastCheck: string;
checksPassed: number;
checksFailed: number;
totalChecks: number;
successRate: string;
}
export interface HealthComponent {
name: string;
status: 'ok' | 'degraded' | 'error' | 'unknown';
statusLabel: string;
statusColor: string;
statusIcon: string;
lastCheck: string;
formattedLastCheck: string;
responseTime: string;
errorRate: string;
}
export interface HealthAlert {
id: string;
type: 'critical' | 'warning' | 'info';
title: string;
message: string;
timestamp: string;
formattedTimestamp: string;
relativeTime: string;
severity: string;
severityColor: string;
}
export interface HealthViewData {
overallStatus: HealthStatus;
metrics: HealthMetrics;
components: HealthComponent[];
alerts: HealthAlert[];
hasAlerts: boolean;
hasDegradedComponents: boolean;
hasErrorComponents: boolean;
lastUpdated: string;
formattedLastUpdated: string;
}