test(manacore): add 48 unit tests for dashboard, API client, and credits

- Dashboard widget registry: 14 tests (types, metadata, size classes)
- Default dashboard config: 12 tests (layout, validation, i18n keys)
- Base API client: 15 tests (retry logic, auth headers, error handling)
- Credits service: 7 tests (balance, transactions, packages, usage)
- Updated audit score from 80 to 82 (testing: 12 → 48)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-19 21:26:51 +01:00
parent 32fba2b7b7
commit 135b65bcd6
5 changed files with 579 additions and 11 deletions

View file

@ -1,16 +1,16 @@
---
title: 'ManaCore: Production Readiness Audit'
description: 'Multi-App Ecosystem Dashboard mit 25 Web-Routes, 11 Dashboard-Widgets, Onboarding, 5 Sprachen, Mobile App, Landing Page'
description: 'Multi-App Ecosystem Dashboard mit 25 Web-Routes, 11 Dashboard-Widgets, 48 Tests, Onboarding, 5 Sprachen, Mobile App, Landing Page'
date: 2026-03-19
app: 'manacore'
author: 'Till Schneider'
tags: ['audit', 'manacore', 'production-readiness', 'platform']
score: 80
score: 82
scores:
backend: 55
frontend: 88
database: 70
testing: 12
testing: 48
deployment: 90
documentation: 88
security: 80
@ -22,8 +22,8 @@ stats:
webRoutes: 25
components: 35
dbTables: 0
testFiles: 0
testCount: 0
testFiles: 4
testCount: 48
languages: 5
---
@ -82,20 +82,24 @@ ManaCore ist das **Herzstück des Monorepos** - das Multi-App Ecosystem Dashboar
- Kein lokaler Cache/Offline-Speicher
- Widget-Daten nicht persistiert
## Testing (12/100)
## Testing (48/100)
**Stärken:**
- Vitest konfiguriert mit Coverage (package.json)
- Playwright für E2E Tests eingerichtet
- @vitest/coverage-v8 und @vitest/ui installiert
- 4 Test-Dateien mit 48 Unit Tests:
- Dashboard Widget Registry Tests (14 Tests)
- Default Dashboard Config Tests (12 Tests)
- Base API Client mit Retry-Logik (15 Tests)
- Credits Service API Tests (7 Tests)
**Lücken:**
- **0 Unit Tests**
- **0 E2E Tests**
- **0 Integration Tests**
- Testing-Infrastruktur vorhanden, aber nicht genutzt
- Keine E2E Tests (Playwright Setup vorhanden)
- Keine Store-Tests (Auth, Dashboard State)
- Keine Komponenten-Tests
## Deployment (90/100)
@ -167,6 +171,6 @@ ManaCore ist das **Herzstück des Monorepos** - das Multi-App Ecosystem Dashboar
## Top-3 Empfehlungen
1. **Tests schreiben** - Auth Store Unit Tests, Dashboard Widget Tests, E2E für Login/Onboarding Flow → Testing von 12 auf 50+
1. **E2E Tests** - Playwright Tests für Login/Onboarding/Dashboard Flow → Testing von 48 auf 70+
2. **Error Tracking** - GlitchTip Integration (wie andere Backends) für Production Monitoring
3. **PWA verifizieren** - Offline-Modus testen, Service Worker prüfen, Manifest validieren

View file

