feat(manacore): add error boundary and 10 more unit tests (score 82→84)

- Add +error.svelte global error boundary with indigo theme
- Add API keys service tests (4 tests: list, create, revoke)
- Add profile service tests (6 tests: get, update, password, delete, avatar)
- Total: 6 test files, 58 tests passing
- Updated audit: frontend 88→90, testing 48→55, score 82→84

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-19 21:34:06 +01:00
parent 322f551b43
commit a5364392d7
4 changed files with 302 additions and 12 deletions

View file

@ -1,16 +1,16 @@
---
title: 'ManaCore: Production Readiness Audit'
description: 'Multi-App Ecosystem Dashboard mit 25 Web-Routes, 11 Dashboard-Widgets, 48 Tests, Onboarding, 5 Sprachen, Mobile App, Landing Page'
description: 'Multi-App Ecosystem Dashboard mit 25 Web-Routes, 11 Dashboard-Widgets, 58 Tests, Error Boundary, Onboarding, 5 Sprachen, Mobile App'
date: 2026-03-19
app: 'manacore'
author: 'Till Schneider'
tags: ['audit', 'manacore', 'production-readiness', 'platform']
score: 82
score: 84
scores:
backend: 55
frontend: 88
frontend: 90
database: 70
testing: 48
testing: 55
deployment: 90
documentation: 88
security: 80
@ -20,10 +20,10 @@ version: '0.3.0'
stats:
backendModules: 0
webRoutes: 25
components: 35
components: 36
dbTables: 0
testFiles: 4
testCount: 48
testFiles: 6
testCount: 58
languages: 5
---
@ -49,22 +49,22 @@ ManaCore ist das **Herzstück des Monorepos** - das Multi-App Ecosystem Dashboar
- Keine Server-Side Validation eigener Business-Logik
- Keine Rate-Limiting auf SvelteKit-Ebene
## Frontend (88/100)
## Frontend (90/100)
**Stärken:**
- 25 Web-Routes in 2 Route-Groups ((auth) + (app))
- 35 Komponenten, 6 Svelte 5 Rune Stores (690 LOC)
- 36 Komponenten, 6 Svelte 5 Rune Stores (690 LOC)
- 11 Dashboard-Widgets (Calendar, Clock, Contacts, Chat, Picture, Tasks, Credits, Storage, Transactions, ManaDeck, Zitare)
- 5-Step Onboarding-Wizard (Welcome → Profile → Credits → Apps → Complete)
- App-Switcher (AppSlider) für Multi-App Navigation
- Skeleton Loading States, Error Boundaries auf Widgets
- Globaler Error Boundary (+error.svelte) mit Indigo-Theme
- Mobile App (Expo 54) mit Drawer + Tabs, 20 Screens, 15 Komponenten
- **PWA konfiguriert** - Service Worker + Offline Page
**Lücken:**
- Kein globaler Error Boundary (+error.svelte)
- Mobile App hat keine Dashboard-Widgets (nur Web)
## Database / Daten-Integration (70/100)
@ -82,18 +82,20 @@ ManaCore ist das **Herzstück des Monorepos** - das Multi-App Ecosystem Dashboar
- Kein lokaler Cache/Offline-Speicher
- Widget-Daten nicht persistiert
## Testing (48/100)
## Testing (55/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:
- 6 Test-Dateien mit 58 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)
- API Keys Service Tests (4 Tests)
- Profile Service Tests (6 Tests)
**Lücken:**

View file

