personal-lifecycle-tracker / frontend / src / services / __tests__ / backendLifecycleService.test.ts
backendLifecycleService.test.ts
Raw
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { BackendLifecycleService } from '../backendLifecycleService';
import { CreateItemDTO, UpdateItemDTO } from '../types';

class MockStorage {
  private store: Record<string, string> = {};
  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<string, string> = {}) {
    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: '<p>Digest</p>',
        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');
    });
  });
});