@ -0,0 +1,222 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock $app/environment before importing module
vi.mock('$app/environment', () => ({
browser: true,
}));
// Mock auth store
vi.mock('$lib/stores/auth.svelte', () => ({
authStore: {
getAccessToken: vi.fn().mockResolvedValue('mock-token-123'),
},
}));
import { fetchWithRetry, createApiClient, type RetryConfig, type ApiResult } from './base-client';
describe('fetchWithRetry', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should return data on successful request', async () => {
const mockData = { id: 1, name: 'test' };
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockData),
});
const result = await fetchWithRetry<typeof mockData>('https://api.example.com/data');
expect(result.data).toEqual(mockData);
expect(result.error).toBeNull();
});
it('should include Authorization header with token', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({}),
});
await fetchWithRetry('https://api.example.com/data');
expect(global.fetch).toHaveBeenCalledWith(
'https://api.example.com/data',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer mock-token-123',
'Content-Type': 'application/json',
}),
})
);
});
it('should return error on 401 without retrying', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
});
const result = await fetchWithRetry('https://api.example.com/data');
expect(result.data).toBeNull();
expect(result.error).toContain('Authentication failed');
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it('should return error on 403 without retrying', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 403,
});
const result = await fetchWithRetry('https://api.example.com/data');
expect(result.data).toBeNull();
expect(result.error).toContain('Authentication failed');
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it('should return error on 4xx client errors without retrying', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 404,
json: () => Promise.resolve({ message: 'Not found' }),
});
const result = await fetchWithRetry('https://api.example.com/data');
expect(result.data).toBeNull();
expect(result.error).toBe('Not found');
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it('should return error on network failure without retrying', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('Failed to fetch'));
const result = await fetchWithRetry('https://api.example.com/data');
expect(result.data).toBeNull();
expect(result.error).toBe('Service nicht erreichbar');
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it('should return error on connection refused', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('ERR_CONNECTION_REFUSED'));
const result = await fetchWithRetry('https://api.example.com/data');
expect(result.data).toBeNull();
expect(result.error).toBe('Service nicht erreichbar');
});
it('should retry on 5xx server errors', async () => {
global.fetch = vi
.fn()
.mockResolvedValueOnce({ ok: false, status: 500 })
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true }),
});
const result = await fetchWithRetry('https://api.example.com/data', {}, { retryDelay: 1 });
expect(result.data).toEqual({ success: true });
expect(global.fetch).toHaveBeenCalledTimes(2);
});
it('should respect maxRetries config', async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
const result = await fetchWithRetry(
'https://api.example.com/data',
{},
{ maxRetries: 1, retryDelay: 1 }
);
expect(result.data).toBeNull();
expect(result.error).toContain('HTTP 500');
// 1 initial + 1 retry = 2
expect(global.fetch).toHaveBeenCalledTimes(2);
});
});
describe('createApiClient', () => {
beforeEach(() => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ result: 'ok' }),
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should create a client with get, post, put, delete methods', () => {
const client = createApiClient('https://api.example.com');
expect(typeof client.get).toBe('function');
expect(typeof client.post).toBe('function');
expect(typeof client.put).toBe('function');
expect(typeof client.delete).toBe('function');
});
it('should prepend base URL to endpoints', async () => {
const client = createApiClient('https://api.example.com');
await client.get('/users');
expect(global.fetch).toHaveBeenCalledWith('https://api.example.com/users', expect.any(Object));
});
it('should send GET requests correctly', async () => {
const client = createApiClient('https://api.example.com');
const result = await client.get('/data');
expect(result.data).toEqual({ result: 'ok' });
expect(global.fetch).toHaveBeenCalledWith(
'https://api.example.com/data',
expect.objectContaining({ method: 'GET' })
);
});
it('should send POST requests with body', async () => {
const client = createApiClient('https://api.example.com');
await client.post('/data', { name: 'test' });
expect(global.fetch).toHaveBeenCalledWith(
'https://api.example.com/data',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ name: 'test' }),
})
);
});
it('should send PUT requests with body', async () => {
const client = createApiClient('https://api.example.com');
await client.put('/data/1', { name: 'updated' });
expect(global.fetch).toHaveBeenCalledWith(
'https://api.example.com/data/1',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ name: 'updated' }),
})
);
});
it('should send DELETE requests', async () => {
const client = createApiClient('https://api.example.com');
await client.delete('/data/1');
expect(global.fetch).toHaveBeenCalledWith(
'https://api.example.com/data/1',
expect.objectContaining({ method: 'DELETE' })
);
});
});

View file

