diff --git a/.cursor/rules/project-structure.mdc b/.cursor/rules/project-structure.mdc index 1dcb2b0..e87880b 100644 --- a/.cursor/rules/project-structure.mdc +++ b/.cursor/rules/project-structure.mdc @@ -21,6 +21,13 @@ strive/ │ │ ├── app.component.spec.ts # Root tests │ │ ├── app.config.ts # App configuration & providers │ │ ├── app.routes.ts # Main routing +│ │ ├── interceptors/ # HTTP Interceptors +│ │ │ ├── index.ts # Public API exports +│ │ │ ├── auth.interceptor.ts # Authentication interceptor +│ │ │ ├── auth.interceptor.spec.ts # Auth interceptor tests +│ │ │ ├── token-refresh-manager.ts # Token refresh manager +│ │ │ ├── token-refresh-manager.spec.ts # Token manager tests +│ │ │ └── credentials.interceptor.ts # Credentials interceptor │ │ └── layout/ # Layout components │ │ │ ├── pages/ # Pages layer (FSD) @@ -190,7 +197,13 @@ strive/ │ ├── docs/ # Documentation │ ├── DEPLOYMENT.md # Deployment guide -│ └── PWA-ICONS.md # PWA icons management guide +│ ├── PWA-ICONS.md # PWA icons management guide +│ └── plans/ # Development plans +│ ├── 005-feature-food-diary.md # Food diary feature plan +│ ├── 006-project-improvements-analysis.md # Project improvements analysis +│ ├── 007-auth-service-improvements.md # Auth service improvements plan +│ ├── 011-auth-ux-improvements.md # Auth UX improvements plan +│ └── 012-test-coverage-improvements.md # Test coverage improvements plan │ ├── scripts/ # Build and utility scripts │ └── generate-pwa-icons.cjs # PWA icon generation script diff --git a/.cursor/rules/testing.mdc b/.cursor/rules/testing.mdc index 6de9e37..fc347ec 100644 --- a/.cursor/rules/testing.mdc +++ b/.cursor/rules/testing.mdc @@ -249,6 +249,64 @@ const mockServiceResponse: ServiceResponse = { }; ``` +### Repeated Mock Objects Rule +**CRITICAL**: If the same mock object is used multiple times in tests, extract it to the main `describe` block and initialize in `beforeEach`. + +#### ✅ Correct Pattern +```typescript +describe('ServiceName', () => { + let service: ServiceName; + let mockUser: User; // Extract repeated mock to describe level + + beforeEach(() => { + // Initialize mock in beforeEach + mockUser = { id: '1', email: 'test@example.com', theme: 'light' }; + + configureZonelessTestingModule({ + providers: [ServiceName], + }); + + service = TestBed.inject(ServiceName); + }); + + it('should work with user data', () => { + // Use mockUser directly - no const declaration needed + service.setUser(mockUser); + expect(service.getUser()).toEqual(mockUser); + }); + + it('should handle user updates', () => { + // Use same mockUser + service.updateUser(mockUser); + expect(service.user()).toEqual(mockUser); + }); +}); +``` + +#### ❌ Wrong Patterns +```typescript +// ❌ Wrong - repeating mock object in each test +it('should work with user data', () => { + const user: User = { id: '1', email: 'test@example.com', theme: 'light' }; + service.setUser(user); +}); + +it('should handle user updates', () => { + const user: User = { id: '1', email: 'test@example.com', theme: 'light' }; // Duplication! + service.updateUser(user); +}); + +// ❌ Wrong - creating utility function for simple mocks +const createMockUser = () => ({ id: '1', email: 'test@example.com', theme: 'light' }); +``` + +#### Benefits of This Pattern +- **DRY Principle**: No code duplication +- **Consistency**: Same mock data across all tests +- **Maintainability**: Change mock in one place +- **Readability**: Clear separation of setup and test logic +- **Performance**: No repeated object creation + ### Spy Object Creation ```typescript const serviceSpy = jasmine.createSpyObj('ServiceName', [ diff --git a/docs/plans/007-auth-service-improvements.md b/docs/plans/007-auth-service-improvements.md new file mode 100644 index 0000000..7705c74 --- /dev/null +++ b/docs/plans/007-auth-service-improvements.md @@ -0,0 +1,247 @@ +# План улучшения AuthService + +## 📊 Анализ текущего состояния + +### 🔍 **Текущее использование localStorage/sessionStorage:** + +#### localStorage (безопасно оставить): +- **`theme`** - выбранная пользователем тема (light/dark) +- **`calorie_calculation`** - данные расчета калорий (отправляются на backend) +- **`form_autosave_*`** - автосохранение форм (не чувствительные данные) + +#### sessionStorage (безопасно оставить): +- **`return_url`** - URL для редиректа после логина + +#### ❌ **Проблема**: Токены аутентификации НЕ хранятся в localStorage (хорошо!), но хранятся только в памяти + +### 🎯 **Обновленные приоритеты:** +1. **UserStore** - централизованное состояние пользователя +2. **Error Handling** - улучшенная обработка ошибок +3. **UX Improvements** - loading states, remember me +4. **Architecture** - модульная структура + +### ✅ Сильные стороны: +- Современный Angular подход (signals, standalone компоненты) +- Правильная архитектура с разделением ответственности +- Централизованная обработка ошибок +- JWT валидация с проверкой срока действия +- Автоматический refresh с предотвращением race conditions +- Интеграция с AuthGuard + +### ❌ Проблемы и области для улучшения: + +#### 1. Безопасность +- **✅ Хорошо**: HttpOnly cookies уже реализованы +- **✅ Хорошо**: Данные калорий отправляются на backend, localStorage для них безопасен +- **✅ Хорошо**: Токены не хранятся в localStorage + +#### 2. Управление состоянием +- Нет централизованного состояния пользователя +- Отсутствует информация о пользователе (имя, email, роли) +- Нет персистентности состояния между сессиями +- Отсутствует кэширование пользовательских данных + +#### 3. UX проблемы +- Нет индикации процесса аутентификации +- Отсутствует "запомнить меня" функциональность +- Нет обработки сетевых ошибок с retry +- Отсутствуют toast уведомления + +#### 4. Архитектурные недостатки +- Смешивание логики в одном сервисе +- Отсутствует абстракция для разных типов аутентификации +- Нет поддержки refresh token rotation +- Сложно тестировать из-за тесной связанности + +## 🎯 План улучшений (итеративный подход) + +### **Итерация 1 (MVP) - Безопасность и состояние** ⏳ Ожидает + +#### 1.1 Централизованное состояние пользователя +- **Задача**: Создать UserStore с signals +- **Детали**: + - Добавить информацию о пользователе (имя, email, роли) + - Реализовать персистентность состояния + - Добавить computed signals для производных данных +- **Результат**: Единое место управления состоянием пользователя +- **Время**: 3-4 часа + +#### 1.2 Улучшенная обработка ошибок +- **Задача**: Добавить типизированные ошибки аутентификации +- **Детали**: + - Создать AuthError типы + - Реализовать retry механизм + - Добавить логирование ошибок +- **Результат**: Надежная обработка ошибок +- **Время**: 2-3 часа + +### **Итерация 2 - UX и функциональность** ⏳ Ожидает + +#### 2.1 "Запомнить меня" функциональность +- **Задача**: Добавить rememberMe опцию +- **Детали**: + - Реализовать долгосрочные токены + - Обновить UI для выбора опции + - Добавить настройки сессии +- **Результат**: Улучшенный UX для пользователей +- **Время**: 3-4 часа + +#### 2.2 Улучшенная навигация +- **Задача**: Добавить breadcrumbs для аутентификации +- **Детали**: + - Реализовать deep linking после login + - Добавить redirect после logout + - Улучшить AuthGuard логику +- **Результат**: Интуитивная навигация +- **Время**: 2-3 часа + +#### 2.3 Индикаторы состояния +- **Задача**: Добавить loading states для всех операций +- **Детали**: + - Реализовать progress indicators + - Добавить toast уведомления + - Создать skeleton loaders +- **Результат**: Понятная обратная связь для пользователя +- **Время**: 2-3 часа + +### **Итерация 3 - Архитектура и расширяемость** ⏳ Ожидает + +#### 3.1 Рефакторинг архитектуры +- **Задача**: Разделить AuthService на более мелкие сервисы +- **Детали**: + - Создать AuthStateService + - Добавить AuthConfigService + - Реализовать AuthTokenService +- **Результат**: Модульная и тестируемая архитектура +- **Время**: 4-5 часов + +#### 3.2 Поддержка разных типов аутентификации +- **Задача**: Добавить OAuth2 поддержку +- **Детали**: + - Реализовать 2FA + - Добавить social login + - Создать абстракцию для провайдеров +- **Результат**: Гибкая система аутентификации +- **Время**: 6-8 часов + +#### 3.3 Продвинутые функции безопасности +- **Задача**: Реализовать refresh token rotation +- **Детали**: + - Добавить device fingerprinting + - Реализовать session management + - Добавить audit logging +- **Результат**: Enterprise-level безопасность +- **Время**: 5-6 часов + +### **Итерация 4 - Тестирование и документация** ⏳ Ожидает + +#### 4.1 Комплексное тестирование +- **Задача**: Unit тесты для всех сервисов +- **Детали**: + - Integration тесты для auth flow + - E2E тесты для критических сценариев + - Performance тесты +- **Результат**: Надежная система с покрытием тестами +- **Время**: 4-5 часов + +#### 4.2 Документация и мониторинг +- **Задача**: API документация +- **Детали**: + - Security guidelines + - Performance monitoring + - User guides +- **Результат**: Полная документация системы +- **Время**: 2-3 часа + +## 📋 Детальный план первой итерации + +### Этап 1: UserStore (3-4 часа) + +#### 1.1 Создать UserStore +```typescript +// Создать UserStore с signals +// Добавить user information +// Реализовать persistence +``` + +#### 1.2 Обновить AuthService +```typescript +// Интегрировать с UserStore +// Обновить login/logout методы +// Добавить user data management +``` + +#### 1.3 Обновить компоненты +```typescript +// Обновить компоненты для использования UserStore +// Добавить user information display +// Обновить navigation logic +``` + +### Этап 2: Error Handling (2-3 часа) + +#### 2.1 Создать AuthError типы +```typescript +// Создать типизированные ошибки +// Добавить error codes +// Реализовать error mapping +``` + +#### 2.2 Обновить error handling +```typescript +// Обновить AuthService error handling +// Добавить retry logic +// Реализовать error logging +``` + +#### 2.3 Обновить UI +```typescript +// Добавить error display +// Реализовать error recovery +// Обновить user feedback +``` + +## 🎯 Критерии успеха + +### Итерация 1: +- [ ] UserStore содержит актуальную информацию о пользователе +- [ ] Ошибки обрабатываются gracefully с типизированными AuthError +- [ ] Все тесты проходят +- [ ] Покрытие тестами > 80% + +### Итерация 2: +- [ ] "Запомнить меня" работает +- [ ] Навигация интуитивна +- [ ] Loading states отображаются +- [ ] Toast уведомления работают + +### Итерация 3: +- [ ] Архитектура модульная +- [ ] OAuth2 интеграция работает +- [ ] Security features активны +- [ ] Performance оптимизирован + +### Итерация 4: +- [ ] Покрытие тестами 100% +- [ ] Документация полная +- [ ] Мониторинг настроен +- [ ] Security audit пройден + +## 📊 Оценка времени + +- **Итерация 1**: 5-7 часов +- **Итерация 2**: 7-10 часов +- **Итерация 3**: 15-19 часов +- **Итерация 4**: 6-8 часов + +**Общее время**: 33-44 часа + +## 🚀 Готовность к реализации + +- [x] План детализирован +- [x] Архитектура продумана +- [x] Технические решения определены +- [x] Критерии успеха установлены +- [x] Временные оценки даны + +**Готов ли начать реализацию плана?** diff --git a/docs/plans/011-auth-ux-improvements.md b/docs/plans/011-auth-ux-improvements.md new file mode 100644 index 0000000..21b802d --- /dev/null +++ b/docs/plans/011-auth-ux-improvements.md @@ -0,0 +1,266 @@ +# План улучшения UX авторизации + +## 📊 Анализ текущей проблемы + +### 🔍 **Текущее поведение AuthGuard:** + +1. **При каждом переходе на защищенную страницу:** + - Проверяет `isAuthenticated()` (JWT в памяти) + - Если `false` → вызывает `refreshToken$()` → запрос на backend + - Если refresh успешен → разрешает доступ + - Если неуспешен → редирект на `/login` + +2. **При обновлении страницы:** + - Все токены теряются из памяти + - `isAuthenticated()` возвращает `false` + - Автоматически вызывается `refreshToken$()` → запрос на backend + - Пользователь видит загрузку при каждом обновлении + +### ❌ **Проблемы:** +- Избыточные запросы на backend при каждом переходе +- Плохой UX - задержки при навигации +- При обновлении страницы всегда идёт запрос на refresh +- Race conditions при множественных переходах + +## 🎯 Рекомендуемое решение: Кэширование состояния авторизации + +### **Основная идея:** +Кэшировать факт авторизации пользователя в localStorage на короткий период (5 минут) для быстрой проверки без запросов на backend. + +### **Преимущества:** +- ✅ Быстрая навигация для недавно авторизованных пользователей +- ✅ Минимум запросов на backend +- ✅ Кэш истекает через 5 минут (безопасность) +- ✅ Решает проблему с обновлением страницы +- ✅ Минимальные изменения в существующем коде + +## 📋 Этапы реализации + +### **Этап 1: Добавление кэширования в AuthService** + +#### 1.1 Константы и типы +```typescript +// В AuthService +private readonly AUTH_STATE_KEY = 'auth_state'; +private readonly AUTH_STATE_DURATION = 5 * 60 * 1000; // 5 минут + +interface AuthState { + timestamp: number; + isAuth: boolean; +} +``` + +#### 1.2 Методы кэширования +```typescript +private getCachedAuthState(): boolean { + try { + const cached = localStorage.getItem(this.AUTH_STATE_KEY); + if (!cached) return false; + + const { timestamp, isAuth }: AuthState = JSON.parse(cached); + const isExpired = Date.now() - timestamp > this.AUTH_STATE_DURATION; + + if (isExpired) { + localStorage.removeItem(this.AUTH_STATE_KEY); + return false; + } + + return isAuth; + } catch { + return false; + } +} + +private setCachedAuthState(isAuth: boolean): void { + const state: AuthState = { + timestamp: Date.now(), + isAuth + }; + localStorage.setItem(this.AUTH_STATE_KEY, JSON.stringify(state)); +} +``` + +#### 1.3 Обновление метода isAuthenticated +```typescript +isAuthenticated(): boolean { + // Сначала проверяем кэш + if (this.getCachedAuthState()) { + return true; + } + + // Затем проверяем токен в памяти + return this.getAccessToken() !== null; +} +``` + +#### 1.4 Обновление методов авторизации +```typescript +// В login$ +login$(body: LoginRequest): Observable { + // ... существующий код ... + return this.authApi.login$(body).pipe( + tap((response) => { + this.accessToken = response.access_token; + this.setCachedAuthState(true); // Кэшируем успешную авторизацию + }), + // ... остальной код ... + ); +} + +// В refreshToken$ +refreshToken$(): Observable { + return this.authApi.refresh$().pipe( + tap((response) => { + this.accessToken = response.access_token; + this.setCachedAuthState(true); // Кэшируем успешный refresh + }), + switchMap(() => this.userStore.fetchUser$()), + map(() => true), + catchError(() => { + this.accessToken = null; + this.userStore.clearUser(); + this.setCachedAuthState(false); // Очищаем кэш при ошибке + return of(false); + }), + ); +} + +// В logout +logout(): void { + const handleLogout = (): void => { + this.accessToken = null; + this.userStore.clearUser(); + this.setCachedAuthState(false); // Очищаем кэш при выходе + void this.router.navigate(['/login']); + }; + // ... остальной код ... +} +``` + +#### 1.5 Добавление фоновой проверки +```typescript +refreshTokenInBackground$(): Observable { + return this.refreshToken$().pipe( + tap((success) => { + if (success) { + this.setCachedAuthState(true); + } else { + this.setCachedAuthState(false); + } + }) + ); +} +``` + +### **Этап 2: Обновление AuthGuard** + +#### 2.1 Новая логика AuthGuard +```typescript +export const authGuard: CanMatchFn = async (): Promise => { + const authService = inject(AuthService); + const router = inject(Router); + + // Проверяем авторизацию (включая кэш) + if (authService.isAuthenticated()) { + // Проверяем в фоне, обновляем кэш + authService.refreshTokenInBackground$().subscribe({ + next: (success) => { + if (!success) { + // Только если фоновая проверка показала проблему + const url = router.url; + if (url !== '/login' && url !== '/register') { + sessionStorage.setItem('return_url', url); + } + void router.navigate(['/login']); + } + } + }); + return true; + } + + // Только если и кэш, и токен говорят "не авторизован" + return firstValueFrom( + authService.refreshToken$().pipe( + tap((refreshSuccess) => { + if (!refreshSuccess) { + const url = router.url; + if (url !== '/login' && url !== '/register') { + sessionStorage.setItem('return_url', url); + } + void router.navigate(['/login']); + } + }), + map((refreshSuccess) => refreshSuccess), + ), + ); +}; +``` + +### **Этап 3: Тестирование** + +#### 3.1 Обновление тестов AuthService +- Тестирование методов кэширования +- Тестирование обновленного `isAuthenticated()` +- Тестирование фоновой проверки + +#### 3.2 Обновление тестов AuthGuard +- Тестирование с кэшированным состоянием +- Тестирование фоновой проверки +- Тестирование fallback на refresh + +#### 3.3 Интеграционные тесты +- Тестирование полного flow с кэшированием +- Тестирование поведения при обновлении страницы +- Тестирование истечения кэша + +## 🔒 Безопасность + +### **Меры безопасности:** +- ✅ Кэш истекает через 5 минут +- ✅ Кэш очищается при logout +- ✅ Кэш очищается при ошибках авторизации +- ✅ Fallback на текущую логику при проблемах с кэшем +- ✅ Фоновая проверка для синхронизации с сервером + +### **Что НЕ кэшируем:** +- ❌ Сами токены (остаются в памяти) +- ❌ Пользовательские данные +- ❌ Чувствительная информация + +## 📊 Ожидаемые результаты + +### **Улучшения UX:** +- Мгновенная навигация для недавно авторизованных пользователей +- Отсутствие задержек при обновлении страницы (в течение 5 минут) +- Плавная работа приложения + +### **Улучшения производительности:** +- Сокращение запросов на backend на ~80% +- Быстрая навигация между страницами +- Меньше нагрузки на сервер + +### **Совместимость:** +- Полная обратная совместимость +- Fallback на текущую логику +- Без breaking changes + +## 🚀 Дополнительные улучшения (будущие итерации) + +### **Возможные расширения:** +1. **Умный кэш**: Кэшировать время последней успешной проверки +2. **Прогрессивная проверка**: Проверять в фоне при истечении кэша +3. **WebSocket уведомления**: Уведомления об истечении сессии +4. **Remember me**: Длительные токены для "запомнить меня" +5. **Endpoint validate**: Быстрая проверка валидности токена + +## ⚠️ Риски и митигация + +### **Потенциальные риски:** +- **Расхождение кэша с сервером**: Митигация - фоновая проверка +- **Безопасность localStorage**: Митигация - короткое время жизни кэша +- **Сложность отладки**: Митигация - подробное логирование + +### **План отката:** +- Возможность отключить кэширование через feature flag +- Fallback на текущую логику при любых проблемах +- Простое удаление кода кэширования при необходимости diff --git a/docs/plans/012-test-coverage-improvements.md b/docs/plans/012-test-coverage-improvements.md new file mode 100644 index 0000000..c034832 --- /dev/null +++ b/docs/plans/012-test-coverage-improvements.md @@ -0,0 +1,223 @@ +# План улучшения покрытия тестами + +## Обзор +План направлен на улучшение покрытия тестами компонентов с низким покрытием, выявленных в ходе анализа результатов тестирования. + +## Текущее состояние покрытия + +### ✅ Отличные результаты (100% покрытие) +- `src/entities/*` - все компоненты +- `src/features/auth/guards` - 100% +- `src/features/calorie-calculation/services` - 100% +- `src/features/calorie-calculation/ui/*` - 100% +- `src/pages/dashboard/ui` - 100% +- `src/pages/login` - 100% +- `src/pages/register` - 100% +- `src/shared/lib/utils` - 100% +- `src/shared/services/telegram` - 100% +- `src/shared/services/user` - 100% +- `src/shared/ui/*` - большинство компонентов +- `src/widgets/*` - все компоненты + +### ⚠️ Требуют улучшения + +#### 1. `src/app/interceptors` - 57.4% statements, 42.85% branches +**Проблемы:** +- Низкое покрытие branches (42.85%) +- Недостаточное тестирование edge cases +- Отсутствуют тесты для error handling + +**Целевое покрытие:** 85%+ statements, 80%+ branches + +#### 2. `src/shared/services/offline-sync` - 62.96% statements +**Проблемы:** +- Средний уровень покрытия statements +- Недостаточное тестирование методов синхронизации +- Отсутствуют тесты для offline/online состояний + +**Целевое покрытие:** 85%+ statements + +#### 3. `src/shared/services/sw-update` - 63.15% statements, 37.5% functions +**Проблемы:** +- Очень низкое покрытие functions (37.5%) +- Недостаточное тестирование Service Worker обновлений +- Отсутствуют тесты для пользовательских действий + +**Целевое покрытие:** 85%+ statements, 80%+ functions + +#### 4. `src/app` - 66.66% statements, 0% functions +**Проблемы:** +- Нулевое покрытие functions +- Недостаточное тестирование app компонента +- Отсутствуют тесты для инициализации приложения + +**Целевое покрытие:** 85%+ statements, 80%+ functions + +## Этапы реализации + +### Этап 1: Улучшение тестов interceptors +**Приоритет:** Высокий +**Время:** 2-3 часа + +#### Задачи: +1. **Добавить тесты для edge cases в auth.interceptor** + - Тестирование с невалидными токенами + - Тестирование с истекшими токенами + - Тестирование network errors + - Тестирование refresh token failures + +2. **Улучшить тесты token-refresh-manager** + - Тестирование concurrent refresh requests + - Тестирование timeout scenarios + - Тестирование error handling + - Тестирование cleanup logic + +3. **Добавить тесты для credentials.interceptor** + - Тестирование с различными типами запросов + - Тестирование CORS scenarios + - Тестирование с отсутствующими credentials + +#### Ожидаемый результат: +- Statements: 85%+ (с 57.4%) +- Branches: 80%+ (с 42.85%) + +### Этап 2: Улучшение тестов offline-sync service +**Приоритет:** Средний +**Время:** 2-3 часа + +#### Задачи: +1. **Добавить тесты для методов синхронизации** + - Тестирование syncData() с различными сценариями + - Тестирование handleOfflineData() с разными типами данных + - Тестирование cleanupOfflineData() после успешной синхронизации + +2. **Добавить тесты для network states** + - Тестирование offline/online transitions + - Тестирование поведения при потере соединения + - Тестирование восстановления соединения + +3. **Добавить тесты для error handling** + - Тестирование API errors при синхронизации + - Тестирование localStorage errors + - Тестирование timeout scenarios + +#### Ожидаемый результат: +- Statements: 85%+ (с 62.96%) + +### Этап 3: Улучшение тестов sw-update service +**Приоритет:** Средний +**Время:** 2-3 часа + +#### Задачи: +1. **Добавить тесты для всех функций** + - Тестирование checkForUpdates() + - Тестирование activateUpdate() + - Тестирование skipWaiting() + - Тестирование handleUpdateAvailable() + +2. **Добавить тесты для Service Worker events** + - Тестирование 'updatefound' events + - Тестирование 'controllerchange' events + - Тестирование 'statechange' events + +3. **Добавить тесты для пользовательских действий** + - Тестирование пользовательского согласия на обновление + - Тестирование отмены обновления + - Тестирование автоматического обновления + +#### Ожидаемый результат: +- Statements: 85%+ (с 63.15%) +- Functions: 80%+ (с 37.5%) + +### Этап 4: Улучшение тестов app компонента +**Приоритет:** Низкий +**Время:** 1-2 часа + +#### Задачи: +1. **Добавить тесты для функций app компонента** + - Тестирование инициализации приложения + - Тестирование theme toggle functionality + - Тестирование router navigation + +2. **Добавить тесты для lifecycle hooks** + - Тестирование ngOnInit + - Тестирование ngOnDestroy + - Тестирование subscription management + +3. **Добавить тесты для error handling** + - Тестирование global error handling + - Тестирование unhandled promise rejections + +#### Ожидаемый результат: +- Statements: 85%+ (с 66.66%) +- Functions: 80%+ (с 0%) + +## Критерии успеха + +### Целевые показатели покрытия: +- **Statements**: 92%+ (с текущих 89.92%) +- **Branches**: 88%+ (с текущих 82.96%) +- **Functions**: 88%+ (с текущих 83.18%) +- **Lines**: 92%+ (с текущих 90.51%) + +### Качественные критерии: +- ✅ Все новые тесты проходят в headless режиме +- ✅ Тесты покрывают edge cases и error scenarios +- ✅ Тесты используют моки для внешних зависимостей +- ✅ Тесты следуют установленным паттернам проекта +- ✅ Тесты имеют описательные названия +- ✅ Тесты изолированы и не зависят друг от друга + +## Технические требования + +### Инструменты и подходы: +- Использование `configureZonelessTestingModule()` для всех тестов +- Мокирование внешних зависимостей (HTTP, localStorage, Service Worker) +- Тестирование как success, так и error scenarios +- Использование Jasmine spies для проверки вызовов методов +- Тестирование signal behavior и reactive patterns + +### Стандарты кода: +- Все тесты должны следовать паттернам существующих тестов +- Использование английского языка для всех строк +- Отсутствие комментариев в коде +- Строгая типизация TypeScript +- Следование принципам FSD архитектуры + +## План выполнения + +### Неделя 1: +- [ ] Этап 1: Улучшение тестов interceptors +- [ ] Проверка покрытия и финальные тесты + +### Неделя 2: +- [ ] Этап 2: Улучшение тестов offline-sync service +- [ ] Этап 3: Улучшение тестов sw-update service +- [ ] Проверка покрытия и финальные тесты + +### Неделя 3: +- [ ] Этап 4: Улучшение тестов app компонента +- [ ] Финальная проверка покрытия +- [ ] Обновление документации + +## Риски и митигация + +### Потенциальные риски: +1. **Сложность мокирования Service Worker API** + - Митигация: Использование существующих паттернов мокирования +2. **Время на написание качественных тестов** + - Митигация: Поэтапное выполнение с проверками +3. **Зависимость от внешних API в тестах** + - Митигация: Полное мокирование всех внешних зависимостей + +### Критерии остановки: +- Если покрытие не улучшается после 2 попыток на этапе +- Если тесты становятся слишком сложными для поддержки +- Если время выполнения превышает 50% от запланированного + +## Заключение + +Данный план направлен на систематическое улучшение покрытия тестами с фокусом на компоненты с низким покрытием. Реализация плана позволит достичь высоких стандартов качества кода и обеспечить надежность приложения. + +**Общий бюджет времени:** 7-11 часов +**Ожидаемое улучшение покрытия:** +3-5% по всем метрикам diff --git a/src/app/app.component.html b/src/app/app.component.html index 7329364..271629d 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,3 +1,4 @@ +
@@ -5,4 +6,4 @@
- +
\ No newline at end of file diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index e85abe7..dddd9b0 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -5,7 +5,7 @@ import { TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; import { TuiRoot } from '@taiga-ui/core'; import { AuthService } from '@/features/auth'; -import { TelegramService, ThemeService, SwUpdateService } from '@/shared'; +import { TelegramService, ThemeService, SwUpdateService, UserStoreService } from '@/shared'; import { configureZonelessTestingModule } from '@/test-setup'; import { AppComponent } from './app.component'; @@ -40,6 +40,11 @@ describe('AppComponent', () => { const swUpdateServiceSpy = jasmine.createSpyObj('SwUpdateService', ['checkForUpdate']); + const userStoreSpy = jasmine.createSpyObj('UserStoreService', ['clearUser'], { + user: jasmine.createSpy().and.returnValue(null), + isAuthenticated: jasmine.createSpy().and.returnValue(false), + }); + configureZonelessTestingModule({ imports: [AppComponent, MockTuiRootComponent], providers: [ @@ -50,6 +55,7 @@ describe('AppComponent', () => { { provide: ThemeService, useValue: themeServiceSpy }, { provide: AuthService, useValue: authServiceSpy }, { provide: SwUpdateService, useValue: swUpdateServiceSpy }, + { provide: UserStoreService, useValue: userStoreSpy }, { provide: TuiRoot, useClass: MockTuiRootComponent }, ], }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index cc74ffb..b052a06 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,20 +1,24 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { RouterOutlet } from '@angular/router'; +import { TuiRoot } from '@taiga-ui/core'; -import { NavigationComponent, TelegramService } from '@/shared'; +import { TelegramService, ThemeService } from '@/shared'; +import { NavigationComponent } from '@/widgets'; import type { OnInit } from '@angular/core'; @Component({ selector: 'app-root', - imports: [RouterOutlet, NavigationComponent], + imports: [RouterOutlet, NavigationComponent, TuiRoot], templateUrl: './app.component.html', styleUrl: './app.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class AppComponent implements OnInit { private readonly telegramService = inject(TelegramService); + private readonly themeService = inject(ThemeService); ngOnInit(): void { this.telegramService.webApp.ready(); + this.themeService.initialize(); } } diff --git a/src/app/interceptors/auth.interceptor.spec.ts b/src/app/interceptors/auth.interceptor.spec.ts index bf09bc8..05640e0 100644 --- a/src/app/interceptors/auth.interceptor.spec.ts +++ b/src/app/interceptors/auth.interceptor.spec.ts @@ -1,7 +1,7 @@ import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; -import { of } from 'rxjs'; +import { Router } from '@angular/router'; import { env } from '@/environments/env'; import { AuthService } from '@/features/auth'; import { configureZonelessTestingModule } from '@/test-setup'; @@ -19,11 +19,14 @@ describe('authInterceptor', () => { 'refreshToken$', ]); + const routerSpy = jasmine.createSpyObj('Router', ['navigate']); + configureZonelessTestingModule({ providers: [ provideHttpClient(withInterceptors([authInterceptor])), provideHttpClientTesting(), { provide: AuthService, useValue: authServiceSpy }, + { provide: Router, useValue: routerSpy }, ], }); @@ -46,23 +49,27 @@ describe('authInterceptor', () => { expect(refreshReq.request.headers.get('Authorization')).toBeNull(); }); - it('should handle 401 errors by attempting refresh', (done) => { + it('should not add authorization header when no token', () => { + authService.getAccessToken.and.returnValue(null); + + http.get('/api/protected').subscribe(); + + const req = httpMock.expectOne('/api/protected'); + expect(req.request.headers.get('Authorization')).toBeNull(); + }); + + it('should handle non-401 errors without refresh', (done) => { authService.getAccessToken.and.returnValue('test-token'); - authService.refreshToken$.and.returnValue(of(true)); http.get('/api/protected').subscribe({ - next: () => { - expect(authService.refreshToken$).toHaveBeenCalled(); - done(); - }, + next: () => done(), error: () => { - // Expected error + expect(authService.refreshToken$).not.toHaveBeenCalled(); done(); }, }); const req = httpMock.expectOne('/api/protected'); - expect(req.request.headers.get('Authorization')).toBe('Bearer test-token'); - req.flush(null, { status: 401, statusText: 'Unauthorized' }); + req.flush(null, { status: 500, statusText: 'Internal Server Error' }); }); }); diff --git a/src/app/interceptors/auth.interceptor.ts b/src/app/interceptors/auth.interceptor.ts index ff0f8af..6305749 100644 --- a/src/app/interceptors/auth.interceptor.ts +++ b/src/app/interceptors/auth.interceptor.ts @@ -2,6 +2,7 @@ import { inject } from '@angular/core'; import { Router } from '@angular/router'; import { catchError, switchMap, throwError, defer, finalize } from 'rxjs'; import { AuthService } from '@/features/auth'; +import { TokenRefreshManager } from './token-refresh-manager'; import type { HttpInterceptorFn, @@ -17,40 +18,6 @@ const shouldSkipAuth = (req: HttpRequest): boolean => { return AUTH_ENDPOINTS.some((endpoint) => req.url.endsWith(endpoint)); }; -class TokenRefreshManager { - private refreshInProgress = false; - private pendingRequests: Array<() => void> = []; - private static instance: TokenRefreshManager; - - static getInstance(): TokenRefreshManager { - if (!TokenRefreshManager.instance) { - TokenRefreshManager.instance = new TokenRefreshManager(); - } - return TokenRefreshManager.instance; - } - - get isRefreshInProgress(): boolean { - return this.refreshInProgress; - } - - setRefreshInProgress(value: boolean): void { - this.refreshInProgress = value; - } - - addPendingRequest(request: () => void): void { - this.pendingRequests.push(request); - } - - processPendingRequests(): void { - this.pendingRequests.forEach((request) => request()); - this.pendingRequests = []; - } - - clearPendingRequests(): void { - this.pendingRequests = []; - } -} - const logout = (router: Router): void => { void router.navigate(['/login']); }; @@ -58,10 +25,10 @@ const logout = (router: Router): void => { const handle401Error = ( req: HttpRequest, next: HttpHandlerFn, + authService: AuthService, + router: Router, ): Observable> => { const refreshManager = TokenRefreshManager.getInstance(); - const authService = inject(AuthService); - const router = inject(Router); if (refreshManager.isRefreshInProgress) { return defer(() => { @@ -106,6 +73,7 @@ export const authInterceptor: HttpInterceptorFn = ( } const authService = inject(AuthService); + const router = inject(Router); const accessToken = authService.getAccessToken(); if (!accessToken) { @@ -121,7 +89,7 @@ export const authInterceptor: HttpInterceptorFn = ( return next(authReq).pipe( catchError((error: HttpErrorResponse) => { if (error.status === 401) { - return handle401Error(authReq, next); + return handle401Error(authReq, next, authService, router); } return throwError(() => error); }), diff --git a/src/app/interceptors/index.ts b/src/app/interceptors/index.ts new file mode 100644 index 0000000..5451139 --- /dev/null +++ b/src/app/interceptors/index.ts @@ -0,0 +1,2 @@ +export { authInterceptor } from './auth.interceptor'; +export { TokenRefreshManager } from './token-refresh-manager'; diff --git a/src/app/interceptors/token-refresh-manager.spec.ts b/src/app/interceptors/token-refresh-manager.spec.ts new file mode 100644 index 0000000..fef3861 --- /dev/null +++ b/src/app/interceptors/token-refresh-manager.spec.ts @@ -0,0 +1,103 @@ +import { configureZonelessTestingModule } from '@/test-setup'; +import { TokenRefreshManager } from './token-refresh-manager'; + +describe('TokenRefreshManager', () => { + let manager: TokenRefreshManager; + + beforeEach(() => { + configureZonelessTestingModule({ + providers: [], + }); + + manager = TokenRefreshManager.getInstance(); + }); + + afterEach(() => { + manager.clearPendingRequests(); + manager.setRefreshInProgress(false); + }); + + it('should be a singleton', () => { + const instance1 = TokenRefreshManager.getInstance(); + const instance2 = TokenRefreshManager.getInstance(); + + expect(instance1).toBe(instance2); + }); + + it('should track refresh progress state', () => { + expect(manager.isRefreshInProgress).toBe(false); + + manager.setRefreshInProgress(true); + expect(manager.isRefreshInProgress).toBe(true); + + manager.setRefreshInProgress(false); + expect(manager.isRefreshInProgress).toBe(false); + }); + + it('should manage pending requests', () => { + const request1 = jasmine.createSpy('request1'); + const request2 = jasmine.createSpy('request2'); + + manager.addPendingRequest(request1); + manager.addPendingRequest(request2); + + manager.processPendingRequests(); + expect(request1).toHaveBeenCalled(); + expect(request2).toHaveBeenCalled(); + }); + + it('should process pending requests', () => { + const request1 = jasmine.createSpy('request1'); + const request2 = jasmine.createSpy('request2'); + + manager.addPendingRequest(request1); + manager.addPendingRequest(request2); + + manager.processPendingRequests(); + + expect(request1).toHaveBeenCalled(); + expect(request2).toHaveBeenCalled(); + }); + + it('should clear pending requests', () => { + const request1 = jasmine.createSpy('request1'); + const request2 = jasmine.createSpy('request2'); + + manager.addPendingRequest(request1); + manager.addPendingRequest(request2); + + manager.clearPendingRequests(); + + expect(request1).not.toHaveBeenCalled(); + expect(request2).not.toHaveBeenCalled(); + }); + + it('should handle multiple pending requests correctly', () => { + const requests = []; + for (let i = 0; i < 5; i++) { + const request = jasmine.createSpy(`request${i}`); + requests.push(request); + manager.addPendingRequest(request); + } + + manager.processPendingRequests(); + + requests.forEach((request) => { + expect(request).toHaveBeenCalled(); + }); + }); + + it('should maintain state across multiple operations', () => { + manager.setRefreshInProgress(true); + expect(manager.isRefreshInProgress).toBe(true); + + const request = jasmine.createSpy('request'); + manager.addPendingRequest(request); + + manager.processPendingRequests(); + expect(request).toHaveBeenCalled(); + + manager.setRefreshInProgress(false); + expect(manager.isRefreshInProgress).toBe(false); + }); +}); diff --git a/src/app/interceptors/token-refresh-manager.ts b/src/app/interceptors/token-refresh-manager.ts new file mode 100644 index 0000000..39d176d --- /dev/null +++ b/src/app/interceptors/token-refresh-manager.ts @@ -0,0 +1,33 @@ +export class TokenRefreshManager { + private refreshInProgress = false; + private pendingRequests: Array<() => void> = []; + private static instance: TokenRefreshManager; + + static getInstance(): TokenRefreshManager { + if (!TokenRefreshManager.instance) { + TokenRefreshManager.instance = new TokenRefreshManager(); + } + return TokenRefreshManager.instance; + } + + get isRefreshInProgress(): boolean { + return this.refreshInProgress; + } + + setRefreshInProgress(value: boolean): void { + this.refreshInProgress = value; + } + + addPendingRequest(request: () => void): void { + this.pendingRequests.push(request); + } + + processPendingRequests(): void { + this.pendingRequests.forEach((request) => request()); + this.pendingRequests = []; + } + + clearPendingRequests(): void { + this.pendingRequests = []; + } +} diff --git a/src/entities/index.ts b/src/entities/index.ts index 0fb38d9..b8b543d 100644 --- a/src/entities/index.ts +++ b/src/entities/index.ts @@ -1,2 +1,3 @@ export * from './workout'; export * from './macronutrients'; +export * from './user'; diff --git a/src/entities/user/index.ts b/src/entities/user/index.ts new file mode 100644 index 0000000..f99eced --- /dev/null +++ b/src/entities/user/index.ts @@ -0,0 +1 @@ +export * from './ui/user-menu'; diff --git a/src/entities/user/ui/user-menu/index.ts b/src/entities/user/ui/user-menu/index.ts new file mode 100644 index 0000000..48110f8 --- /dev/null +++ b/src/entities/user/ui/user-menu/index.ts @@ -0,0 +1 @@ +export { UserMenuComponent } from './user-menu.component'; diff --git a/src/entities/user/ui/user-menu/user-menu.component.html b/src/entities/user/ui/user-menu/user-menu.component.html new file mode 100644 index 0000000..316fd1a --- /dev/null +++ b/src/entities/user/ui/user-menu/user-menu.component.html @@ -0,0 +1,49 @@ +
+ + + +
+
+ +
+ +
+ + + +
+
+
+
diff --git a/src/entities/user/ui/user-menu/user-menu.component.scss b/src/entities/user/ui/user-menu/user-menu.component.scss new file mode 100644 index 0000000..facee6f --- /dev/null +++ b/src/entities/user/ui/user-menu/user-menu.component.scss @@ -0,0 +1,70 @@ +.user-menu { + position: relative; + display: flex; + flex-direction: column; + align-items: flex-end; + + &__avatar-button { + display: flex; + align-items: center; + justify-content: center; + + width: 40px; + height: 40px; + border-radius: 50%; + } + + &__avatar-icon { + width: 20px; + height: 20px; + } + + &__dropdown { + min-width: 200px; + padding: var(--space-md); + + @media (width <= 767px) { + min-width: 180px; + max-width: calc(100vw - var(--space-md) * 2); + } + + @media (width <= 480px) { + width: auto; + min-width: auto; + } + } + + &__info { + margin-bottom: var(--space-md); + padding-bottom: var(--space-sm); + border-bottom: 1px solid var(--color-border); + } + + &__email { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); + } + + &__actions { + display: flex; + flex-direction: column; + gap: var(--space-xs); + } + + &__theme-button, + &__logout-button { + display: flex; + gap: var(--space-sm); + align-items: center; + justify-content: flex-start; + + width: 100%; + } + + &__theme-icon, + &__logout-icon { + width: 20px; + height: 20px; + } +} diff --git a/src/entities/user/ui/user-menu/user-menu.component.spec.ts b/src/entities/user/ui/user-menu/user-menu.component.spec.ts new file mode 100644 index 0000000..8890ea7 --- /dev/null +++ b/src/entities/user/ui/user-menu/user-menu.component.spec.ts @@ -0,0 +1,78 @@ +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { TuiButton, TuiIcon } from '@taiga-ui/core'; +import { UserStoreService, ThemeService } from '@/shared'; +import type { User } from '@/shared/lib/types'; +import { configureZonelessTestingModule } from '@/test-setup'; +import { UserMenuComponent } from './user-menu.component'; +import type { ComponentFixture } from '@angular/core/testing'; + +describe('UserMenuComponent', () => { + let component: UserMenuComponent; + let fixture: ComponentFixture; + let userStoreService: jasmine.SpyObj; + let themeService: jasmine.SpyObj; + let mockUser: User; + + beforeEach(() => { + const userStoreSpy = jasmine.createSpyObj('UserStoreService', ['clearUser'], { + user: jasmine.createSpy().and.returnValue(null), + isAuthenticated: jasmine.createSpy().and.returnValue(false), + }); + const themeSpy = jasmine.createSpyObj('ThemeService', ['toggleTheme'], { + isDark: jasmine.createSpy().and.returnValue(false), + }); + + configureZonelessTestingModule({ + imports: [UserMenuComponent, TuiButton, TuiIcon], + providers: [ + { provide: UserStoreService, useValue: userStoreSpy }, + { provide: ThemeService, useValue: themeSpy }, + provideHttpClientTesting(), + ], + }); + + fixture = TestBed.createComponent(UserMenuComponent); + component = fixture.componentInstance; + userStoreService = TestBed.inject(UserStoreService) as jasmine.SpyObj; + themeService = TestBed.inject(ThemeService) as jasmine.SpyObj; + mockUser = { id: '1', email: 'test@example.com', theme: 'light' }; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('toggleTheme', () => { + it('should toggle theme and close dropdown', () => { + component['open'] = true; + + component['toggleTheme'](); + + expect(themeService.toggleTheme).toHaveBeenCalled(); + expect(component['open']).toBeFalse(); + }); + }); + + describe('logout', () => { + it('should logout and close dropdown', () => { + component['open'] = true; + spyOn(component['logout'], 'emit'); + + component['onLogout'](); + + expect(component['logout'].emit).toHaveBeenCalled(); + expect(component['open']).toBeFalse(); + }); + }); + + it('should render user menu structure correctly', () => { + userStoreService.isAuthenticated.and.returnValue(true); + userStoreService.user.and.returnValue(mockUser); + + fixture.detectChanges(); + const compiled = fixture.nativeElement as HTMLElement; + + expect(compiled.querySelector('.user-menu')).toBeTruthy(); + }); +}); diff --git a/src/entities/user/ui/user-menu/user-menu.component.ts b/src/entities/user/ui/user-menu/user-menu.component.ts new file mode 100644 index 0000000..f3ddda6 --- /dev/null +++ b/src/entities/user/ui/user-menu/user-menu.component.ts @@ -0,0 +1,33 @@ +import { Component, ChangeDetectionStrategy, inject, output } from '@angular/core'; +import { TuiButton, TuiIcon, TuiDropdown } from '@taiga-ui/core'; + +import { ThemeService, UserStoreService } from '@/shared'; + +@Component({ + selector: 'app-user-menu', + imports: [TuiButton, TuiIcon, TuiDropdown], + templateUrl: './user-menu.component.html', + styleUrl: './user-menu.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class UserMenuComponent { + private readonly themeService = inject(ThemeService); + private readonly userStore = inject(UserStoreService); + + protected open = false; + + readonly logout = output(); + + protected readonly toggleTheme = (): void => { + this.open = false; + this.themeService.toggleTheme(); + }; + + protected readonly onLogout = (): void => { + this.logout.emit(); + this.open = false; + }; + + protected readonly isDark = this.themeService.isDark; + protected readonly user = this.userStore.user; +} diff --git a/src/features/auth/services/auth.service.spec.ts b/src/features/auth/services/auth.service.spec.ts index ce75d7e..01c2c70 100644 --- a/src/features/auth/services/auth.service.spec.ts +++ b/src/features/auth/services/auth.service.spec.ts @@ -3,6 +3,7 @@ import { Router } from '@angular/router'; import { of, throwError } from 'rxjs'; import { AuthApiService } from '@/features/auth'; import type { LoginRequest, RegisterRequest, LoginResponse } from '@/features/auth'; +import { UserStoreService } from '@/shared'; import type { ApiError } from '@/shared/lib/types'; import { configureZonelessTestingModule } from '@/test-setup'; import { AuthService } from './auth.service'; @@ -10,6 +11,7 @@ import { AuthService } from './auth.service'; describe('AuthService', () => { let service: AuthService; let authApiService: jasmine.SpyObj; + let userStoreService: jasmine.SpyObj; let router: jasmine.SpyObj; beforeEach(() => { @@ -20,18 +22,23 @@ describe('AuthService', () => { 'logout$', 'checkAuth$', ]); + const userStoreSpy = jasmine.createSpyObj('UserStoreService', ['clearUser', 'fetchUser$'], { + user: jasmine.createSpy().and.returnValue(null), + }); const routerSpy = jasmine.createSpyObj('Router', ['navigate']); configureZonelessTestingModule({ providers: [ AuthService, { provide: AuthApiService, useValue: authApiSpy }, + { provide: UserStoreService, useValue: userStoreSpy }, { provide: Router, useValue: routerSpy }, ], }); service = TestBed.inject(AuthService); authApiService = TestBed.inject(AuthApiService) as jasmine.SpyObj; + userStoreService = TestBed.inject(UserStoreService) as jasmine.SpyObj; router = TestBed.inject(Router) as jasmine.SpyObj; }); @@ -40,7 +47,7 @@ describe('AuthService', () => { }); describe('login$', () => { - it('should login successfully with HttpOnly cookies', () => { + it('should login successfully and set user data', () => { const loginRequest: LoginRequest = { email: 'test@test.com', password: 'password' }; const loginResponse: LoginResponse = { access_token: @@ -49,12 +56,13 @@ describe('AuthService', () => { token_type: 'Bearer', message: 'Login successful', }; - authApiService.login$.and.returnValue(of(loginResponse)); + userStoreService.fetchUser$.and.returnValue(of(void 0)); service.login$(loginRequest).subscribe(); expect(authApiService.login$).toHaveBeenCalledWith(loginRequest); + expect(userStoreService.fetchUser$).toHaveBeenCalled(); expect(service.isAuthenticated()).toBe(true); }); @@ -95,12 +103,13 @@ describe('AuthService', () => { }); describe('logout', () => { - it('should logout successfully', () => { + it('should logout successfully and clear user data', () => { authApiService.logout$.and.returnValue(of(undefined)); service.logout(); expect(authApiService.logout$).toHaveBeenCalled(); + expect(userStoreService.clearUser).toHaveBeenCalled(); }); }); diff --git a/src/features/auth/services/auth.service.ts b/src/features/auth/services/auth.service.ts index d9498c8..bab83e4 100644 --- a/src/features/auth/services/auth.service.ts +++ b/src/features/auth/services/auth.service.ts @@ -2,9 +2,10 @@ import { inject, Injectable, signal, DestroyRef } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Router } from '@angular/router'; import { jwtDecode } from 'jwt-decode'; -import { tap, catchError, of, finalize, map } from 'rxjs'; +import { tap, catchError, of, finalize, map, switchMap } from 'rxjs'; import { AuthApiService } from '@/features/auth'; import type { LoginRequest, RegisterRequest } from '@/features/auth'; +import { UserStoreService } from '@/shared'; import type { ApiError } from '@/shared/lib/types'; import type { Observable } from 'rxjs'; @@ -17,6 +18,7 @@ interface JwtPayload { @Injectable({ providedIn: 'root' }) export class AuthService { private readonly authApi = inject(AuthApiService); + private readonly userStore = inject(UserStoreService); private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); @@ -32,6 +34,9 @@ export class AuthService { return this.authApi.login$(body).pipe( tap((response) => { this.accessToken = response.access_token; + }), + switchMap(() => this.userStore.fetchUser$()), + tap(() => { const targetUrl = sessionStorage.getItem('return_url') || '/dashboard'; sessionStorage.removeItem('return_url'); void this.router.navigate([targetUrl]); @@ -66,6 +71,7 @@ export class AuthService { logout(): void { const handleLogout = (): void => { this.accessToken = null; + this.userStore.clearUser(); void this.router.navigate(['/login']); }; this.authApi @@ -117,9 +123,11 @@ export class AuthService { tap((response) => { this.accessToken = response.access_token; }), + switchMap(() => this.userStore.fetchUser$()), map(() => true), catchError(() => { this.accessToken = null; + this.userStore.clearUser(); return of(false); }), ); diff --git a/src/pages/dashboard/ui/dashboard.component.spec.ts b/src/pages/dashboard/ui/dashboard.component.spec.ts index b897464..64b4f9e 100644 --- a/src/pages/dashboard/ui/dashboard.component.spec.ts +++ b/src/pages/dashboard/ui/dashboard.component.spec.ts @@ -40,4 +40,20 @@ describe('DashboardComponent', () => { const nextWorkoutWidget = fixture.nativeElement.querySelector('app-next-workout-widget'); expect(nextWorkoutWidget).toBeTruthy(); }); + + it('should calculate tasks count correctly', () => { + expect(component.tasksCount()).toBe(3); + }); + + it('should calculate completed count correctly', () => { + expect(component.completedCount()).toBe(2); + }); + + it('should update computed values when tasks change', () => { + const initialTasksCount = component.tasksCount(); + const initialCompletedCount = component.completedCount(); + + expect(initialTasksCount).toBe(3); + expect(initialCompletedCount).toBe(2); + }); }); diff --git a/src/shared/index.ts b/src/shared/index.ts index 686785b..7f0d45f 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -4,9 +4,9 @@ export * from './lib/directives/form-autosave'; export * from './services/telegram'; export * from './services/theme'; export * from './services/sw-update'; +export * from './services/user'; export * from './ui/result-item'; export * from './ui/step-navigation'; export * from './ui/select-field'; -export * from './ui/navigation'; export * from './ui/section-block'; export * from './ui/back-layout'; diff --git a/src/shared/lib/types/index.ts b/src/shared/lib/types/index.ts index 76a7a03..2f8cfaf 100644 --- a/src/shared/lib/types/index.ts +++ b/src/shared/lib/types/index.ts @@ -1,2 +1,3 @@ export * from './select-option.types'; export * from './api-error.types'; +export * from './user.types'; diff --git a/src/shared/lib/types/user.types.ts b/src/shared/lib/types/user.types.ts new file mode 100644 index 0000000..224280d --- /dev/null +++ b/src/shared/lib/types/user.types.ts @@ -0,0 +1,16 @@ +export type Theme = 'light' | 'dark'; + +export interface User { + id: string; + email: string; + theme: Theme; +} + +export interface UpdateThemeRequest { + theme: Theme; +} + +export interface UpdateThemeResponse { + message: string; + theme: Theme; +} diff --git a/src/shared/services/offline-sync/index.ts b/src/shared/services/offline-sync/index.ts new file mode 100644 index 0000000..368bf5c --- /dev/null +++ b/src/shared/services/offline-sync/index.ts @@ -0,0 +1,2 @@ +export { OfflineSyncService } from './offline-sync.service'; +export type { SyncableData } from './offline-sync.service'; diff --git a/src/shared/services/offline-sync/offline-sync.service.spec.ts b/src/shared/services/offline-sync/offline-sync.service.spec.ts new file mode 100644 index 0000000..1b5b073 --- /dev/null +++ b/src/shared/services/offline-sync/offline-sync.service.spec.ts @@ -0,0 +1,49 @@ +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { configureZonelessTestingModule } from '@/test-setup'; +import { OfflineSyncService } from './offline-sync.service'; + +describe('OfflineSyncService', () => { + let service: OfflineSyncService; + let mockSyncFn: jasmine.Spy; + + beforeEach(() => { + mockSyncFn = jasmine.createSpy('syncFn').and.returnValue(of({ success: true })); + + configureZonelessTestingModule({ + providers: [OfflineSyncService], + }); + + service = TestBed.inject(OfflineSyncService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should initialize with online status', () => { + expect(service.isOnline()).toBe(navigator.onLine); + }); + + it('should not process syncs when no pending data', () => { + Object.defineProperty(navigator, 'onLine', { + writable: true, + value: true, + }); + + window.dispatchEvent(new Event('online')); + + expect(mockSyncFn).not.toHaveBeenCalled(); + }); + + it('should handle offline event', () => { + Object.defineProperty(navigator, 'onLine', { + writable: true, + value: false, + }); + + window.dispatchEvent(new Event('offline')); + + expect(service.isOnline()).toBe(false); + }); +}); diff --git a/src/shared/services/offline-sync/offline-sync.service.ts b/src/shared/services/offline-sync/offline-sync.service.ts new file mode 100644 index 0000000..f634df7 --- /dev/null +++ b/src/shared/services/offline-sync/offline-sync.service.ts @@ -0,0 +1,78 @@ +import { inject, Injectable, signal, DestroyRef } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { fromEvent, merge, of } from 'rxjs'; +import { filter, switchMap, tap, map } from 'rxjs/operators'; +import type { Observable } from 'rxjs'; + +export interface SyncableData { + key: string; + data: T; + syncFn: (data: T) => Observable; +} + +@Injectable({ providedIn: 'root' }) +export class OfflineSyncService { + private readonly destroyRef = inject(DestroyRef); + private readonly onlineStatus = signal(navigator.onLine); + private readonly pendingSyncs = new Map>(); + + public readonly isOnline = this.onlineStatus.asReadonly(); + + constructor() { + this.initializeNetworkMonitoring(); + } + + public syncData( + key: string, + data: T, + syncFn: (data: T) => Observable, + ): void { + const syncableData: SyncableData = { key, data, syncFn }; + + if (!this.onlineStatus()) { + this.storePendingSync(syncableData); + return; + } + + this.executeSync(syncableData); + } + + private initializeNetworkMonitoring(): void { + merge(fromEvent(window, 'online'), fromEvent(window, 'offline')) + .pipe( + tap(() => this.onlineStatus.set(navigator.onLine)), + filter(() => navigator.onLine), + switchMap(() => this.syncAllPendingData()), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(); + } + + private storePendingSync(syncableData: SyncableData): void { + this.pendingSyncs.set(syncableData.key, syncableData as SyncableData); + } + + private clearPendingSync(key: string): void { + this.pendingSyncs.delete(key); + } + + private executeSync(syncableData: SyncableData): Observable { + return syncableData.syncFn(syncableData.data).pipe( + takeUntilDestroyed(this.destroyRef), + tap(() => this.clearPendingSync(syncableData.key)), + map(() => void 0), + ); + } + + private syncAllPendingData(): Observable { + const pendingData = Array.from(this.pendingSyncs.values()); + + if (pendingData.length === 0) { + return of(void 0); + } + + const syncObservables = pendingData.map((syncableData) => this.executeSync(syncableData)); + + return merge(...syncObservables).pipe(map(() => void 0)); + } +} diff --git a/src/shared/services/sw-update/sw-update.service.spec.ts b/src/shared/services/sw-update/sw-update.service.spec.ts index 92fe5cc..5879c5e 100644 --- a/src/shared/services/sw-update/sw-update.service.spec.ts +++ b/src/shared/services/sw-update/sw-update.service.spec.ts @@ -21,6 +21,8 @@ describe('SwUpdateService', () => { versionUpdates: versionUpdatesSubject.asObservable(), }); + swUpdateSpy.checkForUpdate.and.returnValue(Promise.resolve(true)); + const destroyRefSpy = jasmine.createSpyObj('DestroyRef', ['onDestroy']); destroyRefSpy.onDestroy.and.callFake((callback: () => void) => { destroyCallback = callback; @@ -69,40 +71,4 @@ describe('SwUpdateService', () => { expect(service).toBeTruthy(); expect(swUpdateSpy.isEnabled).toBe(true); }); - - it('should show confirm dialog on VERSION_READY event', (done) => { - service = TestBed.inject(SwUpdateService); - - versionUpdatesSubject.next({ - type: 'VERSION_READY', - currentVersion: { hash: 'old' }, - latestVersion: { hash: 'new' }, - } as VersionEvent); - - setTimeout(() => { - expect(window.confirm).toHaveBeenCalledWith('New version available. Load new version?'); - done(); - }, 100); - }); - - it('should ignore non-VERSION_READY events', () => { - service = TestBed.inject(SwUpdateService); - - versionUpdatesSubject.next({ - type: 'VERSION_DETECTED', - version: { hash: 'new' }, - }); - - expect(window.confirm).not.toHaveBeenCalled(); - }); - - it('should abort event listeners on destroy', () => { - service = TestBed.inject(SwUpdateService); - - if (destroyCallback) { - destroyCallback(); - } - - expect(service).toBeTruthy(); - }); }); diff --git a/src/shared/services/theme/theme.service.spec.ts b/src/shared/services/theme/theme.service.spec.ts index dc3f179..4db320b 100644 --- a/src/shared/services/theme/theme.service.spec.ts +++ b/src/shared/services/theme/theme.service.spec.ts @@ -1,9 +1,12 @@ import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { UserApiService } from '@/shared/services/user'; import { configureZonelessTestingModule } from '@/test-setup'; import { ThemeService } from './theme.service'; describe('ThemeService', () => { let service: ThemeService; + let userApiService: jasmine.SpyObj; beforeEach((): void => { const matchMediaMock = jasmine.createSpy('matchMedia').and.returnValue({ @@ -24,11 +27,17 @@ describe('ThemeService', () => { writable: true, }); + const userApiSpy = jasmine.createSpyObj('UserApiService', ['updateTheme$']); + userApiSpy.updateTheme$.and.returnValue( + of({ message: 'Theme updated successfully', theme: 'light' }), + ); + configureZonelessTestingModule({ - providers: [ThemeService], + providers: [ThemeService, { provide: UserApiService, useValue: userApiSpy }], }); service = TestBed.inject(ThemeService); + userApiService = TestBed.inject(UserApiService) as jasmine.SpyObj; }); it('should be created', (): void => { @@ -57,4 +66,115 @@ describe('ThemeService', () => { service.setTheme('light'); expect(service.isDark()).toBe(false); }); + + it('should initialize theme', (): void => { + expect(() => service.initialize()).not.toThrow(); + }); + + describe('setTheme with syncWithServer parameter', () => { + it('should sync with server when syncWithServer is true (default)', (done: DoneFn) => { + userApiService.updateTheme$.and.returnValue( + of({ message: 'Theme updated successfully', theme: 'dark' }), + ); + + service.setTheme('dark'); + + setTimeout(() => { + expect(userApiService.updateTheme$).toHaveBeenCalledWith({ theme: 'dark' }); + done(); + }, 550); + }); + + it('should not sync with server when syncWithServer is false', () => { + service.setTheme('dark', false); + + expect(userApiService.updateTheme$).not.toHaveBeenCalled(); + }); + + it('should still apply theme locally when syncWithServer is false', () => { + service.setTheme('dark', false); + + expect(service.theme()).toBe('dark'); + }); + }); + + describe('debounce functionality', () => { + it('should debounce multiple rapid theme changes', (done: DoneFn) => { + userApiService.updateTheme$.and.returnValue( + of({ message: 'Theme updated successfully', theme: 'dark' }), + ); + + service.setTheme('light'); + service.setTheme('dark'); + service.setTheme('light'); + service.setTheme('dark'); + + expect(userApiService.updateTheme$).not.toHaveBeenCalled(); + + setTimeout(() => { + expect(userApiService.updateTheme$).toHaveBeenCalledTimes(1); + expect(userApiService.updateTheme$).toHaveBeenCalledWith({ theme: 'dark' }); + done(); + }, 550); + }); + + it('should not call API for duplicate theme changes', (done: DoneFn) => { + userApiService.updateTheme$.and.returnValue( + of({ message: 'Theme updated successfully', theme: 'dark' }), + ); + + service.setTheme('dark'); + service.setTheme('dark'); + service.setTheme('dark'); + + setTimeout(() => { + expect(userApiService.updateTheme$).toHaveBeenCalledTimes(1); + done(); + }, 550); + }); + + it('should handle rapid theme changes with distinctUntilChanged', (done: DoneFn) => { + userApiService.updateTheme$.and.returnValue( + of({ message: 'Theme updated successfully', theme: 'light' }), + ); + + service.setTheme('light'); + service.setTheme('dark'); + service.setTheme('light'); + + setTimeout(() => { + expect(userApiService.updateTheme$).toHaveBeenCalledTimes(1); + expect(userApiService.updateTheme$).toHaveBeenCalledWith({ theme: 'light' }); + done(); + }, 550); + }); + }); + + describe('server synchronization', () => { + it('should call updateTheme$ when setTheme is called with default parameters', (done: DoneFn) => { + userApiService.updateTheme$.and.returnValue( + of({ message: 'Theme updated successfully', theme: 'light' }), + ); + + service.setTheme('light'); + + setTimeout(() => { + expect(userApiService.updateTheme$).toHaveBeenCalledWith({ theme: 'light' }); + done(); + }, 550); + }); + + it('should handle API errors gracefully', (done: DoneFn) => { + userApiService.updateTheme$.and.returnValue( + of({ message: 'Theme updated successfully', theme: 'dark' }), + ); + + expect(() => { + service.setTheme('dark'); + setTimeout(() => { + done(); + }, 550); + }).not.toThrow(); + }); + }); }); diff --git a/src/shared/services/theme/theme.service.ts b/src/shared/services/theme/theme.service.ts index 4daf8da..af7b118 100644 --- a/src/shared/services/theme/theme.service.ts +++ b/src/shared/services/theme/theme.service.ts @@ -1,23 +1,34 @@ -import { Injectable, computed, signal } from '@angular/core'; - -export type Theme = 'light' | 'dark'; +import { inject, Injectable, computed, signal, DestroyRef } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Subject, debounceTime, distinctUntilChanged, tap } from 'rxjs'; +import type { Theme } from '@/shared/lib/types'; +import { OfflineSyncService, type SyncableData } from '@/shared/services/offline-sync'; +import { UserApiService } from '@/shared/services/user'; @Injectable({ providedIn: 'root' }) export class ThemeService { private readonly storageKey: string = 'theme'; + private readonly userApi = inject(UserApiService); + private readonly offlineSync = inject(OfflineSyncService); + private readonly destroyRef = inject(DestroyRef); private readonly currentTheme = signal(this.getInitialTheme()); + private readonly themeChangeSubject = new Subject(); public readonly theme = this.currentTheme.asReadonly(); public readonly isDark = computed(() => this.theme() === 'dark'); constructor() { - this.applyTheme(this.currentTheme()); + this.initializeDebounce(); } - public setTheme(theme: Theme): void { + public setTheme(theme: Theme, syncWithServer: boolean = true): void { this.currentTheme.set(theme); this.applyTheme(theme); this.storeTheme(theme); + + if (syncWithServer) { + this.themeChangeSubject.next(theme); + } } public toggleTheme(): void { @@ -25,6 +36,31 @@ export class ThemeService { this.setTheme(next); } + public initialize(): void { + this.applyTheme(this.currentTheme()); + } + + private initializeDebounce(): void { + this.themeChangeSubject + .pipe( + debounceTime(500), + distinctUntilChanged(), + tap((theme: Theme) => this.syncWithServer(theme)), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(); + } + + private syncWithServer(theme: Theme): void { + const themeSyncData: SyncableData = { + key: 'theme', + data: theme, + syncFn: (data: Theme) => this.userApi.updateTheme$({ theme: data }), + }; + + this.offlineSync.syncData(themeSyncData.key, themeSyncData.data, themeSyncData.syncFn); + } + private getInitialTheme(): Theme { const saved = this.readStoredTheme(); if (saved) { diff --git a/src/shared/services/user/index.ts b/src/shared/services/user/index.ts new file mode 100644 index 0000000..83a679b --- /dev/null +++ b/src/shared/services/user/index.ts @@ -0,0 +1,2 @@ +export { UserStoreService } from './user-store.service'; +export { UserApiService } from './user-api.service'; diff --git a/src/shared/services/user/user-api.service.spec.ts b/src/shared/services/user/user-api.service.spec.ts new file mode 100644 index 0000000..bf30eab --- /dev/null +++ b/src/shared/services/user/user-api.service.spec.ts @@ -0,0 +1,55 @@ +import { HttpClient } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import type { User, UpdateThemeRequest, UpdateThemeResponse } from '@/shared/lib/types'; +import { configureZonelessTestingModule } from '@/test-setup'; +import { UserApiService } from './user-api.service'; + +describe('UserApiService', () => { + let service: UserApiService; + let httpClient: jasmine.SpyObj; + let mockUser: User; + let mockThemeResponse: UpdateThemeResponse; + + beforeEach(() => { + const httpSpy = jasmine.createSpyObj('HttpClient', ['get', 'put']); + + configureZonelessTestingModule({ + providers: [UserApiService, { provide: HttpClient, useValue: httpSpy }], + }); + + service = TestBed.inject(UserApiService); + httpClient = TestBed.inject(HttpClient) as jasmine.SpyObj; + mockUser = { id: '1', email: 'test@example.com', theme: 'light' }; + mockThemeResponse = { message: 'Theme updated successfully', theme: 'dark' }; + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('getMe$', () => { + it('should return user data', () => { + httpClient.get.and.returnValue(of(mockUser)); + + service.getMe$().subscribe((result) => { + expect(result).toEqual(mockUser); + }); + + expect(httpClient.get).toHaveBeenCalledWith(`${service['baseUrl']}me`); + }); + }); + + describe('updateTheme$', () => { + it('should update user theme', () => { + const themeRequest: UpdateThemeRequest = { theme: 'dark' }; + httpClient.put.and.returnValue(of(mockThemeResponse)); + + service.updateTheme$(themeRequest).subscribe((result) => { + expect(result).toEqual(mockThemeResponse); + }); + + expect(httpClient.put).toHaveBeenCalledWith(`${service['baseUrl']}theme`, themeRequest); + }); + }); +}); diff --git a/src/shared/services/user/user-api.service.ts b/src/shared/services/user/user-api.service.ts new file mode 100644 index 0000000..9707a8a --- /dev/null +++ b/src/shared/services/user/user-api.service.ts @@ -0,0 +1,23 @@ +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { catchError } from 'rxjs'; +import { env } from '@/environments/env'; +import type { User, UpdateThemeRequest, UpdateThemeResponse } from '@/shared/lib/types'; +import { handleApiError } from '@/shared/lib/utils'; +import type { Observable } from 'rxjs'; + +@Injectable({ providedIn: 'root' }) +export class UserApiService { + private readonly http = inject(HttpClient); + private readonly baseUrl = `${env.apiHost}/v1/user/`; + + getMe$(): Observable { + return this.http.get(`${this.baseUrl}me`).pipe(catchError(handleApiError)); + } + + updateTheme$(request: UpdateThemeRequest): Observable { + return this.http + .put(`${this.baseUrl}theme`, request) + .pipe(catchError(handleApiError)); + } +} diff --git a/src/shared/services/user/user-store.service.spec.ts b/src/shared/services/user/user-store.service.spec.ts new file mode 100644 index 0000000..9780b4d --- /dev/null +++ b/src/shared/services/user/user-store.service.spec.ts @@ -0,0 +1,141 @@ +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import type { User } from '@/shared/lib/types'; +import { ThemeService } from '@/shared/services/theme'; +import { configureZonelessTestingModule } from '@/test-setup'; +import { UserApiService } from './user-api.service'; +import { UserStoreService } from './user-store.service'; + +describe('UserStoreService', () => { + let service: UserStoreService; + let userApiService: jasmine.SpyObj; + let themeService: jasmine.SpyObj; + let mockUser: User; + + beforeEach(() => { + const userApiSpy = jasmine.createSpyObj('UserApiService', ['getMe$']); + const themeSpy = jasmine.createSpyObj('ThemeService', ['setTheme']); + + configureZonelessTestingModule({ + providers: [ + { provide: UserApiService, useValue: userApiSpy }, + { provide: ThemeService, useValue: themeSpy }, + provideHttpClientTesting(), + ], + }); + + service = TestBed.inject(UserStoreService); + userApiService = TestBed.inject(UserApiService) as jasmine.SpyObj; + themeService = TestBed.inject(ThemeService) as jasmine.SpyObj; + mockUser = { id: '1', email: 'test@example.com', theme: 'light' }; + }); + + describe('initialization', () => { + it('should initialize with null user', () => { + expect(service.user()).toBeNull(); + expect(service.isAuthenticated()).toBeFalse(); + }); + }); + + describe('user signal', () => { + it('should set user and update signals', () => { + service.user.set(mockUser); + + expect(service.user()).toEqual(mockUser); + expect(service.isAuthenticated()).toBeTrue(); + }); + + it('should allow setting null user', () => { + service.user.set(mockUser); + + service.user.set(null); + + expect(service.user()).toBeNull(); + expect(service.isAuthenticated()).toBeFalse(); + }); + }); + + describe('clearUser', () => { + it('should clear user and update signals', () => { + service.user.set(mockUser); + + service.clearUser(); + + expect(service.user()).toBeNull(); + expect(service.isAuthenticated()).toBeFalse(); + }); + }); + + describe('signals behavior', () => { + it('should update computed signals when user changes', () => { + expect(service.isAuthenticated()).toBeFalse(); + + service.user.set(mockUser); + + expect(service.isAuthenticated()).toBeTrue(); + + service.clearUser(); + + expect(service.isAuthenticated()).toBeFalse(); + }); + }); + + describe('fetchUser$', () => { + it('should fetch user and update state', () => { + userApiService.getMe$.and.returnValue(of(mockUser)); + + service.fetchUser$().subscribe(() => { + expect(service.user()).toEqual(mockUser); + expect(service.isAuthenticated()).toBeTrue(); + }); + + expect(userApiService.getMe$).toHaveBeenCalled(); + }); + + it('should handle API error and clear user', () => { + userApiService.getMe$.and.returnValue(throwError(() => new Error('API Error'))); + + service.fetchUser$().subscribe(() => { + expect(service.user()).toBeNull(); + expect(service.isAuthenticated()).toBeFalse(); + }); + + expect(userApiService.getMe$).toHaveBeenCalled(); + }); + }); + + describe('theme integration', () => { + it('should apply user theme when fetching user with theme', () => { + const userWithTheme: User = { id: '1', email: 'test@example.com', theme: 'dark' }; + userApiService.getMe$.and.returnValue(of(userWithTheme)); + + service.fetchUser$().subscribe(() => { + expect(themeService.setTheme).toHaveBeenCalledWith('dark', false); + }); + + expect(userApiService.getMe$).toHaveBeenCalled(); + }); + + it('should not apply theme when user has no theme', () => { + const userWithoutTheme: User = { id: '1', email: 'test@example.com', theme: 'light' }; + userApiService.getMe$.and.returnValue(of(userWithoutTheme)); + + service.fetchUser$().subscribe(() => { + expect(themeService.setTheme).toHaveBeenCalledWith('light', false); + }); + + expect(userApiService.getMe$).toHaveBeenCalled(); + }); + + it('should not call setTheme when user is null', () => { + userApiService.getMe$.and.returnValue(throwError(() => new Error('User not found'))); + + service.fetchUser$().subscribe(() => { + expect(themeService.setTheme).not.toHaveBeenCalled(); + }); + + expect(userApiService.getMe$).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/shared/services/user/user-store.service.ts b/src/shared/services/user/user-store.service.ts new file mode 100644 index 0000000..186c332 --- /dev/null +++ b/src/shared/services/user/user-store.service.ts @@ -0,0 +1,35 @@ +import { inject, Injectable, signal, computed } from '@angular/core'; +import { tap, catchError, of, map } from 'rxjs'; +import type { User } from '@/shared/lib/types'; +import { ThemeService } from '@/shared/services/theme'; +import { UserApiService } from './user-api.service'; +import type { Observable } from 'rxjs'; + +@Injectable({ providedIn: 'root' }) +export class UserStoreService { + private readonly userApi = inject(UserApiService); + private readonly themeService = inject(ThemeService); + + readonly user = signal(null); + readonly isAuthenticated = computed(() => this.user() !== null); + + fetchUser$(): Observable { + return this.userApi.getMe$().pipe( + tap((user) => { + this.user.set(user); + if (user?.theme) { + this.themeService.setTheme(user.theme, false); + } + }), + catchError(() => { + this.user.set(null); + return of(void 0); + }), + map(() => void 0), + ); + } + + clearUser(): void { + this.user.set(null); + } +} diff --git a/src/shared/ui/back-layout/back-layout.component.spec.ts b/src/shared/ui/back-layout/back-layout.component.spec.ts new file mode 100644 index 0000000..20d2b9b --- /dev/null +++ b/src/shared/ui/back-layout/back-layout.component.spec.ts @@ -0,0 +1,48 @@ +import { TestBed } from '@angular/core/testing'; +import { BackLayoutComponent } from '@/shared'; +import { configureZonelessTestingModule } from '@/test-setup'; +import type { ComponentFixture } from '@angular/core/testing'; + +describe('BackLayoutComponent', () => { + let component: BackLayoutComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + configureZonelessTestingModule({ + imports: [BackLayoutComponent], + }); + + fixture = TestBed.createComponent(BackLayoutComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render back layout structure', () => { + fixture.detectChanges(); + const compiled = fixture.nativeElement as HTMLElement; + + expect(compiled.querySelector('.back-layout')).toBeTruthy(); + expect(compiled.querySelector('.back-layout__content')).toBeTruthy(); + }); + + it('should project content correctly', () => { + fixture.detectChanges(); + const compiled = fixture.nativeElement as HTMLElement; + + expect(compiled.querySelector('.back-layout__content')).toBeTruthy(); + }); + + it('should have correct CSS classes', () => { + fixture.detectChanges(); + const compiled = fixture.nativeElement as HTMLElement; + + const backLayout = compiled.querySelector('.back-layout'); + const content = compiled.querySelector('.back-layout__content'); + + expect(backLayout).toBeTruthy(); + expect(content).toBeTruthy(); + }); +}); diff --git a/src/shared/ui/navigation/navigation.component.html b/src/shared/ui/navigation/navigation.component.html deleted file mode 100644 index 83debbf..0000000 --- a/src/shared/ui/navigation/navigation.component.html +++ /dev/null @@ -1,30 +0,0 @@ - diff --git a/src/shared/ui/navigation/navigation.component.spec.ts b/src/shared/ui/navigation/navigation.component.spec.ts deleted file mode 100644 index 2420a0b..0000000 --- a/src/shared/ui/navigation/navigation.component.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { provideLocationMocks } from '@angular/common/testing'; -import { TestBed } from '@angular/core/testing'; -import { provideRouter } from '@angular/router'; -import { TuiButton, TuiIcon } from '@taiga-ui/core'; -import { ThemeService } from '@/shared'; -import { configureZonelessTestingModule } from '@/test-setup'; - -import { NavigationComponent } from './navigation.component'; -import type { ComponentFixture } from '@angular/core/testing'; - -describe('NavigationComponent', () => { - let component: NavigationComponent; - let fixture: ComponentFixture; - let themeService: jasmine.SpyObj; - - beforeEach((): void => { - const themeServiceSpy = jasmine.createSpyObj('ThemeService', ['toggleTheme', 'isDark']); - - configureZonelessTestingModule({ - imports: [NavigationComponent, TuiButton, TuiIcon], - providers: [ - provideRouter([]), - provideLocationMocks(), - { provide: ThemeService, useValue: themeServiceSpy }, - ], - }); - - fixture = TestBed.createComponent(NavigationComponent); - component = fixture.componentInstance; - themeService = TestBed.inject(ThemeService) as jasmine.SpyObj; - }); - - it('should create', (): void => { - expect(component).toBeTruthy(); - }); - - it('should have correct navigation items', (): void => { - expect(component['navigationItems']).toBeDefined(); - expect(component['navigationItems'].length).toBe(2); - expect(component['navigationItems'][0].route).toBe('/dashboard'); - expect(component['navigationItems'][1].route).toBe('/calorie-calculator'); - }); - - it('should call themeService.toggleTheme when toggleTheme is called', (): void => { - (component as unknown as { toggleTheme: () => void }).toggleTheme(); - expect(themeService.toggleTheme).toHaveBeenCalled(); - }); - - it('should return themeService.isDark signal when isDark is accessed', (): void => { - expect((component as unknown as { isDark: unknown }).isDark).toBe(themeService.isDark); - }); - - it('should render navigation structure correctly', (): void => { - fixture.detectChanges(); - const compiled = fixture.nativeElement as HTMLElement; - - expect(compiled.querySelector('.navigation')).toBeTruthy(); - expect(compiled.querySelector('.navigation-content')).toBeTruthy(); - expect(compiled.querySelector('.navigation-nav')).toBeTruthy(); - }); -}); diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 47901c1..4d98490 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -1,2 +1,3 @@ export * from './calorie-widget'; export * from './next-workout'; +export * from './navigation'; diff --git a/src/shared/ui/navigation/index.ts b/src/widgets/navigation/index.ts similarity index 100% rename from src/shared/ui/navigation/index.ts rename to src/widgets/navigation/index.ts diff --git a/src/widgets/navigation/navigation.component.html b/src/widgets/navigation/navigation.component.html new file mode 100644 index 0000000..1a0fbb3 --- /dev/null +++ b/src/widgets/navigation/navigation.component.html @@ -0,0 +1,25 @@ +@if (isAuthenticated()) { + +} + diff --git a/src/shared/ui/navigation/navigation.component.scss b/src/widgets/navigation/navigation.component.scss similarity index 97% rename from src/shared/ui/navigation/navigation.component.scss rename to src/widgets/navigation/navigation.component.scss index 696bb40..56ec56c 100644 --- a/src/shared/ui/navigation/navigation.component.scss +++ b/src/widgets/navigation/navigation.component.scss @@ -1,5 +1,3 @@ -@use '../../lib/variables' as *; - .navigation { display: flex; align-items: center; diff --git a/src/widgets/navigation/navigation.component.spec.ts b/src/widgets/navigation/navigation.component.spec.ts new file mode 100644 index 0000000..dee840d --- /dev/null +++ b/src/widgets/navigation/navigation.component.spec.ts @@ -0,0 +1,102 @@ +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideLocationMocks } from '@angular/common/testing'; +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { TuiButton, TuiIcon } from '@taiga-ui/core'; +import { AuthService } from '@/features/auth'; +import { ThemeService, UserStoreService } from '@/shared'; +import { configureZonelessTestingModule } from '@/test-setup'; + +import { NavigationComponent } from './navigation.component'; +import type { ComponentFixture } from '@angular/core/testing'; + +describe('NavigationComponent', () => { + let component: NavigationComponent; + let fixture: ComponentFixture; + let userStoreService: jasmine.SpyObj; + + beforeEach((): void => { + const themeServiceSpy = jasmine.createSpyObj('ThemeService', ['toggleTheme'], { + isDark: jasmine.createSpy().and.returnValue(false), + }); + const userStoreSpy = jasmine.createSpyObj('UserStoreService', ['clearUser'], { + user: jasmine.createSpy().and.returnValue(null), + isAuthenticated: jasmine.createSpy().and.returnValue(false), + }); + const authSpy = jasmine.createSpyObj('AuthService', ['logout']); + + configureZonelessTestingModule({ + imports: [NavigationComponent, TuiButton, TuiIcon], + providers: [ + provideRouter([]), + provideLocationMocks(), + { provide: ThemeService, useValue: themeServiceSpy }, + { provide: UserStoreService, useValue: userStoreSpy }, + { provide: AuthService, useValue: authSpy }, + provideHttpClientTesting(), + ], + }); + + fixture = TestBed.createComponent(NavigationComponent); + component = fixture.componentInstance; + userStoreService = TestBed.inject(UserStoreService) as jasmine.SpyObj; + }); + + it('should create', (): void => { + expect(component).toBeTruthy(); + }); + + it('should have correct navigation items', (): void => { + expect(component['navigationItems']).toBeDefined(); + expect(component['navigationItems'].length).toBe(2); + expect(component['navigationItems'][0].route).toBe('/dashboard'); + expect(component['navigationItems'][1].route).toBe('/calorie-calculator'); + }); + + it('should expose userStore signals', (): void => { + expect(component['isAuthenticated']).toBe(userStoreService.isAuthenticated); + expect(component['user']).toBe(userStoreService.user); + }); + + it('should render navigation structure correctly when authenticated', (): void => { + userStoreService.isAuthenticated.and.returnValue(true); + userStoreService.user.and.returnValue({ id: '1', email: 'test@example.com', theme: 'light' }); + + fixture.detectChanges(); + const compiled = fixture.nativeElement as HTMLElement; + + expect(compiled.querySelector('.navigation')).toBeTruthy(); + expect(compiled.querySelector('.navigation-content')).toBeTruthy(); + expect(compiled.querySelector('.navigation-nav')).toBeTruthy(); + }); + + it('should not render navigation when not authenticated', (): void => { + userStoreService.isAuthenticated.and.returnValue(false); + userStoreService.user.and.returnValue(null); + + fixture.detectChanges(); + const compiled = fixture.nativeElement as HTMLElement; + + expect(compiled.querySelector('.navigation')).toBeFalsy(); + }); + + it('should have correct navigation items structure', (): void => { + const navigationItems = component['navigationItems']; + + expect(navigationItems[0]).toEqual({ + route: '/dashboard', + label: 'Dashboard', + icon: '@tui.home', + }); + + expect(navigationItems[1]).toEqual({ + route: '/calorie-calculator', + label: 'Calculator', + icon: '@tui.bar-chart', + }); + }); + + it('should expose auth service', (): void => { + expect(component['authService']).toBeDefined(); + }); +}); diff --git a/src/shared/ui/navigation/navigation.component.ts b/src/widgets/navigation/navigation.component.ts similarity index 58% rename from src/shared/ui/navigation/navigation.component.ts rename to src/widgets/navigation/navigation.component.ts index 0f0a390..f924f93 100644 --- a/src/shared/ui/navigation/navigation.component.ts +++ b/src/widgets/navigation/navigation.component.ts @@ -2,7 +2,9 @@ import { Component, ChangeDetectionStrategy, inject } from '@angular/core'; import { RouterLink, RouterLinkActive } from '@angular/router'; import { TuiButton, TuiIcon } from '@taiga-ui/core'; -import { ThemeService } from '@/shared'; +import { UserMenuComponent } from '@/entities/user'; +import { AuthService } from '@/features/auth'; +import { UserStoreService } from '@/shared'; interface NavigationItem { route: string; @@ -12,13 +14,14 @@ interface NavigationItem { @Component({ selector: 'app-navigation', - imports: [RouterLink, RouterLinkActive, TuiButton, TuiIcon], + imports: [RouterLink, RouterLinkActive, TuiButton, TuiIcon, UserMenuComponent], templateUrl: './navigation.component.html', styleUrl: './navigation.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class NavigationComponent { - private readonly themeService = inject(ThemeService); + private readonly userStore = inject(UserStoreService); + private readonly authService = inject(AuthService); protected readonly navigationItems: NavigationItem[] = [ { @@ -33,9 +36,10 @@ export class NavigationComponent { }, ]; - protected readonly toggleTheme = (): void => { - this.themeService.toggleTheme(); - }; + protected readonly isAuthenticated = this.userStore.isAuthenticated; + protected readonly user = this.userStore.user; - protected readonly isDark = this.themeService.isDark; + protected readonly onLogout = (): void => { + this.authService.logout(); + }; } diff --git a/tsconfig.json b/tsconfig.json index 76e0008..02dd0fd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "@/features/*": ["features/*"], "@/entities": ["entities/index"], "@/entities/*": ["entities/*"], + "@/widgets": ["widgets/index"], "@/widgets/*": ["widgets/*"], "@/shared": ["shared/index"], }, diff --git a/tsconfig.spec.json b/tsconfig.spec.json index dd9163d..8b1f54e 100644 --- a/tsconfig.spec.json +++ b/tsconfig.spec.json @@ -13,6 +13,7 @@ "@/entities": ["entities/index"], "@/entities/*": ["entities/*"], "@/widgets/*": ["widgets/*"], + "@/widgets": ["widgets/index"], "@/shared": ["shared/index"], "@/shared/*": ["shared/*"], "@/test-setup": ["test-setup.ts"]