From 135b65bcd61c8ce8a0ce590e06fc64009a58f26a Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 19 Mar 2026 21:26:51 +0100 Subject: [PATCH] test(manacore): add 48 unit tests for dashboard, API client, and credits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../src/content/audits/2026-03-19-manacore.md | 26 +- .../apps/web/src/lib/api/base-client.test.ts | 222 ++++++++++++++++++ .../apps/web/src/lib/api/credits.test.ts | 137 +++++++++++ .../src/lib/config/default-dashboard.test.ts | 75 ++++++ .../apps/web/src/lib/types/dashboard.test.ts | 130 ++++++++++ 5 files changed, 579 insertions(+), 11 deletions(-) create mode 100644 apps/manacore/apps/web/src/lib/api/base-client.test.ts create mode 100644 apps/manacore/apps/web/src/lib/api/credits.test.ts create mode 100644 apps/manacore/apps/web/src/lib/config/default-dashboard.test.ts create mode 100644 apps/manacore/apps/web/src/lib/types/dashboard.test.ts diff --git a/apps/manacore/apps/landing/src/content/audits/2026-03-19-manacore.md b/apps/manacore/apps/landing/src/content/audits/2026-03-19-manacore.md index e877c277c..b36f850b0 100644 --- a/apps/manacore/apps/landing/src/content/audits/2026-03-19-manacore.md +++ b/apps/manacore/apps/landing/src/content/audits/2026-03-19-manacore.md @@ -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 diff --git a/apps/manacore/apps/web/src/lib/api/base-client.test.ts b/apps/manacore/apps/web/src/lib/api/base-client.test.ts new file mode 100644 index 000000000..a46590c71 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/api/base-client.test.ts @@ -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('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' }) + ); + }); +}); diff --git a/apps/manacore/apps/web/src/lib/api/credits.test.ts b/apps/manacore/apps/web/src/lib/api/credits.test.ts new file mode 100644 index 000000000..226a099ad --- /dev/null +++ b/apps/manacore/apps/web/src/lib/api/credits.test.ts @@ -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' }), + }) + ); + }); + }); +}); diff --git a/apps/manacore/apps/web/src/lib/config/default-dashboard.test.ts b/apps/manacore/apps/web/src/lib/config/default-dashboard.test.ts new file mode 100644 index 000000000..2e7091e3e --- /dev/null +++ b/apps/manacore/apps/web/src/lib/config/default-dashboard.test.ts @@ -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'); + }); +}); diff --git a/apps/manacore/apps/web/src/lib/types/dashboard.test.ts b/apps/manacore/apps/web/src/lib/types/dashboard.test.ts new file mode 100644 index 000000000..277070544 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/types/dashboard.test.ts @@ -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'); + }); +});