@ -0,0 +1,137 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock $app/environment
vi.mock('$app/environment', () => ({
browser: true,
}));
// Mock auth store
vi.mock('$lib/stores/auth.svelte', () => ({
authStore: {
getAccessToken: vi.fn().mockResolvedValue('test-token'),
},
}));
// Mock config
vi.mock('./config', () => ({
getManaAuthUrl: vi.fn().mockReturnValue('http://localhost:3001'),
}));
import { creditsService } from './credits';
describe('creditsService', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('getBalance', () => {
it('should fetch credit balance from correct endpoint', async () => {
const mockBalance = { balance: 100, totalEarned: 500, totalSpent: 400 };
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockBalance),
});
const result = await creditsService.getBalance();
expect(result).toEqual(mockBalance);
expect(global.fetch).toHaveBeenCalledWith(
'http://localhost:3001/api/v1/credits/balance',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer test-token',
}),
})
);
});
it('should throw on failed request', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ message: 'Server error' }),
});
await expect(creditsService.getBalance()).rejects.toThrow('Server error');
});
});
describe('getTransactions', () => {
it('should fetch transactions with default pagination', async () => {
const mockTransactions = [{ id: '1', type: 'purchase', amount: 100 }];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTransactions),
});
const result = await creditsService.getTransactions();
expect(result).toEqual(mockTransactions);
expect(global.fetch).toHaveBeenCalledWith(
'http://localhost:3001/api/v1/credits/transactions?limit=50&offset=0',
expect.any(Object)
);
});
it('should pass custom limit and offset', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
});
await creditsService.getTransactions(10, 20);
expect(global.fetch).toHaveBeenCalledWith(
'http://localhost:3001/api/v1/credits/transactions?limit=10&offset=20',
expect.any(Object)
);
});
});
describe('getPackages', () => {
it('should fetch packages without auth (public endpoint)', async () => {
const mockPackages = [{ id: 'pkg-1', name: 'Starter', credits: 100 }];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockPackages),
});
const result = await creditsService.getPackages();
expect(result).toEqual(mockPackages);
// Should NOT have Authorization header (public endpoint)
expect(global.fetch).toHaveBeenCalledWith('http://localhost:3001/api/v1/credits/packages');
});
it('should throw on failed request', async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
await expect(creditsService.getPackages()).rejects.toThrow('Failed to fetch packages');
});
});
describe('useCredits', () => {
it('should send credit usage request', async () => {
const mockResponse = { success: true, newBalance: { balance: 90 } };
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
});
const result = await creditsService.useCredits(10, 'chat', 'AI generation');
expect(result).toEqual(mockResponse);
expect(global.fetch).toHaveBeenCalledWith(
'http://localhost:3001/api/v1/credits/use',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ amount: 10, appId: 'chat', description: 'AI generation' }),
})
);
});
});
});

View file

@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest';
import { DEFAULT_DASHBOARD_CONFIG, DASHBOARD_STORAGE_KEY } from './default-dashboard';
import { getWidgetMeta, type WidgetSize } from '$lib/types/dashboard';
describe('DEFAULT_DASHBOARD_CONFIG', () => {
it('should have a 12-column grid', () => {
expect(DEFAULT_DASHBOARD_CONFIG.gridColumns).toBe(12);
});
it('should have 3 default widgets', () => {
expect(DEFAULT_DASHBOARD_CONFIG.widgets).toHaveLength(3);
});
it('should have all widgets visible by default', () => {
for (const widget of DEFAULT_DASHBOARD_CONFIG.widgets) {
expect(widget.visible).toBe(true);
}
});
it('should include clock, tasks-today, and calendar widgets', () => {
const types = DEFAULT_DASHBOARD_CONFIG.widgets.map((w) => w.type);
expect(types).toContain('clock-timers');
expect(types).toContain('tasks-today');
expect(types).toContain('calendar-events');
});
it('should have unique widget IDs', () => {
const ids = DEFAULT_DASHBOARD_CONFIG.widgets.map((w) => w.id);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(ids.length);
});
it('should have valid widget types that exist in registry', () => {
for (const widget of DEFAULT_DASHBOARD_CONFIG.widgets) {
const meta = getWidgetMeta(widget.type);
expect(meta).toBeDefined();
}
});
it('should have valid sizes', () => {
const validSizes: WidgetSize[] = ['small', 'medium', 'large', 'full'];
for (const widget of DEFAULT_DASHBOARD_CONFIG.widgets) {
expect(validSizes).toContain(widget.size);
}
});
it('should have valid grid positions', () => {
for (const widget of DEFAULT_DASHBOARD_CONFIG.widgets) {
expect(widget.position.x).toBeGreaterThanOrEqual(0);
expect(widget.position.x).toBeLessThan(12);
expect(widget.position.y).toBeGreaterThanOrEqual(0);
}
});
it('should have i18n title keys', () => {
for (const widget of DEFAULT_DASHBOARD_CONFIG.widgets) {
expect(widget.title).toMatch(/^dashboard\.widgets\..+\.title$/);
}
});
it('should have a lastModified timestamp', () => {
expect(DEFAULT_DASHBOARD_CONFIG.lastModified).toBeTruthy();
});
});
describe('DASHBOARD_STORAGE_KEY', () => {
it('should be a non-empty string', () => {
expect(DASHBOARD_STORAGE_KEY).toBeTruthy();
expect(typeof DASHBOARD_STORAGE_KEY).toBe('string');
});
it('should contain manacore identifier', () => {
expect(DASHBOARD_STORAGE_KEY).toContain('manacore');
});
});

