/** * client.ts unit tests — Task 1 (TDD RED → GREEN) * * Tests for write client calls added in Plan 03-05: * - createEvent: POSTs to /api/events/create with credentials:'include' * - updateEvent: PATCHes /api/events/:uid/edit * - fetchWritableCalendars: GETs /api/events/writable-calendars, returns WritableCalendar[] * Tests for Zustand store new keys: * - eventFormOpen: boolean, defaults false * - eventFormMode: 'create'|'edit', defaults 'create' * - eventFormUid: string|null, defaults null * - setEventForm: setter for all three * * Plan 06-05: SessionExpiredError detection tests (D-11) * - fetchEvents / createEvent / updateEvent / deleteEvent with opaqueredirect → SessionExpiredError * - fetchEvents / createEvent / updateEvent / deleteEvent with 401 → SessionExpiredError * - 500 response does NOT produce SessionExpiredError * - fetchMe's existing opaqueredirect/401 path now throws SessionExpiredError (unified) */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // ── Plan 06-05: SessionExpiredError detection tests ─────────────────────────── describe('SessionExpiredError — fetchEvents (06-05)', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); }); afterEach(() => { vi.unstubAllGlobals(); }); it('throws SessionExpiredError on opaqueredirect', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, type: 'opaqueredirect', status: 0, } as unknown as Response); const { fetchEvents, SessionExpiredError } = await import('./client.js'); await expect(fetchEvents('2026-06-01', '2026-06-30')).rejects.toBeInstanceOf( SessionExpiredError, ); }); it('throws SessionExpiredError on 401', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, type: 'basic', status: 401, } as unknown as Response); const { fetchEvents, SessionExpiredError } = await import('./client.js'); await expect(fetchEvents('2026-06-01', '2026-06-30')).rejects.toBeInstanceOf( SessionExpiredError, ); }); it('throws a generic Error (not SessionExpiredError) on 500', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, type: 'basic', status: 500, } as unknown as Response); const { fetchEvents, SessionExpiredError } = await import('./client.js'); await expect(fetchEvents('2026-06-01', '2026-06-30')).rejects.toSatisfy( (e: unknown) => e instanceof Error && !(e instanceof SessionExpiredError), ); }); }); describe('SessionExpiredError — createEvent (06-05)', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); }); afterEach(() => { vi.unstubAllGlobals(); }); it('throws SessionExpiredError on 401', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, type: 'basic', status: 401, } as unknown as Response); const { createEvent, SessionExpiredError } = await import('./client.js'); await expect( createEvent({ title: 'Test', allDay: false, start: '2026-06-15T10:00:00', end: '2026-06-15T11:00:00', }), ).rejects.toBeInstanceOf(SessionExpiredError); }); }); describe('SessionExpiredError — updateEvent (06-05)', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); }); afterEach(() => { vi.unstubAllGlobals(); }); it('throws SessionExpiredError on 401', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, type: 'basic', status: 401, } as unknown as Response); const { updateEvent, SessionExpiredError } = await import('./client.js'); await expect( updateEvent('uid-123', { title: 'Test', allDay: false, start: '2026-06-15T10:00:00', end: '2026-06-15T11:00:00', }), ).rejects.toBeInstanceOf(SessionExpiredError); }); }); describe('SessionExpiredError — deleteEvent (06-05)', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); }); afterEach(() => { vi.unstubAllGlobals(); }); it('throws SessionExpiredError on 401', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, type: 'basic', status: 401, } as unknown as Response); const { deleteEvent, SessionExpiredError } = await import('./client.js'); await expect(deleteEvent('uid-to-delete')).rejects.toBeInstanceOf(SessionExpiredError); }); }); describe('SessionExpiredError — fetchMe unified (06-05)', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); }); afterEach(() => { vi.unstubAllGlobals(); }); it('throws SessionExpiredError (instanceof) on opaqueredirect — unified with other wrappers', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, type: 'opaqueredirect', status: 0, } as unknown as Response); const { fetchMe, SessionExpiredError } = await import('./client.js'); await expect(fetchMe()).rejects.toBeInstanceOf(SessionExpiredError); }); it('throws SessionExpiredError on 401 — unified', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, type: 'basic', status: 401, } as unknown as Response); const { fetchMe, SessionExpiredError } = await import('./client.js'); await expect(fetchMe()).rejects.toBeInstanceOf(SessionExpiredError); }); }); // ── createEvent tests ───────────────────────────────────────────────────────── describe('createEvent', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); }); afterEach(() => { vi.unstubAllGlobals(); }); it('POSTs to /api/events/create with credentials:include', async () => { const mockFetch = vi.mocked(fetch); mockFetch.mockResolvedValueOnce({ ok: true, json: () => ({ uid: 'test-uid-123' }), } as unknown as Response); const { createEvent } = await import('./client.js'); const payload = { title: 'Team Meeting', allDay: false, start: '2026-06-15T10:00:00', end: '2026-06-15T11:00:00', recurrence: 'none' as const, }; await createEvent(payload); expect(mockFetch).toHaveBeenCalledWith( '/api/events/create', expect.objectContaining({ method: 'POST', credentials: 'include', // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- expect.objectContaining returns 'any' (Vitest asymmetric matcher); safe in assertion context headers: expect.objectContaining({ 'Content-Type': 'application/json' }), }), ); }); it('returns { uid } from 202 response', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: () => ({ uid: 'returned-uid-456' }), } as unknown as Response); const { createEvent } = await import('./client.js'); const result = await createEvent({ title: 'Test', allDay: true, start: '2026-06-20', end: '2026-06-20', recurrence: 'none' as const, }); expect(result).toEqual({ uid: 'returned-uid-456' }); }); it('throws on non-ok response', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 400, json: () => ({ error: 'Bad Request' }), } as unknown as Response); const { createEvent } = await import('./client.js'); await expect( createEvent({ title: '', allDay: false, start: '2026-06-15T10:00:00', end: '2026-06-15T11:00:00', recurrence: 'none' as const, }), ).rejects.toThrow(); }); }); // ── updateEvent tests ───────────────────────────────────────────────────────── describe('updateEvent', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); }); afterEach(() => { vi.unstubAllGlobals(); }); it('PATCHes /api/events/:uid/edit', async () => { const mockFetch = vi.mocked(fetch); mockFetch.mockResolvedValueOnce({ ok: true, json: () => ({ uid: 'edit-uid-789' }), } as unknown as Response); const { updateEvent } = await import('./client.js'); await updateEvent('edit-uid-789', { title: 'Updated Meeting', allDay: false, start: '2026-06-15T10:00:00', end: '2026-06-15T11:00:00', recurrence: 'weekly' as const, }); expect(mockFetch).toHaveBeenCalledWith( '/api/events/edit-uid-789/edit', expect.objectContaining({ method: 'PATCH', credentials: 'include', }), ); }); it('returns { uid } on success', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: () => ({ uid: 'patched-uid' }), } as unknown as Response); const { updateEvent } = await import('./client.js'); const result = await updateEvent('some-uid', { title: 'Patched', allDay: false, start: '2026-06-15T10:00:00', end: '2026-06-15T11:00:00', recurrence: 'none' as const, }); expect(result).toEqual({ uid: 'patched-uid' }); }); }); // ── fetchWritableCalendars tests ────────────────────────────────────────────── describe('fetchWritableCalendars', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); }); afterEach(() => { vi.unstubAllGlobals(); }); it('GETs /api/events/writable-calendars with credentials:include', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: () => ({ calendars: [ { url: 'https://caldav.fastmail.com/cal1', displayName: 'My Calendar', color: '#4A90D9', isShared: false, }, ], }), } as unknown as Response); const { fetchWritableCalendars } = await import('./client.js'); await fetchWritableCalendars(); expect(vi.mocked(fetch)).toHaveBeenCalledWith( '/api/events/writable-calendars', expect.objectContaining({ credentials: 'include' }), ); }); it('returns WritableCalendar[] from the calendars envelope', async () => { const mockCalendars = [ { url: 'https://caldav.fastmail.com/cal1', displayName: 'My Calendar', color: '#4A90D9', isShared: false, }, { url: 'https://caldav.fastmail.com/cal2', displayName: 'Family', color: '#F25C7A', isShared: true, }, ]; vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: () => ({ calendars: mockCalendars }), } as unknown as Response); const { fetchWritableCalendars } = await import('./client.js'); const result = await fetchWritableCalendars(); expect(result).toEqual(mockCalendars); expect(result).toHaveLength(2); }); it('throws on non-ok response', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 401, } as unknown as Response); const { fetchWritableCalendars } = await import('./client.js'); await expect(fetchWritableCalendars()).rejects.toThrow(); }); }); // ── deleteEvent tests ───────────────────────────────────────────────────────── describe('deleteEvent', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); }); afterEach(() => { vi.unstubAllGlobals(); }); it('DELETEs /api/events/:uid with credentials:include', async () => { const mockFetch = vi.mocked(fetch); mockFetch.mockResolvedValueOnce({ ok: true, json: () => ({}), } as unknown as Response); const { deleteEvent } = await import('./client.js'); await deleteEvent('uid-to-delete'); expect(mockFetch).toHaveBeenCalledWith( '/api/events/uid-to-delete', expect.objectContaining({ method: 'DELETE', credentials: 'include', }), ); }); it('resolves void on success (204/202)', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: () => ({}), } as unknown as Response); const { deleteEvent } = await import('./client.js'); const result = await deleteEvent('uid-abc'); expect(result).toBeUndefined(); }); it('throws on non-ok response', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 404, } as unknown as Response); const { deleteEvent } = await import('./client.js'); await expect(deleteEvent('missing-uid')).rejects.toThrow(); }); }); // ── fetchSyncStatus tests ───────────────────────────────────────────────────── describe('fetchSyncStatus', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); }); afterEach(() => { vi.unstubAllGlobals(); }); it('GETs /api/events/sync-status?uid= with credentials:include', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: () => ({ uid: 'my-uid', status: 'pending' }), } as unknown as Response); const { fetchSyncStatus } = await import('./client.js'); await fetchSyncStatus('my-uid'); expect(vi.mocked(fetch)).toHaveBeenCalledWith( '/api/events/sync-status?uid=my-uid', expect.objectContaining({ credentials: 'include' }), ); }); it('returns SyncStatus object with uid and status', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: () => ({ uid: 'test-uid', status: 'done' }), } as unknown as Response); const { fetchSyncStatus } = await import('./client.js'); const result = await fetchSyncStatus('test-uid'); expect(result).toEqual({ uid: 'test-uid', status: 'done' }); }); it('returns SyncStatus with error field for failed status', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: () => ({ uid: 'fail-uid', status: 'failed', error: '412 Conflict' }), } as unknown as Response); const { fetchSyncStatus } = await import('./client.js'); const result = await fetchSyncStatus('fail-uid'); expect(result).toEqual({ uid: 'fail-uid', status: 'failed', error: '412 Conflict' }); }); it('throws on non-ok response', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 500, } as unknown as Response); const { fetchSyncStatus } = await import('./client.js'); await expect(fetchSyncStatus('any-uid')).rejects.toThrow(); }); }); // ── fetchMe tests ───────────────────────────────────────────────────────────── describe('fetchMe', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); }); afterEach(() => { vi.unstubAllGlobals(); }); it('requests /api/me with credentials:include AND redirect:manual', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, type: 'basic', status: 200, json: () => ({ user: { id: 2, displayName: 'Me', color: '#E8734A' } }), } as unknown as Response); const { fetchMe } = await import('./client.js'); await fetchMe(); expect(vi.mocked(fetch)).toHaveBeenCalledWith( '/api/me', expect.objectContaining({ credentials: 'include', redirect: 'manual' }), ); }); it('returns the user envelope on 200', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, type: 'basic', status: 200, json: () => ({ user: { id: 2, displayName: 'Me', color: '#E8734A' } }), } as unknown as Response); const { fetchMe } = await import('./client.js'); const result = await fetchMe(); expect(result).toEqual({ user: { id: 2, displayName: 'Me', color: '#E8734A' } }); }); it('throws on an opaqueredirect (OIDC guard 302 to Authelia) — the auth-required signal', async () => { // redirect:'manual' surfaces a 302 as an opaqueredirect with status 0 and // ok:false instead of following it (which would hang the fetch). vi.mocked(fetch).mockResolvedValueOnce({ ok: false, type: 'opaqueredirect', status: 0, json: () => { throw new Error('body not accessible on opaqueredirect'); }, } as unknown as Response); const { fetchMe } = await import('./client.js'); await expect(fetchMe()).rejects.toThrow(/authentication required/i); }); it('throws on a 401', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, type: 'basic', status: 401, } as unknown as Response); const { fetchMe } = await import('./client.js'); await expect(fetchMe()).rejects.toThrow(/authentication required/i); }); }); // ── Zustand store new keys tests ────────────────────────────────────────────── describe('calendarStore — eventForm keys', () => { it('eventFormOpen defaults to false', async () => { const { useCalendarStore } = await import('../store/calendarStore.js'); const state = useCalendarStore.getState(); expect(state.eventFormOpen).toBe(false); }); it('eventFormMode defaults to create', async () => { const { useCalendarStore } = await import('../store/calendarStore.js'); const state = useCalendarStore.getState(); expect(state.eventFormMode).toBe('create'); }); it('eventFormUid defaults to null', async () => { const { useCalendarStore } = await import('../store/calendarStore.js'); const state = useCalendarStore.getState(); expect(state.eventFormUid).toBeNull(); }); it('setEventForm(true, edit, some-uid) updates all three keys', async () => { const { useCalendarStore } = await import('../store/calendarStore.js'); useCalendarStore.getState().setEventForm(true, 'edit', 'some-uid'); const state = useCalendarStore.getState(); expect(state.eventFormOpen).toBe(true); expect(state.eventFormMode).toBe('edit'); expect(state.eventFormUid).toBe('some-uid'); }); it('setEventForm(false) closes the form and preserves other defaults', async () => { const { useCalendarStore } = await import('../store/calendarStore.js'); // Open first useCalendarStore.getState().setEventForm(true, 'edit', 'uid-abc'); // Close useCalendarStore.getState().setEventForm(false); const state = useCalendarStore.getState(); expect(state.eventFormOpen).toBe(false); }); it('setEventForm(true) with no mode/uid defaults to create/null', async () => { const { useCalendarStore } = await import('../store/calendarStore.js'); useCalendarStore.getState().setEventForm(true); const state = useCalendarStore.getState(); expect(state.eventFormOpen).toBe(true); expect(state.eventFormMode).toBe('create'); expect(state.eventFormUid).toBeNull(); }); }); describe('calendarStore — delete/sync keys', () => { it('deleteDialogOpen defaults to false', async () => { const { useCalendarStore } = await import('../store/calendarStore.js'); const state = useCalendarStore.getState(); expect(state.deleteDialogOpen).toBe(false); }); it('deleteDialogUid defaults to null', async () => { const { useCalendarStore } = await import('../store/calendarStore.js'); const state = useCalendarStore.getState(); expect(state.deleteDialogUid).toBeNull(); }); it('lastSyncedUid defaults to null', async () => { const { useCalendarStore } = await import('../store/calendarStore.js'); const state = useCalendarStore.getState(); expect(state.lastSyncedUid).toBeNull(); }); it('setDeleteDialog(true, uid) opens dialog with uid', async () => { const { useCalendarStore } = await import('../store/calendarStore.js'); useCalendarStore.getState().setDeleteDialog(true, 'delete-uid-999'); const state = useCalendarStore.getState(); expect(state.deleteDialogOpen).toBe(true); expect(state.deleteDialogUid).toBe('delete-uid-999'); }); it('setDeleteDialog(false) closes dialog', async () => { const { useCalendarStore } = await import('../store/calendarStore.js'); useCalendarStore.getState().setDeleteDialog(true, 'some-uid'); useCalendarStore.getState().setDeleteDialog(false); const state = useCalendarStore.getState(); expect(state.deleteDialogOpen).toBe(false); }); it('setLastSyncedUid sets the UID', async () => { const { useCalendarStore } = await import('../store/calendarStore.js'); useCalendarStore.getState().setLastSyncedUid('synced-uid-abc'); const state = useCalendarStore.getState(); expect(state.lastSyncedUid).toBe('synced-uid-abc'); }); it('setLastSyncedUid(null) clears the UID', async () => { const { useCalendarStore } = await import('../store/calendarStore.js'); useCalendarStore.getState().setLastSyncedUid('some-uid'); useCalendarStore.getState().setLastSyncedUid(null); const state = useCalendarStore.getState(); expect(state.lastSyncedUid).toBeNull(); }); }); // ── Phase 19 (AUTH-LOCAL-08): admin reset-password URL contract ─────────────── // Regression guard for the post-merge blocker: client.ts targeted // /members/:id/reset-password but the API registers /members/:id/password, so the // Admin reset sheet 404'd in production. Unit tests on both sides missed it (API // tests hit the real path directly; PWA tests mock the fetcher). Pin the exact URL. describe('fetchAdminResetPassword — URL contract (Phase 19, AUTH-LOCAL-08)', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); }); afterEach(() => { vi.unstubAllGlobals(); }); it('POSTs to /api/admin/members/:id/password (must match admin.ts route)', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, type: 'basic', status: 200, } as unknown as Response); const { fetchAdminResetPassword } = await import('./client.js'); await fetchAdminResetPassword(7, 'new-password-123'); expect(fetch).toHaveBeenCalledWith( '/api/admin/members/7/password', expect.objectContaining({ method: 'POST' }), ); }); }); // ── Phase 20 (Plan 20-02): updateMemberProfile + AdminMember.isAdmin ───────── // TDD RED: these tests MUST fail before the implementation is added to client.ts. describe('updateMemberProfile — URL + verb contract (Phase 20, Plan 20-02)', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); }); afterEach(() => { vi.unstubAllGlobals(); }); it('PATCHes /api/admin/members/:id (must match PATCH /members/:id in admin.ts)', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, type: 'basic', status: 200, } as unknown as Response); const { updateMemberProfile } = await import('./client.js'); await updateMemberProfile(7, { displayName: 'Alice' }); expect(fetch).toHaveBeenCalledWith( '/api/admin/members/7', expect.objectContaining({ method: 'PATCH' }), ); }); it('sends credentials:include and redirect:manual', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, type: 'basic', status: 200, } as unknown as Response); const { updateMemberProfile } = await import('./client.js'); await updateMemberProfile(3, { isAdmin: true }); expect(fetch).toHaveBeenCalledWith( '/api/admin/members/3', expect.objectContaining({ credentials: 'include', redirect: 'manual', }), ); }); it('resolves void on 200', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, type: 'basic', status: 200, } as unknown as Response); const { updateMemberProfile } = await import('./client.js'); const result = await updateMemberProfile(7, { displayName: 'Bob' }); expect(result).toBeUndefined(); }); it('throws SessionExpiredError on opaqueredirect', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, type: 'opaqueredirect', status: 0, } as unknown as Response); const { updateMemberProfile, SessionExpiredError } = await import('./client.js'); await expect(updateMemberProfile(7, { displayName: 'X' })).rejects.toBeInstanceOf( SessionExpiredError, ); }); it('throws SessionExpiredError on 401', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, type: 'basic', status: 401, } as unknown as Response); const { updateMemberProfile, SessionExpiredError } = await import('./client.js'); await expect(updateMemberProfile(7, { displayName: 'X' })).rejects.toBeInstanceOf( SessionExpiredError, ); }); it('throws Error("last-admin") on 409 (last-admin demotion sentinel)', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, type: 'basic', status: 409, } as unknown as Response); const { updateMemberProfile } = await import('./client.js'); await expect(updateMemberProfile(7, { isAdmin: false })).rejects.toThrow('last-admin'); }); it('throws Error("last-admin") on 422 as well (server may return either)', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, type: 'basic', status: 422, } as unknown as Response); const { updateMemberProfile } = await import('./client.js'); await expect(updateMemberProfile(7, { isAdmin: false })).rejects.toThrow('last-admin'); }); it('throws a generic error on any other non-ok status (not last-admin sentinel)', async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, type: 'basic', status: 500, } as unknown as Response); const { updateMemberProfile, SessionExpiredError } = await import('./client.js'); await expect(updateMemberProfile(7, { displayName: 'X' })).rejects.toSatisfy( (e: unknown) => e instanceof Error && !(e instanceof SessionExpiredError) && e.message !== 'last-admin', ); }); }); describe('AdminMember.isAdmin field (Phase 20, Plan 20-02)', () => { it('AdminMember interface has isAdmin: boolean (compile-time type check via runtime shape)', () => { // Construct a conforming object — TypeScript will error at compile time if // isAdmin is missing from the AdminMember interface (caught by tsc --noEmit). const member: import('./client.js').AdminMember = { id: 1, displayName: 'Test', color: '#abc', isAdmin: true, hasCredential: false, hasLocalCredential: false, }; expect(member.isAdmin).toBe(true); }); });