import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { BackendLifecycleService } from '../backendLifecycleService'; import { CreateItemDTO, UpdateItemDTO } from '../types'; class MockStorage { private store: Record = {}; clear() { this.store = {}; } getItem(key: string) { return this.store[key] ?? null; } setItem(key: string, value: string) { this.store[key] = String(value); } removeItem(key: string) { delete this.store[key]; } } describe('BackendLifecycleService', () => { let service: BackendLifecycleService; const originalFetch = globalThis.fetch; const mockStorage = new MockStorage(); beforeEach(() => { mockStorage.clear(); vi.stubGlobal('localStorage', mockStorage); vi.stubGlobal('window', { localStorage: mockStorage }); service = new BackendLifecycleService('http://test-api:8000'); }); afterEach(() => { globalThis.fetch = originalFetch; vi.restoreAllMocks(); }); function mockFetchJson(status: number, data: unknown, headers: Record = {}) { return vi.fn().mockImplementation(() => Promise.resolve({ ok: status >= 200 && status < 300, status, headers: new Headers({ 'content-type': 'application/json', ...headers }), json: () => Promise.resolve(data), }) ); } describe('Authentication & User Management', () => { it('auto-logs in default seed user on initial load when no session exists', async () => { const mockUser = { id: 'user_alex', email: 'alex@example.com', name: 'Alex Taylor', createdAt: '2026-01-10T08:00:00Z', }; const fetchMock = mockFetchJson(200, { user: mockUser, token: 'test-jwt-token-alex', }); globalThis.fetch = fetchMock; const user = await service.getCurrentUser(); expect(user).toEqual(mockUser); expect(fetchMock).toHaveBeenCalledWith( 'http://test-api:8000/api/v1/auth/login', expect.objectContaining({ method: 'POST', body: JSON.stringify({ email: 'alex@example.com', password: 'password123' }), }) ); }); it('retrieves current user from /me when token is cached', async () => { mockStorage.setItem('plt_auth_token_v1', 'existing-jwt-token'); service = new BackendLifecycleService('http://test-api:8000'); const mockUser = { id: 'user_alex', email: 'alex@example.com', name: 'Alex Taylor', createdAt: '2026-01-10T08:00:00Z', }; const fetchMock = mockFetchJson(200, mockUser); globalThis.fetch = fetchMock; const user = await service.getCurrentUser(); expect(user).toEqual(mockUser); expect(fetchMock).toHaveBeenCalledWith( 'http://test-api:8000/api/v1/auth/me', expect.objectContaining({ method: 'GET', headers: expect.any(Headers), }) ); }); it('returns null if user has explicitly logged out', async () => { mockStorage.setItem('plt_logged_out_v1', 'true'); service = new BackendLifecycleService('http://test-api:8000'); const user = await service.getCurrentUser(); expect(user).toBeNull(); }); it('logs in and attaches bearer token to subsequent requests', async () => { const mockUser = { id: 'user_sarah', email: 'sarah@example.com', name: 'Sarah Jenkins', createdAt: '2026-02-14T09:30:00Z', }; globalThis.fetch = mockFetchJson(200, { user: mockUser, token: 'sarah-token-xyz', }); const user = await service.login('sarah@example.com', 'mypassword'); expect(user).toEqual(mockUser); expect(mockStorage.getItem('plt_auth_token_v1')).toBe('sarah-token-xyz'); // Now verify subsequent request passes Bearer header const fetchItemsMock = mockFetchJson(200, []); globalThis.fetch = fetchItemsMock; await service.getItems(); expect(fetchItemsMock).toHaveBeenCalledWith( 'http://test-api:8000/api/v1/items', expect.objectContaining({ headers: expect.any(Headers), }) ); }); it('signs up a new user and updates session', async () => { const mockNewUser = { id: 'user_new', email: 'newuser@example.com', name: 'New User', createdAt: '2026-10-09T12:00:00Z', }; globalThis.fetch = mockFetchJson(201, { user: mockNewUser, token: 'new-user-token', }); const user = await service.signup('newuser@example.com', 'password123', 'New User'); expect(user).toEqual(mockNewUser); expect(mockStorage.getItem('plt_auth_token_v1')).toBe('new-user-token'); }); it('logs out and clears session state', async () => { mockStorage.setItem('plt_auth_token_v1', 'token-to-clear'); service = new BackendLifecycleService('http://test-api:8000'); globalThis.fetch = mockFetchJson(200, { message: 'Logged out successfully' }); await service.logout(); expect(mockStorage.getItem('plt_auth_token_v1')).toBeNull(); expect(mockStorage.getItem('plt_logged_out_v1')).toBe('true'); }); it('switches user using cached token or login fallback', async () => { const usersList = [ { id: 'user_alex', email: 'alex@example.com', name: 'Alex', createdAt: '2026-01-01' }, { id: 'user_sarah', email: 'sarah@example.com', name: 'Sarah', createdAt: '2026-01-01' }, ]; const fetchMock = vi.fn().mockImplementation((url: string) => { if (url.includes('/api/v1/users')) { return Promise.resolve({ ok: true, status: 200, headers: new Headers({ 'content-type': 'application/json' }), json: () => Promise.resolve(usersList), }); } if (url.includes('/api/v1/auth/login')) { return Promise.resolve({ ok: true, status: 200, headers: new Headers({ 'content-type': 'application/json' }), json: () => Promise.resolve({ user: usersList[1], token: 'sarah-token', }), }); } return Promise.reject(new Error(`Unhandled URL: ${url}`)); }); globalThis.fetch = fetchMock; const switched = await service.switchUser('user_sarah'); expect(switched.id).toBe('user_sarah'); expect(mockStorage.getItem('plt_auth_token_v1')).toBe('sarah-token'); }); it('deletes account and clears session', async () => { mockStorage.setItem('plt_auth_token_v1', 'active-token'); mockStorage.setItem('plt_current_user_id_v1', 'user_to_delete'); service = new BackendLifecycleService('http://test-api:8000'); globalThis.fetch = vi.fn().mockImplementation(() => Promise.resolve({ ok: true, status: 204, headers: new Headers(), json: () => Promise.resolve(), }) ); await service.deleteAccount('user_to_delete'); expect(mockStorage.getItem('plt_auth_token_v1')).toBeNull(); expect(mockStorage.getItem('plt_current_user_id_v1')).toBeNull(); }); }); describe('Lifecycle Items CRUD', () => { it('fetches items with query parameters', async () => { const fetchMock = mockFetchJson(200, []); globalThis.fetch = fetchMock; await service.getItems({ groupId: null, type: 'renewal', searchQuery: 'insurance', }); expect(fetchMock).toHaveBeenCalledWith( 'http://test-api:8000/api/v1/items?groupId=null&type=renewal&searchQuery=insurance', expect.any(Object) ); }); it('creates an item with guided payload', async () => { const dto: CreateItemDTO = { name: 'Home Filter', lifecycleType: 'maintenance', dueDate: '2026-11-01', status: 'active', }; const createdItem = { id: 'item_100', ...dto, createdAt: '2026-10-09', updatedAt: '2026-10-09' }; const fetchMock = mockFetchJson(201, createdItem); globalThis.fetch = fetchMock; const result = await service.createItem(dto); expect(result).toEqual(createdItem); expect(fetchMock).toHaveBeenCalledWith( 'http://test-api:8000/api/v1/items', expect.objectContaining({ method: 'POST', body: JSON.stringify(dto), }) ); }); it('updates an item', async () => { const updateDto: UpdateItemDTO = { name: 'Updated Name' }; const updatedItem = { id: 'item_1', name: 'Updated Name', lifecycleType: 'renewal', status: 'active' }; const fetchMock = mockFetchJson(200, updatedItem); globalThis.fetch = fetchMock; const result = await service.updateItem('item_1', updateDto); expect(result).toEqual(updatedItem); expect(fetchMock).toHaveBeenCalledWith( 'http://test-api:8000/api/v1/items/item_1', expect.objectContaining({ method: 'PATCH', body: JSON.stringify(updateDto), }) ); }); it('toggles item completion', async () => { const completedItem = { id: 'item_1', status: 'completed' }; const fetchMock = mockFetchJson(200, completedItem); globalThis.fetch = fetchMock; const result = await service.toggleItemCompletion('item_1'); expect(result.status).toBe('completed'); expect(fetchMock).toHaveBeenCalledWith( 'http://test-api:8000/api/v1/items/item_1/complete', expect.objectContaining({ method: 'POST' }) ); }); it('deletes an item with 204 response', async () => { const fetchMock = vi.fn().mockImplementation(() => Promise.resolve({ ok: true, status: 204, headers: new Headers(), json: () => Promise.resolve(), }) ); globalThis.fetch = fetchMock; await service.deleteItem('item_1'); expect(fetchMock).toHaveBeenCalledWith( 'http://test-api:8000/api/v1/items/item_1', expect.objectContaining({ method: 'DELETE' }) ); }); }); describe('Groups & Sharing', () => { it('manages groups and members', async () => { const groupData = { id: 'group_test', name: 'Family Home', ownerId: 'user_alex', ownerEmail: 'alex@example.com', memberEmails: ['alex@example.com'], createdAt: '2026-01-01', }; // 1. Create group globalThis.fetch = mockFetchJson(201, groupData); const created = await service.createGroup('Family Home'); expect(created).toEqual(groupData); // 2. Invite member const withMember = { ...groupData, memberEmails: ['alex@example.com', 'guest@example.com'] }; globalThis.fetch = mockFetchJson(200, withMember); const updated = await service.inviteMember('group_test', 'guest@example.com'); expect(updated.memberEmails).toContain('guest@example.com'); // 3. Remove member globalThis.fetch = mockFetchJson(200, groupData); const removed = await service.removeMember('group_test', 'guest@example.com'); expect(removed.memberEmails).not.toContain('guest@example.com'); // 4. Leave group globalThis.fetch = mockFetchJson(200, { message: 'Left group successfully' }); await service.leaveGroup('group_test'); // 5. Delete group globalThis.fetch = vi.fn().mockImplementation(() => Promise.resolve({ ok: true, status: 204, headers: new Headers(), json: () => Promise.resolve(), }) ); await service.deleteGroup('group_test'); }); }); describe('Reminders & Digest', () => { it('fetches summary, config, and generates daily digest', async () => { // 1. Summary const summary = { overdue: [], upcoming: [], totalActiveReminders: 0 }; globalThis.fetch = mockFetchJson(200, summary); const fetchedSummary = await service.getRemindersSummary(); expect(fetchedSummary).toEqual(summary); // 2. Config & update const config = { timeZone: 'UTC', deliveryTime: '08:00', enabled: true }; globalThis.fetch = mockFetchJson(200, config); const fetchedConfig = await service.getDigestConfig(); expect(fetchedConfig.deliveryTime).toBe('08:00'); globalThis.fetch = mockFetchJson(200, { ...config, deliveryTime: '09:00' }); const updatedConfig = await service.updateDigestConfig({ deliveryTime: '09:00' }); expect(updatedConfig.deliveryTime).toBe('09:00'); // 3. Generate digest const digestEmail = { id: 'digest_1', recipientEmail: 'alex@example.com', recipientName: 'Alex Taylor', subject: 'Daily Digest', overdueCount: 0, upcomingCount: 0, overdueItems: [], upcomingItems: [], htmlContent: '

Digest

', textContent: 'Digest', }; globalThis.fetch = mockFetchJson(200, digestEmail); const generated = await service.generateAndSendDailyDigest('alex@example.com'); expect(generated.recipientEmail).toBe('alex@example.com'); }); }); describe('Error Handling', () => { it('extracts error field from backend 400 responses', async () => { globalThis.fetch = mockFetchJson(400, { error: 'Item not found or permission denied' }); await expect(service.getItem('invalid_id')).rejects.toThrow('Item not found or permission denied'); }); it('extracts detail field if error is absent', async () => { globalThis.fetch = mockFetchJson(401, { detail: 'Invalid or expired token' }); await expect(service.getItems()).rejects.toThrow('Invalid or expired token'); }); }); });