View file

@ -0,0 +1,130 @@
import { describe, it, expect } from 'vitest';
import {
getWidgetMeta,
WIDGET_REGISTRY,
WIDGET_SIZE_CLASSES,
type WidgetType,
type WidgetSize,
} from './dashboard';
describe('WIDGET_REGISTRY', () => {
it('should contain 13 widget definitions', () => {
expect(WIDGET_REGISTRY).toHaveLength(13);
});
it('should have unique types for all widgets', () => {
const types = WIDGET_REGISTRY.map((w) => w.type);
const uniqueTypes = new Set(types);
expect(uniqueTypes.size).toBe(types.length);
});
it('should have required fields for every widget', () => {
for (const widget of WIDGET_REGISTRY) {
expect(widget.type).toBeTruthy();
expect(widget.nameKey).toBeTruthy();
expect(widget.descriptionKey).toBeTruthy();
expect(widget.icon).toBeTruthy();
expect(widget.defaultSize).toBeTruthy();
expect(typeof widget.allowMultiple).toBe('boolean');
}
});
it('should have valid default sizes', () => {
const validSizes: WidgetSize[] = ['small', 'medium', 'large', 'full'];
for (const widget of WIDGET_REGISTRY) {
expect(validSizes).toContain(widget.defaultSize);
}
});
it('should have valid required backends', () => {
const validBackends = [
'todo',
'calendar',
'chat',
'contacts',
'zitare',
'picture',
'manadeck',
'clock',
'storage',
'mana-core-auth',
undefined,
];
for (const widget of WIDGET_REGISTRY) {
expect(validBackends).toContain(widget.requiredBackend);
}
});
it('should include all expected widget types', () => {
const types = WIDGET_REGISTRY.map((w) => w.type);
expect(types).toContain('credits');
expect(types).toContain('quick-actions');
expect(types).toContain('transactions');
expect(types).toContain('tasks-today');
expect(types).toContain('tasks-upcoming');
expect(types).toContain('calendar-events');
expect(types).toContain('chat-recent');
expect(types).toContain('contacts-favorites');
expect(types).toContain('zitare-quote');
expect(types).toContain('picture-recent');
expect(types).toContain('manadeck-progress');
expect(types).toContain('clock-timers');
expect(types).toContain('storage-usage');
});
it('should have i18n-style name keys', () => {
for (const widget of WIDGET_REGISTRY) {
expect(widget.nameKey).toMatch(/^dashboard\.widgets\..+\.title$/);
expect(widget.descriptionKey).toMatch(/^dashboard\.widgets\..+\.description$/);
}
});
});
describe('getWidgetMeta', () => {
it('should return metadata for a valid widget type', () => {
const meta = getWidgetMeta('credits');
expect(meta).toBeDefined();
expect(meta!.type).toBe('credits');
expect(meta!.requiredBackend).toBe('mana-core-auth');
});
it('should return undefined for an invalid widget type', () => {
const meta = getWidgetMeta('nonexistent' as WidgetType);
expect(meta).toBeUndefined();
});
it('should return correct metadata for clock-timers', () => {
const meta = getWidgetMeta('clock-timers');
expect(meta).toBeDefined();
expect(meta!.defaultSize).toBe('small');
expect(meta!.requiredBackend).toBe('clock');
expect(meta!.allowMultiple).toBe(false);
});
it('should return correct metadata for each widget type', () => {
for (const widget of WIDGET_REGISTRY) {
const meta = getWidgetMeta(widget.type);
expect(meta).toBeDefined();
expect(meta).toEqual(widget);
}
});
});
describe('WIDGET_SIZE_CLASSES', () => {
it('should have all four size classes', () => {
expect(WIDGET_SIZE_CLASSES).toHaveProperty('small');
expect(WIDGET_SIZE_CLASSES).toHaveProperty('medium');
expect(WIDGET_SIZE_CLASSES).toHaveProperty('large');
expect(WIDGET_SIZE_CLASSES).toHaveProperty('full');
});
it('should contain col-span-12 in all sizes for mobile', () => {
for (const [, className] of Object.entries(WIDGET_SIZE_CLASSES)) {
expect(className).toContain('col-span-12');
}
});
it('full size should only use col-span-12', () => {
expect(WIDGET_SIZE_CLASSES.full).toBe('col-span-12');
});
});