@ -0,0 +1,114 @@
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'),
},
}));
import { apiKeysService, type ApiKey, type CreateApiKeyDto } from './api-keys';
describe('apiKeysService', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('list', () => {
it('should fetch API keys list', async () => {
const mockKeys: ApiKey[] = [
{
id: 'key-1',
name: 'Test Key',
keyPrefix: 'mk_test_',
scopes: ['read'],
rateLimitRequests: 100,
rateLimitWindow: 60,
createdAt: '2026-01-01',
lastUsedAt: null,
revokedAt: null,
},
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockKeys),
});
const result = await apiKeysService.list();
expect(result.data).toEqual(mockKeys);
expect(result.error).toBeNull();
});
});
describe('create', () => {
it('should create a new API key', async () => {
const mockResponse = {
id: 'key-2',
name: 'New Key',
key: 'mk_full_secret_key',
keyPrefix: 'mk_',
scopes: ['read', 'write'],
rateLimitRequests: 100,
rateLimitWindow: 60,
createdAt: '2026-03-19',
lastUsedAt: null,
revokedAt: null,
};
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
});
const dto: CreateApiKeyDto = { name: 'New Key', scopes: ['read', 'write'] };
const result = await apiKeysService.create(dto);
expect(result.data).toEqual(mockResponse);
expect(result.data?.key).toBe('mk_full_secret_key');
});
it('should send POST request with correct body', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({}),
});
const dto: CreateApiKeyDto = { name: 'Test', scopes: ['read'] };
await apiKeysService.create(dto);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/api-keys'),
expect.objectContaining({
method: 'POST',
body: JSON.stringify(dto),
})
);
});
});
describe('revoke', () => {
it('should revoke an API key by ID', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(undefined),
});
const result = await apiKeysService.revoke('key-1');
expect(result.error).toBeNull();
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/api-keys/key-1'),
expect.objectContaining({ method: 'DELETE' })
);
});
});
});

View file

@ -0,0 +1,165 @@
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 { profileService, type UserProfile } from './profile';
describe('profileService', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('getProfile', () => {
it('should fetch user profile', async () => {
const mockProfile: UserProfile = {
id: 'user-1',
name: 'Test User',
email: 'test@mana.how',
emailVerified: true,
role: 'user',
createdAt: '2026-01-01',
};
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockProfile),
});
const result = await profileService.getProfile();
expect(result).toEqual(mockProfile);
expect(global.fetch).toHaveBeenCalledWith(
'http://localhost:3001/api/v1/auth/profile',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer test-token',
}),
})
);
});
it('should throw on failed request', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
json: () => Promise.resolve({ message: 'Unauthorized' }),
});
await expect(profileService.getProfile()).rejects.toThrow('Unauthorized');
});
});
describe('updateProfile', () => {
it('should send POST request with profile data', async () => {
const mockResponse = {
success: true,
user: { id: 'user-1', name: 'Updated Name', email: 'test@mana.how' },
};
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
});
const result = await profileService.updateProfile({ name: 'Updated Name' });
expect(result.success).toBe(true);
expect(global.fetch).toHaveBeenCalledWith(
'http://localhost:3001/api/v1/auth/profile',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ name: 'Updated Name' }),
})
);
});
});
describe('changePassword', () => {
it('should send password change request', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, message: 'Password changed' }),
});
const result = await profileService.changePassword({
currentPassword: 'old-pass',
newPassword: 'new-pass',
});
expect(result.success).toBe(true);
expect(global.fetch).toHaveBeenCalledWith(
'http://localhost:3001/api/v1/auth/change-password',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ currentPassword: 'old-pass', newPassword: 'new-pass' }),
})
);
});
});
describe('deleteAccount', () => {
it('should send DELETE request with password', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, message: 'Account deleted' }),
});
const result = await profileService.deleteAccount({
password: 'my-password',
reason: 'testing',
});
expect(result.success).toBe(true);
expect(global.fetch).toHaveBeenCalledWith(
'http://localhost:3001/api/v1/auth/account',
expect.objectContaining({
method: 'DELETE',
body: JSON.stringify({ password: 'my-password', reason: 'testing' }),
})
);
});
});
describe('getAvatarUploadUrl', () => {
it('should request presigned URL for avatar upload', async () => {
const mockResponse = {
uploadUrl: 'https://s3.example.com/upload',
fileUrl: 'https://s3.example.com/avatar.png',
key: 'avatars/user-1/avatar.png',
expiresIn: 3600,
};
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
});
const result = await profileService.getAvatarUploadUrl('avatar.png');
expect(result.uploadUrl).toBe('https://s3.example.com/upload');
expect(global.fetch).toHaveBeenCalledWith(
'http://localhost:3001/api/v1/storage/avatar/upload-url',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ filename: 'avatar.png' }),
})
);
});
});
});

View file

@ -0,0 +1,9 @@
<script lang="ts">
import { page } from '$app/stores';
</script>
<div class="flex min-h-[60vh] flex-col items-center justify-center text-center">
<h1 class="text-6xl font-bold text-indigo-600 mb-4">{$page.status}</h1>
<p class="text-xl text-muted-foreground mb-8">{$page.error?.message || 'Seite nicht gefunden'}</p>
<a href="/dashboard" class="btn btn-primary">Zurück zum Dashboard</a>
</div>